├── .gitignore ├── md-images ├── img1.jpg ├── img2.jpg ├── img3.jpg ├── img4.jpg ├── img5.jpg ├── img6.jpg ├── img7.jpg └── img8.jpg ├── public └── favicon.png ├── CHANGELOG.md ├── app ├── plugins │ ├── motion.client.js │ └── auth.client.js ├── app.vue ├── middleware │ └── auth.js ├── utils │ └── clipboard.ts ├── stores │ ├── toast.js │ ├── auth.js │ ├── images.js │ └── settings.js ├── components │ ├── Loading.vue │ ├── ImageViewer.vue │ ├── Modal.vue │ ├── Toast.vue │ ├── ThemeToggle.vue │ ├── Announcement.vue │ └── ImageCard.vue ├── pages │ ├── login.vue │ └── about.vue ├── assets │ └── css │ │ └── main.css └── layouts │ └── default.vue ├── server ├── api │ ├── auth │ │ ├── logout.post.js │ │ ├── verify.get.js │ │ └── login.post.js │ ├── notification │ │ ├── index.get.js │ │ ├── index.put.js │ │ └── test.post.js │ ├── blacklist │ │ ├── index.get.js │ │ ├── [id].delete.js │ │ └── index.post.js │ ├── settings │ │ ├── public.get.js │ │ ├── hard-delete.post.js │ │ ├── index.get.js │ │ ├── index.put.js │ │ └── stats.get.js │ ├── config │ │ ├── private.get.js │ │ ├── private.put.js │ │ ├── public.get.js │ │ └── public.put.js │ ├── apikeys │ │ ├── index.get.js │ │ ├── [id].delete.js │ │ ├── index.post.js │ │ └── [id].put.js │ ├── images │ │ ├── nsfw-clear.post.js │ │ ├── [id].delete.js │ │ ├── [id] │ │ │ └── unmark-nsfw.put.js │ │ ├── batch.delete.js │ │ ├── nsfw.get.js │ │ ├── index.get.js │ │ └── preview │ │ │ └── [...path].js │ ├── admin │ │ ├── username.put.js │ │ └── password.put.js │ ├── version │ │ └── check.get.js │ └── upload │ │ ├── private.post.js │ │ ├── url.post.js │ │ ├── public.post.js │ │ └── urls.post.js ├── utils │ ├── jwt.js │ ├── authMiddleware.js │ ├── db.js │ ├── ipBlacklist.js │ ├── image.js │ ├── upload.js │ └── rateLimit.js ├── plugins │ ├── scheduler.js │ └── database.js └── routes │ └── i │ └── [...path].js ├── docker-compose.yml ├── .dockerignore ├── Dockerfile ├── tailwind.config.js ├── package.json ├── nuxt.config.js ├── .github └── workflows │ └── docker-publish.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .nuxt 2 | node_modules 3 | db 4 | uploads -------------------------------------------------------------------------------- /md-images/img1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/md-images/img1.jpg -------------------------------------------------------------------------------- /md-images/img2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/md-images/img2.jpg -------------------------------------------------------------------------------- /md-images/img3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/md-images/img3.jpg -------------------------------------------------------------------------------- /md-images/img4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/md-images/img4.jpg -------------------------------------------------------------------------------- /md-images/img5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/md-images/img5.jpg -------------------------------------------------------------------------------- /md-images/img6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/md-images/img6.jpg -------------------------------------------------------------------------------- /md-images/img7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/md-images/img7.jpg -------------------------------------------------------------------------------- /md-images/img8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/md-images/img8.jpg -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaos-zhu/easyimg/HEAD/public/favicon.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.0](https://github.com/chaos-zhu/easyimg/releases) (2025-12-14) 2 | * 基本功能完善,初始版本发布 -------------------------------------------------------------------------------- /app/plugins/motion.client.js: -------------------------------------------------------------------------------- 1 | import { MotionPlugin } from '@vueuse/motion' 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | nuxtApp.vueApp.use(MotionPlugin) 5 | }) -------------------------------------------------------------------------------- /server/api/auth/logout.post.js: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | // JWT 是无状态的,登出只需要前端删除 token 即可 3 | // 这里返回成功响应 4 | return { 5 | success: true, 6 | message: '登出成功' 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /app/plugins/auth.client.js: -------------------------------------------------------------------------------- 1 | // 客户端插件:在应用启动时初始化认证状态 2 | import { useAuthStore } from '~/stores/auth' 3 | 4 | export default defineNuxtPlugin(() => { 5 | const authStore = useAuthStore() 6 | 7 | // 从 localStorage 恢复认证状态 8 | authStore.init() 9 | }) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | easyimg: 3 | image: ghcr.io/chaos-zhu/easyimg:latest 4 | container_name: easyimg 5 | restart: unless-stopped 6 | ports: 7 | - "8092:3000" 8 | volumes: 9 | - ./db:/app/db 10 | - ./uploads:/app/uploads 11 | environment: 12 | - NODE_ENV=production 13 | - HOST=0.0.0.0 14 | - PORT=3000 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # 依赖目录 2 | node_modules 3 | 4 | # 构建输出 5 | .output 6 | .nuxt 7 | dist 8 | 9 | # 开发文件 10 | .git 11 | .gitignore 12 | .vscode 13 | .idea 14 | *.md 15 | README* 16 | 17 | # 日志文件 18 | *.log 19 | npm-debug.log* 20 | pnpm-debug.log* 21 | 22 | # 环境变量文件 23 | .env 24 | .env.* 25 | !.env.example 26 | 27 | # 测试文件 28 | test 29 | tests 30 | __tests__ 31 | *.test.js 32 | *.spec.js 33 | 34 | # 数据目录(运行时生成) 35 | db 36 | uploads 37 | 38 | # Docker 相关 39 | Dockerfile 40 | docker-compose*.yml 41 | .dockerignore 42 | 43 | # 其他 44 | .DS_Store 45 | Thumbs.db 46 | *.swp 47 | *.swo 48 | md-images -------------------------------------------------------------------------------- /app/middleware/auth.js: -------------------------------------------------------------------------------- 1 | // 路由守卫中间件 - 保护需要登录的页面 2 | export default defineNuxtRouteMiddleware((to, from) => { 3 | // 需要登录的页面 4 | const protectedRoutes = ['/api', '/settings', '/stats', '/notification'] 5 | 6 | // 检查是否是受保护的路由 7 | const isProtectedRoute = protectedRoutes.some(route => to.path.startsWith(route)) 8 | 9 | if (!isProtectedRoute) { 10 | return 11 | } 12 | 13 | // 仅在客户端检查 14 | if (import.meta.client) { 15 | const token = localStorage.getItem('token') 16 | 17 | if (!token) { 18 | // 未登录,重定向到登录页 19 | return navigateTo('/login') 20 | } 21 | } 22 | }) -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 构建阶段 2 | FROM node:20-alpine AS builder 3 | 4 | # 安装 pnpm 5 | RUN corepack enable && corepack prepare pnpm@latest --activate 6 | 7 | # 设置工作目录 8 | WORKDIR /app 9 | 10 | # 复制包管理文件 11 | COPY package.json pnpm-lock.yaml ./ 12 | 13 | # 安装依赖 14 | RUN pnpm install --frozen-lockfile 15 | 16 | # 复制源代码 17 | COPY . . 18 | 19 | # 构建应用 20 | RUN pnpm build 21 | 22 | # 生产阶段 23 | FROM node:20-alpine AS production 24 | 25 | # 设置工作目录 26 | WORKDIR /app 27 | 28 | # 从构建阶段复制构建产物 29 | COPY --from=builder /app/.output ./.output 30 | 31 | # 暴露端口 32 | EXPOSE 3000 33 | 34 | # 设置环境变量 35 | ENV NODE_ENV=production 36 | ENV HOST=0.0.0.0 37 | ENV PORT=3000 38 | 39 | # 启动应用 40 | CMD ["node", ".output/server/index.mjs"] -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | './app/components/**/*.{js,vue,ts}', 5 | './app/layouts/**/*.vue', 6 | './app/pages/**/*.vue', 7 | './app/plugins/**/*.{js,ts}', 8 | './app/app.vue', 9 | './app/error.vue' 10 | ], 11 | darkMode: 'class', 12 | theme: { 13 | extend: { 14 | colors: { 15 | primary: { 16 | 50: '#eff6ff', 17 | 100: '#dbeafe', 18 | 200: '#bfdbfe', 19 | 300: '#93c5fd', 20 | 400: '#60a5fa', 21 | 500: '#3b82f6', 22 | 600: '#2563eb', 23 | 700: '#1d4ed8', 24 | 800: '#1e40af', 25 | 900: '#1e3a8a', 26 | 950: '#172554' 27 | } 28 | }, 29 | animation: { 30 | 'spin-slow': 'spin 2s linear infinite' 31 | } 32 | } 33 | }, 34 | plugins: [] 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easyimg", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "nuxt dev --host", 8 | "build": "nuxt build", 9 | "generate": "nuxt generate", 10 | "preview": "nuxt preview" 11 | }, 12 | "dependencies": { 13 | "@iconify/vue": "^5.0.0", 14 | "@nuxt/icon": "^2.1.0", 15 | "@pinia/nuxt": "^0.11.3", 16 | "@seald-io/nedb": "^4.1.2", 17 | "@vueuse/motion": "^3.0.3", 18 | "bcryptjs": "^3.0.3", 19 | "form-data": "^4.0.1", 20 | "jsonwebtoken": "^9.0.3", 21 | "masonry-layout": "^4.2.2", 22 | "multer": "^2.0.2", 23 | "node-telegram-bot-api": "^0.67.0", 24 | "nodemailer": "^7.0.11", 25 | "nuxt": "latest", 26 | "pinia": "^3.0.4", 27 | "sharp": "^0.34.5", 28 | "uuid": "^13.0.0" 29 | }, 30 | "devDependencies": { 31 | "autoprefixer": "^10.4.16", 32 | "tailwindcss": "^3.4.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/api/notification/index.get.js: -------------------------------------------------------------------------------- 1 | import { verifyToken, extractToken } from '../../utils/jwt.js' 2 | import { getNotificationConfig } from '../../utils/notification.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取通知配置 24 | const config = await getNotificationConfig() 25 | 26 | return { 27 | success: true, 28 | data: config 29 | } 30 | } catch (error) { 31 | if (error.statusCode) { 32 | throw error 33 | } 34 | 35 | console.error('[Notification] 获取通知配置失败:', error) 36 | throw createError({ 37 | statusCode: 500, 38 | message: '获取通知配置失败' 39 | }) 40 | } 41 | }) -------------------------------------------------------------------------------- /server/api/blacklist/index.get.js: -------------------------------------------------------------------------------- 1 | import { verifyToken, extractToken } from '../../utils/jwt.js' 2 | import { getBlacklist } from '../../utils/ipBlacklist.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取查询参数 24 | const query = getQuery(event) 25 | const page = parseInt(query.page) || 1 26 | const limit = parseInt(query.limit) || 20 27 | 28 | // 获取黑名单列表 29 | const result = await getBlacklist({ page, limit }) 30 | 31 | return { 32 | success: true, 33 | data: result 34 | } 35 | } catch (error) { 36 | if (error.statusCode) { 37 | throw error 38 | } 39 | 40 | console.error('[Blacklist] 获取黑名单列表失败:', error) 41 | throw createError({ 42 | statusCode: 500, 43 | message: '获取黑名单列表失败' 44 | }) 45 | } 46 | }) -------------------------------------------------------------------------------- /server/utils/jwt.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { db } from './db.js' 3 | import crypto from 'crypto' 4 | 5 | // 获取或生成 JWT 密钥 6 | export async function getJwtSecret() { 7 | let setting = await db.settings.findOne({ key: 'jwtSecret' }) 8 | 9 | if (!setting) { 10 | // 首次启动,生成随机密钥 11 | const secret = crypto.randomBytes(64).toString('hex') 12 | await db.settings.insert({ 13 | key: 'jwtSecret', 14 | value: secret, 15 | createdAt: new Date() 16 | }) 17 | return secret 18 | } 19 | 20 | return setting.value 21 | } 22 | 23 | // 生成 Token 24 | export async function generateToken(payload) { 25 | const secret = await getJwtSecret() 26 | return jwt.sign(payload, secret, { expiresIn: '30d' }) 27 | } 28 | 29 | // 验证 Token 30 | export async function verifyToken(token) { 31 | try { 32 | const secret = await getJwtSecret() 33 | return jwt.verify(token, secret) 34 | } catch (error) { 35 | return null 36 | } 37 | } 38 | 39 | // 从请求头中提取 Token 40 | export function extractToken(event) { 41 | const authHeader = getHeader(event, 'authorization') 42 | if (authHeader && authHeader.startsWith('Bearer ')) { 43 | return authHeader.substring(7) 44 | } 45 | return null 46 | } 47 | -------------------------------------------------------------------------------- /server/api/auth/verify.get.js: -------------------------------------------------------------------------------- 1 | import { verifyToken, extractToken } from '../../utils/jwt.js' 2 | import db from '../../utils/db.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | const token = extractToken(event) 7 | 8 | if (!token) { 9 | return { 10 | success: false, 11 | authenticated: false, 12 | message: '未提供 Token' 13 | } 14 | } 15 | 16 | const decoded = await verifyToken(token) 17 | if (!decoded) { 18 | return { 19 | success: false, 20 | authenticated: false, 21 | message: 'Token 无效或已过期' 22 | } 23 | } 24 | 25 | // 验证用户是否存在 26 | const user = await db.users.findOne({ _id: decoded.userId }) 27 | if (!user) { 28 | return { 29 | success: false, 30 | authenticated: false, 31 | message: '用户不存在' 32 | } 33 | } 34 | 35 | return { 36 | success: true, 37 | authenticated: true, 38 | data: { 39 | user: { 40 | username: user.username 41 | } 42 | } 43 | } 44 | } catch (error) { 45 | return { 46 | success: false, 47 | authenticated: false, 48 | message: '验证失败: ' + error.message 49 | } 50 | } 51 | }) 52 | -------------------------------------------------------------------------------- /server/api/settings/public.get.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | 3 | export default defineEventHandler(async (event) => { 4 | try { 5 | // 获取应用设置(公共部分,无需登录) 6 | const settings = await db.settings.findOne({ key: 'appSettings' }) 7 | 8 | // 默认公告配置 9 | const defaultAnnouncement = { 10 | enabled: false, 11 | content: '', 12 | displayType: 'modal' // 'modal' | 'banner' 13 | } 14 | 15 | if (!settings) { 16 | return { 17 | success: true, 18 | data: { 19 | appName: 'easyimg', 20 | appLogo: '', 21 | backgroundUrl: '', 22 | backgroundBlur: 0, 23 | announcement: defaultAnnouncement 24 | } 25 | } 26 | } 27 | 28 | return { 29 | success: true, 30 | data: { 31 | appName: settings.value.appName || 'easyimg', 32 | appLogo: settings.value.appLogo || '', 33 | backgroundUrl: settings.value.backgroundUrl || '', 34 | backgroundBlur: settings.value.backgroundBlur || 0, 35 | announcement: settings.value.announcement || defaultAnnouncement 36 | } 37 | } 38 | } catch (error) { 39 | console.error('[Settings] 获取公共应用设置失败:', error) 40 | throw createError({ 41 | statusCode: 500, 42 | message: '获取设置失败' 43 | }) 44 | } 45 | }) -------------------------------------------------------------------------------- /server/utils/authMiddleware.js: -------------------------------------------------------------------------------- 1 | import { verifyToken, extractToken } from '../utils/jwt.js' 2 | 3 | // 认证中间件 - 验证用户是否登录 4 | export async function authMiddleware(event) { 5 | const token = extractToken(event) 6 | 7 | if (!token) { 8 | throw createError({ 9 | statusCode: 401, 10 | message: '未登录,请先登录' 11 | }) 12 | } 13 | 14 | const decoded = await verifyToken(token) 15 | if (!decoded) { 16 | throw createError({ 17 | statusCode: 401, 18 | message: 'Token 无效或已过期' 19 | }) 20 | } 21 | 22 | // 将用户信息附加到 event.context 23 | event.context.user = decoded 24 | return decoded 25 | } 26 | 27 | // 可选认证中间件 - 不强制要求登录,但如果有 token 则验证 28 | export async function optionalAuthMiddleware(event) { 29 | const token = extractToken(event) 30 | 31 | if (!token) { 32 | event.context.user = null 33 | return null 34 | } 35 | 36 | const decoded = await verifyToken(token) 37 | event.context.user = decoded || null 38 | return decoded 39 | } 40 | 41 | // 包装为 eventHandler 的认证中间件 42 | export const authHandler = defineEventHandler(async (event) => { 43 | return authMiddleware(event) 44 | }) 45 | 46 | // 包装为 eventHandler 的可选认证中间件 47 | export const optionalAuthHandler = defineEventHandler(async (event) => { 48 | return optionalAuthMiddleware(event) 49 | }) 50 | 51 | export default authMiddleware 52 | -------------------------------------------------------------------------------- /server/api/config/private.get.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取私有 API 配置 24 | const config = await db.settings.findOne({ key: 'privateApiConfig' }) 25 | 26 | if (!config) { 27 | // 返回默认配置 28 | return { 29 | success: true, 30 | data: { 31 | maxFileSize: 100 * 1024 * 1024, 32 | convertToWebp: false, 33 | webpQuality: 80, 34 | showOnHomepage: false 35 | } 36 | } 37 | } 38 | 39 | return { 40 | success: true, 41 | data: config.value 42 | } 43 | } catch (error) { 44 | if (error.statusCode) { 45 | throw error 46 | } 47 | 48 | console.error('[Config] 获取私有 API 配置失败:', error) 49 | throw createError({ 50 | statusCode: 500, 51 | message: '获取配置失败' 52 | }) 53 | } 54 | }) 55 | -------------------------------------------------------------------------------- /app/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 复制文本到剪贴板 3 | * 兼容处理:优先使用 navigator.clipboard,如果不可用(非 HTTPS 等)则降级使用 document.execCommand 4 | * @param text 需要复制的文本 5 | * @returns Promise 是否复制成功 6 | */ 7 | export async function copyToClipboard(text: string): Promise { 8 | if (!text) return false 9 | 10 | // 1. 尝试使用现代 API (navigator.clipboard) 11 | // 注意:navigator.clipboard 在非 HTTPS 环境下可能未定义 12 | if (navigator.clipboard && navigator.clipboard.writeText) { 13 | try { 14 | await navigator.clipboard.writeText(text) 15 | return true 16 | } catch (err) { 17 | console.warn('navigator.clipboard.writeText failed, trying fallback', err) 18 | 19 | } 20 | } 21 | 22 | // 2. 降级方案:使用 document.execCommand 23 | try { 24 | const textArea = document.createElement('textarea') 25 | 26 | textArea.value = text 27 | textArea.style.position = 'fixed' 28 | textArea.style.left = '-9999px' 29 | textArea.style.top = '0' 30 | textArea.style.opacity = '0' 31 | textArea.setAttribute('readonly', '') 32 | 33 | document.body.appendChild(textArea) 34 | textArea.focus() 35 | textArea.select() 36 | 37 | const successful = document.execCommand('copy') 38 | 39 | document.body.removeChild(textArea) 40 | 41 | return successful 42 | } catch (err) { 43 | console.error('Fallback: Oops, unable to copy', err) 44 | return false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/api/blacklist/[id].delete.js: -------------------------------------------------------------------------------- 1 | import { verifyToken, extractToken } from '../../utils/jwt.js' 2 | import { removeFromBlacklistById } from '../../utils/ipBlacklist.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取 ID 24 | const id = getRouterParam(event, 'id') 25 | if (!id) { 26 | throw createError({ 27 | statusCode: 400, 28 | message: '缺少 ID 参数' 29 | }) 30 | } 31 | 32 | // 从黑名单中移除 33 | const result = await removeFromBlacklistById(id) 34 | 35 | if (!result.success) { 36 | throw createError({ 37 | statusCode: 400, 38 | message: result.error 39 | }) 40 | } 41 | 42 | return { 43 | success: true, 44 | message: `IP ${result.ip} 已从黑名单中移除` 45 | } 46 | } catch (error) { 47 | if (error.statusCode) { 48 | throw error 49 | } 50 | 51 | console.error('[Blacklist] 移除黑名单失败:', error) 52 | throw createError({ 53 | statusCode: 500, 54 | message: '移除黑名单失败' 55 | }) 56 | } 57 | }) -------------------------------------------------------------------------------- /app/stores/toast.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useToastStore = defineStore('toast', { 4 | state: () => ({ 5 | toasts: [] 6 | }), 7 | 8 | actions: { 9 | // 添加 Toast 10 | add(message, type = 'info', duration = 3000) { 11 | const id = Date.now() + Math.random() 12 | 13 | this.toasts.push({ 14 | id, 15 | message, 16 | type, // 'success' | 'error' | 'warning' | 'info' 17 | duration 18 | }) 19 | 20 | // 自动移除 21 | if (duration > 0) { 22 | setTimeout(() => { 23 | this.remove(id) 24 | }, duration) 25 | } 26 | 27 | return id 28 | }, 29 | 30 | // 移除 Toast 31 | remove(id) { 32 | const index = this.toasts.findIndex(t => t.id === id) 33 | if (index > -1) { 34 | this.toasts.splice(index, 1) 35 | } 36 | }, 37 | 38 | // 快捷方法 39 | success(message, duration = 3000) { 40 | return this.add(message, 'success', duration) 41 | }, 42 | 43 | error(message, duration = 4000) { 44 | return this.add(message, 'error', duration) 45 | }, 46 | 47 | warning(message, duration = 3500) { 48 | return this.add(message, 'warning', duration) 49 | }, 50 | 51 | info(message, duration = 3000) { 52 | return this.add(message, 'info', duration) 53 | }, 54 | 55 | // 清空所有 56 | clear() { 57 | this.toasts = [] 58 | } 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /server/api/apikeys/index.get.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取所有 ApiKey 24 | const apiKeys = await db.apikeys.find({}) 25 | 26 | // 按创建时间排序 27 | apiKeys.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) 28 | 29 | // 返回 ApiKey 列表 30 | const safeKeys = apiKeys.map(key => ({ 31 | id: key._id, 32 | key: key.key, 33 | name: key.name, 34 | isDefault: key.isDefault || false, 35 | enabled: key.enabled !== false, 36 | createdAt: key.createdAt, 37 | updatedAt: key.updatedAt 38 | })) 39 | 40 | return { 41 | success: true, 42 | data: safeKeys 43 | } 44 | } catch (error) { 45 | if (error.statusCode) { 46 | throw error 47 | } 48 | 49 | console.error('[ApiKeys] 获取 ApiKey 列表失败:', error) 50 | throw createError({ 51 | statusCode: 500, 52 | message: '获取 ApiKey 列表失败' 53 | }) 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /server/api/images/nsfw-clear.post.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { authMiddleware } from '../../utils/authMiddleware.js' 3 | import { deleteImage } from '../../utils/image.js' 4 | 5 | export default defineEventHandler(async (event) => { 6 | // 验证登录 7 | await authMiddleware(event) 8 | 9 | try { 10 | // 获取所有违规图片 11 | const nsfwImages = await db.images.find({ isNsfw: true }) 12 | 13 | if (nsfwImages.length === 0) { 14 | return { 15 | success: true, 16 | message: '没有需要清空的违规图片', 17 | data: { deletedCount: 0 } 18 | } 19 | } 20 | 21 | let deletedCount = 0 22 | let errors = [] 23 | 24 | for (const image of nsfwImages) { 25 | try { 26 | // 删除物理文件 27 | deleteImage(image.filename) 28 | 29 | // 从数据库删除记录 30 | await db.images.remove({ _id: image._id }) 31 | deletedCount++ 32 | } catch (err) { 33 | console.error(`删除违规图片失败 ${image.uuid}:`, err) 34 | errors.push(image.uuid) 35 | } 36 | } 37 | 38 | return { 39 | success: true, 40 | message: `成功清空 ${deletedCount} 张违规图片`, 41 | data: { 42 | deletedCount, 43 | errors: errors.length > 0 ? errors : undefined 44 | } 45 | } 46 | } catch (error) { 47 | if (error.statusCode) { 48 | throw error 49 | } 50 | 51 | console.error('[Images] 清空违规图片失败:', error) 52 | throw createError({ 53 | statusCode: 500, 54 | message: '清空违规图片失败' 55 | }) 56 | } 57 | }) -------------------------------------------------------------------------------- /app/components/Loading.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 61 | -------------------------------------------------------------------------------- /server/api/images/[id].delete.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取图片 ID 24 | const id = getRouterParam(event, 'id') 25 | if (!id) { 26 | throw createError({ 27 | statusCode: 400, 28 | message: '缺少图片 ID' 29 | }) 30 | } 31 | 32 | // 查找图片 33 | const image = await db.images.findOne({ _id: id }) 34 | if (!image) { 35 | throw createError({ 36 | statusCode: 404, 37 | message: '图片不存在' 38 | }) 39 | } 40 | 41 | // 软删除 42 | await db.images.update( 43 | { _id: id }, 44 | { 45 | $set: { 46 | isDeleted: true, 47 | deletedAt: new Date().toISOString(), 48 | deletedBy: user.username 49 | } 50 | } 51 | ) 52 | 53 | return { 54 | success: true, 55 | message: '删除成功' 56 | } 57 | } catch (error) { 58 | if (error.statusCode) { 59 | throw error 60 | } 61 | 62 | console.error('[Images] 删除图片失败:', error) 63 | throw createError({ 64 | statusCode: 500, 65 | message: '删除图片失败' 66 | }) 67 | } 68 | }) 69 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import { resolve } from 'path' 3 | 4 | // 在构建时读取 package.json 的版本号 5 | const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8')) 6 | const appVersion = packageJson.version || '1.0.0' 7 | 8 | export default defineNuxtConfig({ 9 | ssr: false, // 关闭服务端渲染,变成纯 SPA 10 | 11 | future: { 12 | compatibilityVersion: 4 13 | }, 14 | 15 | compatibilityDate: '2025-12-12', 16 | 17 | modules: [ 18 | '@pinia/nuxt', 19 | '@nuxt/icon' 20 | ], 21 | 22 | css: [ 23 | '~/assets/css/main.css' 24 | ], 25 | 26 | postcss: { 27 | plugins: { 28 | tailwindcss: {}, 29 | autoprefixer: {} 30 | } 31 | }, 32 | 33 | app: { 34 | head: { 35 | title: 'EasyImg', 36 | meta: [ 37 | { charset: 'utf-8' }, 38 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 39 | { name: 'description', content: '简单易用的个人图床' } 40 | ], 41 | link: [ 42 | { rel: 'icon', type: 'image/png', href: '/favicon.png' } 43 | ] 44 | } 45 | }, 46 | 47 | nitro: { 48 | // CORS 配置 49 | routeRules: { 50 | '/api/**': { 51 | cors: true, 52 | headers: { 53 | 'Access-Control-Allow-Origin': '*', 54 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 55 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key' 56 | } 57 | } 58 | } 59 | }, 60 | 61 | runtimeConfig: { 62 | // 服务端运行时配置(不会暴露给客户端) 63 | appVersion, 64 | public: { 65 | apiBase: '' 66 | } 67 | } 68 | }) -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: ${{ github.repository }} 12 | 13 | jobs: 14 | build-and-push: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | packages: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v3 29 | 30 | - name: Log in to Container Registry 31 | if: github.event_name != 'pull_request' 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ${{ env.REGISTRY }} 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Extract metadata (tags, labels) for Docker 39 | id: meta 40 | uses: docker/metadata-action@v5 41 | with: 42 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 43 | tags: | 44 | type=semver,pattern={{version}} 45 | type=raw,value=latest 46 | 47 | - name: Build and push Docker image 48 | uses: docker/build-push-action@v5 49 | with: 50 | context: . 51 | platforms: linux/amd64,linux/arm64 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | cache-from: type=gha 56 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /server/api/apikeys/[id].delete.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取 ApiKey ID 24 | const id = getRouterParam(event, 'id') 25 | if (!id) { 26 | throw createError({ 27 | statusCode: 400, 28 | message: '缺少 ApiKey ID' 29 | }) 30 | } 31 | 32 | // 查找 ApiKey 33 | const apiKey = await db.apikeys.findOne({ _id: id }) 34 | if (!apiKey) { 35 | throw createError({ 36 | statusCode: 404, 37 | message: 'ApiKey 不存在' 38 | }) 39 | } 40 | 41 | // 检查是否为默认 Key(不能删除) 42 | if (apiKey.isDefault) { 43 | throw createError({ 44 | statusCode: 400, 45 | message: '默认 ApiKey 不能删除,只能更新' 46 | }) 47 | } 48 | 49 | // 删除 ApiKey 50 | await db.apikeys.remove({ _id: id }) 51 | 52 | return { 53 | success: true, 54 | message: 'ApiKey 删除成功' 55 | } 56 | } catch (error) { 57 | if (error.statusCode) { 58 | throw error 59 | } 60 | 61 | console.error('[ApiKeys] 删除 ApiKey 失败:', error) 62 | throw createError({ 63 | statusCode: 500, 64 | message: '删除 ApiKey 失败' 65 | }) 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /server/api/config/private.put.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取请求体 24 | const body = await readBody(event) 25 | 26 | // 验证配置项 27 | const { 28 | maxFileSize, 29 | convertToWebp, 30 | webpQuality, 31 | showOnHomepage 32 | } = body 33 | 34 | // 构建更新对象 35 | const configValue = { 36 | maxFileSize: maxFileSize || 100 * 1024 * 1024, 37 | convertToWebp: convertToWebp || false, 38 | webpQuality: webpQuality || 80, 39 | showOnHomepage: showOnHomepage === true 40 | } 41 | 42 | // 更新配置 43 | await db.settings.update( 44 | { key: 'privateApiConfig' }, 45 | { 46 | $set: { 47 | value: configValue, 48 | updatedAt: new Date().toISOString() 49 | } 50 | }, 51 | { upsert: true } 52 | ) 53 | 54 | return { 55 | success: true, 56 | message: '配置已保存', 57 | data: configValue 58 | } 59 | } catch (error) { 60 | if (error.statusCode) { 61 | throw error 62 | } 63 | 64 | console.error('[Config] 更新私有 API 配置失败:', error) 65 | throw createError({ 66 | statusCode: 500, 67 | message: '保存配置失败' 68 | }) 69 | } 70 | }) 71 | -------------------------------------------------------------------------------- /server/api/images/[id]/unmark-nsfw.put.js: -------------------------------------------------------------------------------- 1 | import db from '../../../utils/db.js' 2 | import { authMiddleware } from '../../../utils/authMiddleware.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | // 验证登录 6 | await authMiddleware(event) 7 | 8 | try { 9 | // 获取图片 ID 10 | const id = getRouterParam(event, 'id') 11 | if (!id) { 12 | throw createError({ 13 | statusCode: 400, 14 | message: '缺少图片 ID' 15 | }) 16 | } 17 | 18 | // 查找图片 19 | const image = await db.images.findOne({ _id: id }) 20 | if (!image) { 21 | throw createError({ 22 | statusCode: 404, 23 | message: '图片不存在' 24 | }) 25 | } 26 | 27 | // 检查是否是违规图片 28 | if (!image.isNsfw) { 29 | return { 30 | success: true, 31 | message: '该图片未被标记为违规' 32 | } 33 | } 34 | 35 | // 更新图片状态:取消违规标记,同时恢复图片(如果是因为违规被软删除的) 36 | const updateData = { 37 | isNsfw: false, 38 | moderationStatus: 'unmarked_by_admin', 39 | unmarkedAt: new Date().toISOString(), 40 | unmarkedBy: event.context.user.username 41 | } 42 | 43 | // 如果图片是因为违规被软删除的,同时恢复图片 44 | if (image.isDeleted) { 45 | updateData.isDeleted = false 46 | updateData.deletedAt = null 47 | updateData.deletedBy = null 48 | } 49 | 50 | await db.images.update( 51 | { _id: id }, 52 | { $set: updateData } 53 | ) 54 | 55 | return { 56 | success: true, 57 | message: image.isDeleted ? '已取消违规标记并恢复图片' : '已取消违规标记', 58 | data: { 59 | restored: image.isDeleted 60 | } 61 | } 62 | } catch (error) { 63 | if (error.statusCode) { 64 | throw error 65 | } 66 | 67 | console.error('[Images] 取消违规标记失败:', error) 68 | throw createError({ 69 | statusCode: 500, 70 | message: '取消违规标记失败' 71 | }) 72 | } 73 | }) -------------------------------------------------------------------------------- /server/api/settings/hard-delete.post.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | import { deleteImage } from '../../utils/image.js' 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // 验证登录 8 | const token = extractToken(event) 9 | if (!token) { 10 | throw createError({ 11 | statusCode: 401, 12 | message: '请先登录' 13 | }) 14 | } 15 | 16 | const user = await verifyToken(token) 17 | if (!user) { 18 | throw createError({ 19 | statusCode: 401, 20 | message: 'Token 无效或已过期' 21 | }) 22 | } 23 | 24 | // 获取所有已软删除的图片 25 | const deletedImages = await db.images.find({ isDeleted: true }) 26 | 27 | if (deletedImages.length === 0) { 28 | return { 29 | success: true, 30 | message: '没有需要硬删除的图片', 31 | data: { deletedCount: 0 } 32 | } 33 | } 34 | 35 | let deletedCount = 0 36 | let errors = [] 37 | 38 | for (const image of deletedImages) { 39 | try { 40 | // 删除物理文件 41 | deleteImage(image.filename) 42 | 43 | // 从数据库删除记录 44 | await db.images.remove({ _id: image._id }) 45 | deletedCount++ 46 | } catch (err) { 47 | console.error(`删除图片失败 ${image.uuid}:`, err) 48 | errors.push(image.uuid) 49 | } 50 | } 51 | 52 | return { 53 | success: true, 54 | message: `成功硬删除 ${deletedCount} 张图片`, 55 | data: { 56 | deletedCount, 57 | errors: errors.length > 0 ? errors : undefined 58 | } 59 | } 60 | } catch (error) { 61 | if (error.statusCode) { 62 | throw error 63 | } 64 | 65 | console.error('[Settings] 硬删除图片失败:', error) 66 | throw createError({ 67 | statusCode: 500, 68 | message: '硬删除图片失败' 69 | }) 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /server/api/auth/login.post.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { generateToken } from '../../utils/jwt.js' 3 | import bcrypt from 'bcryptjs' 4 | import { sendLoginNotification } from '../../utils/notification.js' 5 | 6 | export default defineEventHandler(async (event) => { 7 | try { 8 | const body = await readBody(event) 9 | const { username, password } = body 10 | 11 | if (!username || !password) { 12 | throw createError({ 13 | statusCode: 400, 14 | message: '用户名和密码不能为空' 15 | }) 16 | } 17 | 18 | // 查找用户 19 | const user = await db.users.findOne({ username }) 20 | if (!user) { 21 | throw createError({ 22 | statusCode: 401, 23 | message: '用户名或密码错误' 24 | }) 25 | } 26 | 27 | // 验证密码 28 | const isValidPassword = await bcrypt.compare(password, user.password) 29 | if (!isValidPassword) { 30 | throw createError({ 31 | statusCode: 401, 32 | message: '用户名或密码错误' 33 | }) 34 | } 35 | 36 | // 生成 Token 37 | const token = await generateToken({ 38 | userId: user._id, 39 | username: user.username 40 | }) 41 | 42 | // 发送登录通知(异步,不阻塞响应) 43 | const clientIP = getRequestIP(event, { xForwardedFor: true }) || 'unknown' 44 | const userAgent = getHeader(event, 'user-agent') || 'unknown' 45 | sendLoginNotification(user.username, clientIP, userAgent).catch(err => { 46 | console.error('[Login] 发送登录通知失败:', err) 47 | }) 48 | 49 | return { 50 | success: true, 51 | data: { 52 | token, 53 | user: { 54 | username: user.username 55 | } 56 | } 57 | } 58 | } catch (error) { 59 | if (error.statusCode) { 60 | throw error 61 | } 62 | throw createError({ 63 | statusCode: 500, 64 | message: '登录失败: ' + error.message 65 | }) 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /server/api/images/batch.delete.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取要删除的图片 ID 列表 24 | const body = await readBody(event) 25 | const { ids } = body 26 | 27 | if (!ids || !Array.isArray(ids) || ids.length === 0) { 28 | throw createError({ 29 | statusCode: 400, 30 | message: '请选择要删除的图片' 31 | }) 32 | } 33 | 34 | // 批量软删除 35 | let deletedCount = 0 36 | for (const id of ids) { 37 | // 先查找图片是否存在且未被删除 38 | const image = await db.images.findOne({ _id: id }) 39 | if (image && !image.isDeleted) { 40 | await db.images.update( 41 | { _id: id }, 42 | { 43 | $set: { 44 | isDeleted: true, 45 | deletedAt: new Date().toISOString(), 46 | deletedBy: user.username 47 | } 48 | } 49 | ) 50 | // 更新成功,增加计数 51 | deletedCount++ 52 | } 53 | } 54 | 55 | return { 56 | success: true, 57 | message: `成功删除 ${deletedCount} 张图片`, 58 | data: { 59 | deletedCount 60 | } 61 | } 62 | } catch (error) { 63 | if (error.statusCode) { 64 | throw error 65 | } 66 | 67 | console.error('[Images] 批量删除图片失败:', error) 68 | throw createError({ 69 | statusCode: 500, 70 | message: '批量删除图片失败' 71 | }) 72 | } 73 | }) 74 | -------------------------------------------------------------------------------- /server/api/apikeys/index.post.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | import { v4 as uuidv4 } from 'uuid' 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // 验证登录 8 | const token = extractToken(event) 9 | if (!token) { 10 | throw createError({ 11 | statusCode: 401, 12 | message: '请先登录' 13 | }) 14 | } 15 | 16 | const user = await verifyToken(token) 17 | if (!user) { 18 | throw createError({ 19 | statusCode: 401, 20 | message: 'Token 无效或已过期' 21 | }) 22 | } 23 | 24 | // 获取请求体 25 | const body = await readBody(event) 26 | const { name } = body 27 | 28 | if (!name || !name.trim()) { 29 | throw createError({ 30 | statusCode: 400, 31 | message: '请输入 ApiKey 名称' 32 | }) 33 | } 34 | 35 | // 生成新的 ApiKey 36 | const apiKey = `sk-${uuidv4().replace(/-/g, '')}` 37 | 38 | const newKey = { 39 | _id: uuidv4(), 40 | key: apiKey, 41 | name: name.trim(), 42 | isDefault: false, 43 | enabled: true, 44 | createdAt: new Date().toISOString(), 45 | updatedAt: new Date().toISOString() 46 | } 47 | 48 | await db.apikeys.insert(newKey) 49 | 50 | return { 51 | success: true, 52 | message: 'ApiKey 创建成功', 53 | data: { 54 | id: newKey._id, 55 | key: newKey.key, 56 | name: newKey.name, 57 | isDefault: newKey.isDefault, 58 | enabled: newKey.enabled, 59 | createdAt: newKey.createdAt 60 | } 61 | } 62 | } catch (error) { 63 | if (error.statusCode) { 64 | throw error 65 | } 66 | 67 | console.error('[ApiKeys] 创建 ApiKey 失败:', error) 68 | throw createError({ 69 | statusCode: 500, 70 | message: '创建 ApiKey 失败' 71 | }) 72 | } 73 | }) 74 | -------------------------------------------------------------------------------- /server/api/blacklist/index.post.js: -------------------------------------------------------------------------------- 1 | import { verifyToken, extractToken } from '../../utils/jwt.js' 2 | import { addToBlacklist } from '../../utils/ipBlacklist.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取请求体 24 | const body = await readBody(event) 25 | const { ip, reason } = body 26 | 27 | if (!ip) { 28 | throw createError({ 29 | statusCode: 400, 30 | message: '请输入 IP 地址' 31 | }) 32 | } 33 | 34 | // 简单的 IP 格式验证 35 | const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ 36 | const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::$|^([0-9a-fA-F]{1,4}:){1,7}:$|^:([0-9a-fA-F]{1,4}:){1,7}$|^([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$/ 37 | 38 | if (!ipv4Regex.test(ip) && !ipv6Regex.test(ip)) { 39 | throw createError({ 40 | statusCode: 400, 41 | message: 'IP 地址格式不正确' 42 | }) 43 | } 44 | 45 | // 添加到黑名单 46 | const result = await addToBlacklist(ip, reason || '手动添加') 47 | 48 | if (!result.success) { 49 | throw createError({ 50 | statusCode: 400, 51 | message: result.error 52 | }) 53 | } 54 | 55 | return { 56 | success: true, 57 | message: `IP ${ip} 已添加到黑名单`, 58 | data: result.record 59 | } 60 | } catch (error) { 61 | if (error.statusCode) { 62 | throw error 63 | } 64 | 65 | console.error('[Blacklist] 添加黑名单失败:', error) 66 | throw createError({ 67 | statusCode: 500, 68 | message: '添加黑名单失败' 69 | }) 70 | } 71 | }) -------------------------------------------------------------------------------- /server/utils/db.js: -------------------------------------------------------------------------------- 1 | import Datastore from '@seald-io/nedb' 2 | import { join } from 'path' 3 | import { existsSync, mkdirSync } from 'fs' 4 | 5 | // 数据目录:生产环境使用 /app/db,开发环境使用项目根目录下的 db 6 | const dataDir = process.env.NODE_ENV === 'production' 7 | ? '/app/db' 8 | : join(process.cwd(), 'db') 9 | 10 | // 确保 db 目录存在 11 | if (!existsSync(dataDir)) { 12 | mkdirSync(dataDir, { recursive: true }) 13 | } 14 | 15 | console.log('[Database] 数据目录:', dataDir) 16 | 17 | // 创建数据库实例 18 | const users = new Datastore({ 19 | filename: join(dataDir, 'users.db'), 20 | autoload: true 21 | }) 22 | 23 | const images = new Datastore({ 24 | filename: join(dataDir, 'images.db'), 25 | autoload: true 26 | }) 27 | 28 | const apikeys = new Datastore({ 29 | filename: join(dataDir, 'apikeys.db'), 30 | autoload: true 31 | }) 32 | 33 | const settings = new Datastore({ 34 | filename: join(dataDir, 'settings.db'), 35 | autoload: true 36 | }) 37 | 38 | // 内容审核任务表 39 | const moderationTasks = new Datastore({ 40 | filename: join(dataDir, 'moderation_tasks.db'), 41 | autoload: true 42 | }) 43 | 44 | // IP 黑名单表 45 | const ipBlacklist = new Datastore({ 46 | filename: join(dataDir, 'ip_blacklist.db'), 47 | autoload: true 48 | }) 49 | 50 | // Promise 化数据库操作 51 | const promisify = (db) => ({ 52 | findOne: (query) => db.findOneAsync(query), 53 | find: (query) => db.findAsync(query), 54 | insert: (doc) => db.insertAsync(doc), 55 | update: (query, update, options = {}) => db.updateAsync(query, update, options), 56 | remove: (query, options = {}) => db.removeAsync(query, options), 57 | count: (query) => db.countAsync(query), 58 | ensureIndex: (options) => db.ensureIndexAsync(options) 59 | }) 60 | 61 | export const db = { 62 | users: promisify(users), 63 | images: promisify(images), 64 | apikeys: promisify(apikeys), 65 | settings: promisify(settings), 66 | moderationTasks: promisify(moderationTasks), 67 | ipBlacklist: promisify(ipBlacklist) 68 | } 69 | 70 | export default db 71 | -------------------------------------------------------------------------------- /app/components/ImageViewer.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 81 | -------------------------------------------------------------------------------- /server/api/settings/index.get.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取应用设置 24 | const settings = await db.settings.findOne({ key: 'appSettings' }) 25 | 26 | // 获取已删除图片数量 27 | const deletedCount = await db.images.count({ isDeleted: true }) 28 | 29 | // 默认公告配置 30 | const defaultAnnouncement = { 31 | enabled: false, 32 | content: '', 33 | displayType: 'modal' // 'modal' | 'banner' 34 | } 35 | 36 | if (!settings) { 37 | return { 38 | success: true, 39 | data: { 40 | appName: 'easyimg', 41 | appLogo: '', 42 | backgroundUrl: '', 43 | backgroundBlur: 0, 44 | siteUrl: '', 45 | deletedImagesCount: deletedCount, 46 | announcement: defaultAnnouncement 47 | } 48 | } 49 | } 50 | 51 | return { 52 | success: true, 53 | data: { 54 | appName: settings.value.appName || 'easyimg', 55 | appLogo: settings.value.appLogo || '', 56 | backgroundUrl: settings.value.backgroundUrl || '', 57 | backgroundBlur: settings.value.backgroundBlur || 0, 58 | siteUrl: settings.value.siteUrl || '', 59 | deletedImagesCount: deletedCount, 60 | announcement: settings.value.announcement || defaultAnnouncement 61 | } 62 | } 63 | } catch (error) { 64 | if (error.statusCode) { 65 | throw error 66 | } 67 | 68 | console.error('[Settings] 获取应用设置失败:', error) 69 | throw createError({ 70 | statusCode: 500, 71 | message: '获取设置失败' 72 | }) 73 | } 74 | }) 75 | -------------------------------------------------------------------------------- /server/api/images/nsfw.get.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { authMiddleware } from '../../utils/authMiddleware.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | // 验证登录 6 | await authMiddleware(event) 7 | 8 | try { 9 | // 获取查询参数 10 | const query = getQuery(event) 11 | const page = parseInt(query.page) || 1 12 | const limit = parseInt(query.limit) || 20 13 | const skip = (page - 1) * limit 14 | 15 | // 查询条件:isNsfw 为 true 的图片(包括已软删除的) 16 | const queryCondition = { isNsfw: true } 17 | 18 | // 获取总数 19 | const total = await db.images.count(queryCondition) 20 | 21 | // 获取违规图片列表 22 | let images = await db.images.find(queryCondition) 23 | 24 | // 按上传时间倒序排序 25 | images.sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt)) 26 | 27 | // 分页 28 | images = images.slice(skip, skip + limit) 29 | 30 | // 返回图片信息 31 | const nsfwImages = images.map(img => ({ 32 | id: img._id, 33 | uuid: img.uuid, 34 | filename: img.filename, 35 | originalName: img.originalName, 36 | format: img.format, 37 | size: img.size, 38 | width: img.width, 39 | height: img.height, 40 | // 使用特殊的管理员预览路由 41 | url: `/api/images/preview/${img.uuid}.${img.format}`, 42 | uploadedBy: img.uploadedBy, 43 | uploadedByType: img.uploadedByType, 44 | uploadedAt: img.uploadedAt, 45 | isDeleted: img.isDeleted || false, 46 | isNsfw: img.isNsfw || false, 47 | moderationStatus: img.moderationStatus, 48 | moderationScore: img.moderationResult?.score, 49 | moderationCategories: img.moderationResult?.categories 50 | })) 51 | 52 | return { 53 | success: true, 54 | data: { 55 | images: nsfwImages, 56 | pagination: { 57 | page, 58 | limit, 59 | total, 60 | totalPages: Math.ceil(total / limit) 61 | } 62 | } 63 | } 64 | } catch (error) { 65 | console.error('[Images] 获取违规图片列表失败:', error) 66 | throw createError({ 67 | statusCode: 500, 68 | message: '获取违规图片列表失败' 69 | }) 70 | } 71 | }) -------------------------------------------------------------------------------- /server/api/admin/username.put.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken, generateToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取请求体 24 | const body = await readBody(event) 25 | const { username } = body 26 | 27 | if (!username || !username.trim()) { 28 | throw createError({ 29 | statusCode: 400, 30 | message: '请输入新用户名' 31 | }) 32 | } 33 | 34 | if (username.length < 3) { 35 | throw createError({ 36 | statusCode: 400, 37 | message: '用户名长度至少 3 位' 38 | }) 39 | } 40 | 41 | // 检查用户名是否已存在(如果不同) 42 | if (username !== user.username) { 43 | const existingUser = await db.users.findOne({ username: username.trim() }) 44 | if (existingUser) { 45 | throw createError({ 46 | statusCode: 400, 47 | message: '用户名已存在' 48 | }) 49 | } 50 | } 51 | 52 | // 更新用户名 53 | await db.users.update( 54 | { _id: user.userId }, 55 | { 56 | $set: { 57 | username: username.trim(), 58 | updatedAt: new Date().toISOString() 59 | } 60 | } 61 | ) 62 | 63 | // 生成新 Token 64 | const newToken = await generateToken({ 65 | userId: user.userId, 66 | username: username.trim() 67 | }) 68 | 69 | return { 70 | success: true, 71 | message: '用户名修改成功', 72 | data: { 73 | token: newToken, 74 | username: username.trim() 75 | } 76 | } 77 | } catch (error) { 78 | if (error.statusCode) { 79 | throw error 80 | } 81 | 82 | console.error('[Admin] 修改用户名失败:', error) 83 | throw createError({ 84 | statusCode: 500, 85 | message: '修改用户名失败' 86 | }) 87 | } 88 | }) 89 | -------------------------------------------------------------------------------- /server/api/admin/password.put.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | import bcrypt from 'bcryptjs' 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // 验证登录 8 | const token = extractToken(event) 9 | if (!token) { 10 | throw createError({ 11 | statusCode: 401, 12 | message: '请先登录' 13 | }) 14 | } 15 | 16 | const user = await verifyToken(token) 17 | if (!user) { 18 | throw createError({ 19 | statusCode: 401, 20 | message: 'Token 无效或已过期' 21 | }) 22 | } 23 | 24 | // 获取请求体 25 | const body = await readBody(event) 26 | const { oldPassword, newPassword } = body 27 | 28 | if (!oldPassword || !newPassword) { 29 | throw createError({ 30 | statusCode: 400, 31 | message: '请输入旧密码和新密码' 32 | }) 33 | } 34 | 35 | if (newPassword.length < 6) { 36 | throw createError({ 37 | statusCode: 400, 38 | message: '新密码长度至少 6 位' 39 | }) 40 | } 41 | 42 | // 获取用户 43 | const dbUser = await db.users.findOne({ _id: user.userId }) 44 | if (!dbUser) { 45 | throw createError({ 46 | statusCode: 404, 47 | message: '用户不存在' 48 | }) 49 | } 50 | 51 | // 验证旧密码 52 | const isValidPassword = await bcrypt.compare(oldPassword, dbUser.password) 53 | if (!isValidPassword) { 54 | throw createError({ 55 | statusCode: 400, 56 | message: '旧密码错误' 57 | }) 58 | } 59 | 60 | // 加密新密码 61 | const hashedPassword = await bcrypt.hash(newPassword, 10) 62 | 63 | // 更新密码 64 | await db.users.update( 65 | { _id: user.userId }, 66 | { 67 | $set: { 68 | password: hashedPassword, 69 | updatedAt: new Date().toISOString() 70 | } 71 | } 72 | ) 73 | 74 | return { 75 | success: true, 76 | message: '密码修改成功' 77 | } 78 | } catch (error) { 79 | if (error.statusCode) { 80 | throw error 81 | } 82 | 83 | console.error('[Admin] 修改密码失败:', error) 84 | throw createError({ 85 | statusCode: 500, 86 | message: '修改密码失败' 87 | }) 88 | } 89 | }) 90 | -------------------------------------------------------------------------------- /server/plugins/scheduler.js: -------------------------------------------------------------------------------- 1 | import db from '../utils/db.js' 2 | import { unlink } from 'fs/promises' 3 | import { existsSync } from 'fs' 4 | import { startProcessor as startModerationProcessor, retryFailedTasks } from '../utils/moderationQueue.js' 5 | import { getUploadsDirPath, getImagePath } from '../utils/upload.js' 6 | 7 | // 定时任务间隔(毫秒) 8 | const RETRY_FAILED_TASKS_INTERVAL = 60 * 60 * 1000 // 1 小时 9 | 10 | // 硬删除已软删除的图片文件 11 | export async function hardDeleteImages() { 12 | try { 13 | const deletedImages = await db.images.find({ isDeleted: true }) 14 | 15 | if (deletedImages.length === 0) { 16 | return { success: true, count: 0, message: '没有需要硬删除的图片' } 17 | } 18 | 19 | let deletedCount = 0 20 | for (const image of deletedImages) { 21 | try { 22 | // 删除物理文件 23 | const filePath = getImagePath(image.filename) 24 | if (existsSync(filePath)) { 25 | await unlink(filePath) 26 | } 27 | 28 | // 从数据库中彻底删除记录 29 | await db.images.remove({ _id: image._id }) 30 | deletedCount++ 31 | } catch (err) { 32 | console.error(`[Scheduler] 硬删除图片失败 ${image.uuid}:`, err) 33 | } 34 | } 35 | 36 | console.log(`[Scheduler] 已硬删除 ${deletedCount} 张图片`) 37 | return { success: true, count: deletedCount, message: `已硬删除 ${deletedCount} 张图片` } 38 | } catch (error) { 39 | console.error('[Scheduler] 硬删除图片时出错:', error) 40 | return { success: false, count: 0, message: error.message } 41 | } 42 | } 43 | 44 | // 重试失败的审核任务 45 | async function retryFailedModerationTasks() { 46 | try { 47 | // 获取 error 状态的任务数量 48 | const errorCount = await db.moderationTasks.count({ status: 'error' }) 49 | 50 | if (errorCount === 0) { 51 | console.log('[Scheduler] 没有需要重试的失败审核任务') 52 | return 53 | } 54 | 55 | console.log(`[Scheduler] 发现 ${errorCount} 个失败的审核任务,开始重试...`) 56 | const result = await retryFailedTasks() 57 | console.log(`[Scheduler] 已重置 ${result.count} 个失败的审核任务`) 58 | } catch (error) { 59 | console.error('[Scheduler] 重试失败审核任务时出错:', error) 60 | } 61 | } 62 | 63 | export default defineNitroPlugin(() => { 64 | // 启动内容审核任务处理器 65 | startModerationProcessor() 66 | console.log('[Scheduler] 内容审核任务处理器已启动') 67 | 68 | // 启动定时任务:每小时重试失败的审核任务 69 | setInterval(() => { 70 | retryFailedModerationTasks() 71 | }, RETRY_FAILED_TASKS_INTERVAL) 72 | console.log('[Scheduler] 失败审核任务重试定时器已启动(每小时执行一次)') 73 | }) 74 | -------------------------------------------------------------------------------- /server/api/apikeys/[id].put.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | import { v4 as uuidv4 } from 'uuid' 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // 验证登录 8 | const token = extractToken(event) 9 | if (!token) { 10 | throw createError({ 11 | statusCode: 401, 12 | message: '请先登录' 13 | }) 14 | } 15 | 16 | const user = await verifyToken(token) 17 | if (!user) { 18 | throw createError({ 19 | statusCode: 401, 20 | message: 'Token 无效或已过期' 21 | }) 22 | } 23 | 24 | // 获取 ApiKey ID 25 | const id = getRouterParam(event, 'id') 26 | if (!id) { 27 | throw createError({ 28 | statusCode: 400, 29 | message: '缺少 ApiKey ID' 30 | }) 31 | } 32 | 33 | // 查找 ApiKey 34 | const apiKey = await db.apikeys.findOne({ _id: id }) 35 | if (!apiKey) { 36 | throw createError({ 37 | statusCode: 404, 38 | message: 'ApiKey 不存在' 39 | }) 40 | } 41 | 42 | // 获取请求体 43 | const body = await readBody(event) 44 | const { name, enabled, regenerate } = body 45 | 46 | // 构建更新对象 47 | const updateData = { 48 | updatedAt: new Date().toISOString() 49 | } 50 | 51 | if (name !== undefined) { 52 | updateData.name = name.trim() 53 | } 54 | 55 | if (enabled !== undefined) { 56 | updateData.enabled = enabled 57 | } 58 | 59 | // 如果需要重新生成 Key 60 | if (regenerate) { 61 | updateData.key = `sk-${uuidv4().replace(/-/g, '')}` 62 | } 63 | 64 | // 更新 65 | await db.apikeys.update({ _id: id }, { $set: updateData }) 66 | 67 | // 获取更新后的数据 68 | const updatedKey = await db.apikeys.findOne({ _id: id }) 69 | 70 | return { 71 | success: true, 72 | message: 'ApiKey 更新成功', 73 | data: { 74 | id: updatedKey._id, 75 | key: updatedKey.key, 76 | name: updatedKey.name, 77 | isDefault: updatedKey.isDefault, 78 | enabled: updatedKey.enabled, 79 | createdAt: updatedKey.createdAt, 80 | updatedAt: updatedKey.updatedAt 81 | } 82 | } 83 | } catch (error) { 84 | if (error.statusCode) { 85 | throw error 86 | } 87 | 88 | console.error('[ApiKeys] 更新 ApiKey 失败:', error) 89 | throw createError({ 90 | statusCode: 500, 91 | message: '更新 ApiKey 失败' 92 | }) 93 | } 94 | }) 95 | -------------------------------------------------------------------------------- /server/api/settings/index.put.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取请求体 24 | const body = await readBody(event) 25 | const { appName, appLogo, backgroundUrl, backgroundBlur, siteUrl, announcement } = body 26 | 27 | // 验证毛玻璃效果值范围 (0-20) 28 | let blurValue = parseInt(backgroundBlur) || 0 29 | if (blurValue < 0) blurValue = 0 30 | if (blurValue > 20) blurValue = 20 31 | 32 | // 处理公告配置 33 | let announcementValue = { 34 | enabled: false, 35 | content: '', 36 | displayType: 'modal' // 'modal' | 'banner' 37 | } 38 | if (announcement) { 39 | announcementValue = { 40 | enabled: !!announcement.enabled, 41 | content: announcement.content || '', 42 | displayType: ['modal', 'banner'].includes(announcement.displayType) ? announcement.displayType : 'modal' 43 | } 44 | } 45 | 46 | // 处理站点 URL(移除末尾斜杠) 47 | let siteUrlValue = (siteUrl || '').trim() 48 | if (siteUrlValue) { 49 | siteUrlValue = siteUrlValue.replace(/\/+$/, '') 50 | } 51 | 52 | // 构建更新对象 53 | const settingsValue = { 54 | appName: appName || 'easyimg', 55 | appLogo: appLogo || '', 56 | backgroundUrl: backgroundUrl || '', 57 | backgroundBlur: blurValue, 58 | siteUrl: siteUrlValue, 59 | announcement: announcementValue 60 | } 61 | 62 | // 更新设置 63 | await db.settings.update( 64 | { key: 'appSettings' }, 65 | { 66 | $set: { 67 | value: settingsValue, 68 | updatedAt: new Date().toISOString() 69 | } 70 | }, 71 | { upsert: true } 72 | ) 73 | 74 | return { 75 | success: true, 76 | message: '设置已保存', 77 | data: settingsValue 78 | } 79 | } catch (error) { 80 | if (error.statusCode) { 81 | throw error 82 | } 83 | 84 | console.error('[Settings] 更新应用设置失败:', error) 85 | throw createError({ 86 | statusCode: 500, 87 | message: '保存设置失败' 88 | }) 89 | } 90 | }) 91 | -------------------------------------------------------------------------------- /server/api/config/public.get.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | import { getDefaultContentSafetyConfig, MODERATION_PROVIDERS } from '../../utils/moderation.js' 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // 检查是否登录(可选) 8 | const token = extractToken(event) 9 | let isAdmin = false 10 | 11 | if (token) { 12 | const user = await verifyToken(token) 13 | isAdmin = !!user 14 | } 15 | 16 | // 获取公共 API 配置 17 | const config = await db.settings.findOne({ key: 'publicApiConfig' }) 18 | 19 | const defaultConfig = { 20 | enabled: true, 21 | allowedFormats: ['jpeg', 'jpg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico', 'apng', 'tiff', 'tif'], 22 | maxFileSize: 10 * 1024 * 1024, 23 | compressToWebp: true, 24 | webpQuality: 80, 25 | rateLimit: 10, 26 | allowConcurrent: false, 27 | contentSafety: getDefaultContentSafetyConfig() 28 | } 29 | 30 | let configData = config?.value || defaultConfig 31 | 32 | // 确保 contentSafety 配置完整(兼容旧数据) 33 | if (!configData.contentSafety) { 34 | configData.contentSafety = getDefaultContentSafetyConfig() 35 | } else { 36 | // 确保每个 provider 的配置完整(包括默认 apiKey) 37 | const defaultProviders = getDefaultContentSafetyConfig().providers 38 | for (const [key, defaultProvider] of Object.entries(defaultProviders)) { 39 | if (configData.contentSafety.providers?.[key]) { 40 | // 如果没有 apiKey,使用默认值 41 | if (!configData.contentSafety.providers[key].apiKey) { 42 | configData.contentSafety.providers[key].apiKey = defaultProvider.apiKey 43 | } 44 | // 如果没有 name,使用默认值 45 | if (!configData.contentSafety.providers[key].name) { 46 | configData.contentSafety.providers[key].name = defaultProvider.name 47 | } 48 | } 49 | } 50 | } 51 | 52 | // 未登录用户只返回上传所需的基本配置 53 | if (!isAdmin) { 54 | return { 55 | success: true, 56 | data: { 57 | enabled: configData.enabled, 58 | allowedFormats: configData.allowedFormats, 59 | maxFileSize: configData.maxFileSize, 60 | allowConcurrent: configData.allowConcurrent 61 | } 62 | } 63 | } 64 | 65 | // 登录用户返回完整配置 66 | return { 67 | success: true, 68 | data: configData 69 | } 70 | } catch (error) { 71 | if (error.statusCode) { 72 | throw error 73 | } 74 | 75 | console.error('[Config] 获取公共 API 配置失败:', error) 76 | throw createError({ 77 | statusCode: 500, 78 | message: '获取配置失败' 79 | }) 80 | } 81 | }) 82 | -------------------------------------------------------------------------------- /app/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 106 | -------------------------------------------------------------------------------- /server/api/notification/index.put.js: -------------------------------------------------------------------------------- 1 | import { verifyToken, extractToken } from '../../utils/jwt.js' 2 | import { saveNotificationConfig, NOTIFICATION_TYPES, NOTIFICATION_METHODS } from '../../utils/notification.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取请求体 24 | const body = await readBody(event) 25 | const { enabled, method, types, webhook } = body 26 | 27 | // 验证通知方式 28 | if (method && !Object.values(NOTIFICATION_METHODS).includes(method)) { 29 | throw createError({ 30 | statusCode: 400, 31 | message: `不支持的通知方式: ${method}` 32 | }) 33 | } 34 | 35 | // 获取 telegram、email 和 serverchan 配置 36 | const { telegram, email, serverchan } = body 37 | 38 | // 构建配置对象 39 | const config = { 40 | enabled: !!enabled, 41 | method: method || NOTIFICATION_METHODS.WEBHOOK, 42 | types: { 43 | [NOTIFICATION_TYPES.LOGIN]: !!types?.login, 44 | [NOTIFICATION_TYPES.UPLOAD]: !!types?.upload, 45 | [NOTIFICATION_TYPES.NSFW_DETECTED]: !!types?.nsfw, 46 | }, 47 | webhook: { 48 | url: webhook?.url || '', 49 | method: webhook?.method || 'POST', 50 | contentType: webhook?.contentType || 'application/json', 51 | headers: webhook?.headers || {}, 52 | bodyTemplate: webhook?.bodyTemplate || JSON.stringify({ 53 | type: '{{type}}', 54 | title: '{{title}}', 55 | message: '{{message}}', 56 | timestamp: '{{timestamp}}', 57 | data: '{{data}}' 58 | }, null, 2) 59 | }, 60 | telegram: { 61 | token: telegram?.token || '', 62 | chatId: telegram?.chatId || '' 63 | }, 64 | email: { 65 | service: email?.service || '', 66 | user: email?.user || '', 67 | pass: email?.pass || '', 68 | to: email?.to || '' 69 | }, 70 | serverchan: { 71 | sendKey: serverchan?.sendKey || '' 72 | } 73 | } 74 | 75 | // 保存配置 76 | const result = await saveNotificationConfig(config) 77 | 78 | if (!result.success) { 79 | throw createError({ 80 | statusCode: 500, 81 | message: result.error || '保存配置失败' 82 | }) 83 | } 84 | 85 | return { 86 | success: true, 87 | message: '通知配置已保存', 88 | data: config 89 | } 90 | } catch (error) { 91 | if (error.statusCode) { 92 | throw error 93 | } 94 | 95 | console.error('[Notification] 保存通知配置失败:', error) 96 | throw createError({ 97 | statusCode: 500, 98 | message: '保存通知配置失败' 99 | }) 100 | } 101 | }) -------------------------------------------------------------------------------- /app/stores/auth.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useAuthStore = defineStore('auth', { 4 | state: () => ({ 5 | token: null, 6 | user: null, 7 | isAuthenticated: false 8 | }), 9 | 10 | actions: { 11 | // 初始化(从 localStorage 恢复) 12 | init() { 13 | if (import.meta.client) { 14 | const token = localStorage.getItem('token') 15 | const user = localStorage.getItem('user') 16 | if (token && user) { 17 | this.token = token 18 | this.user = JSON.parse(user) 19 | this.isAuthenticated = true 20 | } 21 | } 22 | }, 23 | 24 | // 登录 25 | async login(username, password) { 26 | try { 27 | const response = await $fetch('/api/auth/login', { 28 | method: 'POST', 29 | body: { username, password } 30 | }) 31 | 32 | if (response.success) { 33 | this.token = response.data.token 34 | this.user = response.data.user 35 | this.isAuthenticated = true 36 | 37 | // 保存到 localStorage 38 | if (import.meta.client) { 39 | localStorage.setItem('token', this.token) 40 | localStorage.setItem('user', JSON.stringify(this.user)) 41 | } 42 | 43 | return { success: true } 44 | } 45 | 46 | return { success: false, message: response.message || '登录失败' } 47 | } catch (error) { 48 | return { success: false, message: error.data?.message || '登录失败,请稍后重试' } 49 | } 50 | }, 51 | 52 | // 登出 53 | logout() { 54 | this.token = null 55 | this.user = null 56 | this.isAuthenticated = false 57 | 58 | if (import.meta.client) { 59 | localStorage.removeItem('token') 60 | localStorage.removeItem('user') 61 | } 62 | }, 63 | 64 | // 验证 Token 65 | async verify() { 66 | if (!this.token) { 67 | return false 68 | } 69 | 70 | try { 71 | const response = await $fetch('/api/auth/verify', { 72 | headers: { 73 | 'Authorization': `Bearer ${this.token}` 74 | } 75 | }) 76 | 77 | if (response.success && response.authenticated) { 78 | this.user = response.data.user 79 | return true 80 | } 81 | 82 | // Token 无效,清除 83 | this.logout() 84 | return false 85 | } catch (error) { 86 | this.logout() 87 | return false 88 | } 89 | }, 90 | 91 | // 更新用户名 92 | updateUsername(username, newToken) { 93 | this.user = { ...this.user, username } 94 | if (newToken) { 95 | this.token = newToken 96 | } 97 | 98 | if (import.meta.client) { 99 | localStorage.setItem('token', this.token) 100 | localStorage.setItem('user', JSON.stringify(this.user)) 101 | } 102 | } 103 | }, 104 | 105 | getters: { 106 | authHeader: (state) => { 107 | return state.token ? { 'Authorization': `Bearer ${state.token}` } : {} 108 | } 109 | } 110 | }) 111 | -------------------------------------------------------------------------------- /app/components/Toast.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 61 | -------------------------------------------------------------------------------- /server/api/config/public.put.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | import { validateProviderConfig, getDefaultContentSafetyConfig } from '../../utils/moderation.js' 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // 验证登录 8 | const token = extractToken(event) 9 | if (!token) { 10 | throw createError({ 11 | statusCode: 401, 12 | message: '请先登录' 13 | }) 14 | } 15 | 16 | const user = await verifyToken(token) 17 | if (!user) { 18 | throw createError({ 19 | statusCode: 401, 20 | message: 'Token 无效或已过期' 21 | }) 22 | } 23 | 24 | // 获取请求体 25 | const body = await readBody(event) 26 | 27 | // 验证配置项 28 | const { 29 | enabled, 30 | allowedFormats, 31 | maxFileSize, 32 | compressToWebp, 33 | webpQuality, 34 | rateLimit, 35 | allowConcurrent, 36 | contentSafety 37 | } = body 38 | 39 | // 构建内容安全配置(使用统一的默认配置) 40 | let contentSafetyConfig = getDefaultContentSafetyConfig() 41 | 42 | if (contentSafety) { 43 | // 验证内容安全配置 44 | if (contentSafety.provider && contentSafety.providers?.[contentSafety.provider]) { 45 | const providerConfig = contentSafety.providers[contentSafety.provider] 46 | const validation = validateProviderConfig(contentSafety.provider, providerConfig) 47 | if (!validation.valid) { 48 | throw createError({ 49 | statusCode: 400, 50 | message: validation.error 51 | }) 52 | } 53 | } 54 | 55 | // 合并用户配置 56 | contentSafetyConfig = { 57 | enabled: contentSafety.enabled || false, 58 | provider: contentSafety.provider || contentSafetyConfig.provider, 59 | autoBlacklistIp: contentSafety.autoBlacklistIp || false, 60 | providers: contentSafety.providers || contentSafetyConfig.providers 61 | } 62 | } 63 | 64 | // 构建更新对象 65 | const configValue = { 66 | enabled: enabled !== undefined ? enabled : true, 67 | allowedFormats: Array.isArray(allowedFormats) ? allowedFormats : ['jpg', 'jpeg', 'png', 'gif', 'webp'], 68 | maxFileSize: maxFileSize || 10 * 1024 * 1024, 69 | compressToWebp: compressToWebp !== undefined ? compressToWebp : true, 70 | webpQuality: webpQuality || 80, 71 | rateLimit: rateLimit || 10, 72 | allowConcurrent: allowConcurrent || false, 73 | contentSafety: contentSafetyConfig 74 | } 75 | 76 | // 更新配置 77 | await db.settings.update( 78 | { key: 'publicApiConfig' }, 79 | { 80 | $set: { 81 | value: configValue, 82 | updatedAt: new Date().toISOString() 83 | } 84 | }, 85 | { upsert: true } 86 | ) 87 | 88 | return { 89 | success: true, 90 | message: '配置已保存', 91 | data: configValue 92 | } 93 | } catch (error) { 94 | if (error.statusCode) { 95 | throw error 96 | } 97 | 98 | console.error('[Config] 更新公共 API 配置失败:', error) 99 | throw createError({ 100 | statusCode: 500, 101 | message: '保存配置失败' 102 | }) 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /server/api/images/index.get.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 获取查询参数 7 | const query = getQuery(event) 8 | const page = parseInt(query.page) || 1 9 | const limit = parseInt(query.limit) || 20 10 | const skip = (page - 1) * limit 11 | 12 | // 检查用户是否已登录 13 | const token = extractToken(event) 14 | let isAdmin = false 15 | 16 | if (token) { 17 | try { 18 | const user = await verifyToken(token) 19 | isAdmin = !!user 20 | } catch (e) { 21 | // Token 无效,视为未登录 22 | } 23 | } 24 | 25 | // 获取私有 API 配置,检查是否允许首页展示私有图片 26 | const privateConfig = await db.settings.findOne({ key: 'privateApiConfig' }) 27 | // 默认不显示私有图片,只有明确设置为 true 时才显示 28 | const showPrivateOnHomepage = privateConfig?.value?.showOnHomepage === true 29 | 30 | // 构建查询条件 31 | // 如果配置允许展示私有图片,则所有人都能看到私有图片 32 | // 否则未登录用户只能看到公开上传的图片 33 | let queryCondition 34 | if (isAdmin) { 35 | // 管理员可以看到所有非违规的图片(违规图片不在首页显示) 36 | queryCondition = { isDeleted: false, isNsfw: { $ne: true } } 37 | } else if (showPrivateOnHomepage) { 38 | // 配置开启时展示所有非违规图片 39 | queryCondition = { isDeleted: false, isNsfw: { $ne: true } } 40 | } else { 41 | // 配置关闭且未登录时只展示公开的非违规图片 42 | queryCondition = { isDeleted: false, uploadedByType: 'public', isNsfw: { $ne: true } } 43 | } 44 | 45 | // 获取总数 46 | const total = await db.images.count(queryCondition) 47 | 48 | // 获取图片列表(按上传时间倒序) 49 | let images = await db.images.find(queryCondition) 50 | 51 | // 手动排序(按上传时间倒序) 52 | images.sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt)) 53 | 54 | // 手动分页 55 | images = images.slice(skip, skip + limit) 56 | 57 | // 过滤敏感信息 58 | const safeImages = images.map(img => { 59 | const baseInfo = { 60 | id: img._id, 61 | uuid: img.uuid, 62 | filename: img.filename, 63 | originalName: img.originalName, 64 | format: img.format, 65 | size: img.size, 66 | width: img.width, 67 | height: img.height, 68 | url: `/i/${img.uuid}.${img.format}`, 69 | uploadedBy: img.uploadedBy, 70 | uploadedAt: img.uploadedAt 71 | } 72 | 73 | // 管理员可见额外信息 74 | if (isAdmin) { 75 | return { 76 | ...baseInfo, 77 | isNsfw: img.isNsfw || false, 78 | isDeleted: img.isDeleted || false, 79 | moderationStatus: img.moderationStatus, 80 | moderationScore: img.moderationResult?.score 81 | } 82 | } 83 | 84 | return baseInfo 85 | }) 86 | 87 | return { 88 | success: true, 89 | data: { 90 | images: safeImages, 91 | pagination: { 92 | page, 93 | limit, 94 | total, 95 | totalPages: Math.ceil(total / limit) 96 | } 97 | } 98 | } 99 | } catch (error) { 100 | console.error('[Images] 获取图片列表失败:', error) 101 | throw createError({ 102 | statusCode: 500, 103 | message: '获取图片列表失败' 104 | }) 105 | } 106 | }) 107 | -------------------------------------------------------------------------------- /server/api/version/check.get.js: -------------------------------------------------------------------------------- 1 | import { verifyToken } from '../../utils/jwt' 2 | 3 | // GitHub package.json 的 URL 4 | const PROXY_URL = 'https://cf.111443.xyz/https://raw.githubusercontent.com/chaos-zhu/easyimg/main/package.json' 5 | const DIRECT_URL = 'https://raw.githubusercontent.com/chaos-zhu/easyimg/main/package.json' 6 | 7 | export default defineEventHandler(async (event) => { 8 | try { 9 | // 验证登录状态 10 | const authHeader = getHeader(event, 'authorization') 11 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 12 | return { 13 | success: false, 14 | message: '需要登录后才能检测版本更新' 15 | } 16 | } 17 | 18 | const token = authHeader.substring(7) 19 | const decoded = verifyToken(token) 20 | if (!decoded) { 21 | return { 22 | success: false, 23 | message: '登录已过期,请重新登录' 24 | } 25 | } 26 | 27 | // 从 runtimeConfig 获取当前版本(构建时注入) 28 | const config = useRuntimeConfig() 29 | const currentVersion = config.appVersion || '1.0.0' 30 | 31 | // 从 GitHub 获取最新版本 32 | let latestVersion = null 33 | let hasUpdate = false 34 | let error = null 35 | 36 | // 尝试通过代理获取 37 | let remotePackage = await fetchPackageJson(PROXY_URL) 38 | 39 | // 如果代理失败,使用源地址兜底 40 | if (!remotePackage) { 41 | remotePackage = await fetchPackageJson(DIRECT_URL) 42 | } 43 | 44 | if (remotePackage && remotePackage.version) { 45 | latestVersion = remotePackage.version 46 | // 比较版本号 47 | hasUpdate = compareVersions(latestVersion, currentVersion) > 0 48 | } else { 49 | error = '无法获取最新版本信息' 50 | } 51 | 52 | return { 53 | success: true, 54 | data: { 55 | currentVersion, 56 | latestVersion, 57 | hasUpdate, 58 | error 59 | } 60 | } 61 | } catch (err) { 62 | return { 63 | success: false, 64 | message: err.message 65 | } 66 | } 67 | }) 68 | 69 | /** 70 | * 从指定 URL 获取 package.json 71 | * @param {string} url - 请求地址 72 | * @returns {object|null} - package.json 对象或 null 73 | */ 74 | async function fetchPackageJson(url) { 75 | try { 76 | const response = await fetch(url, { 77 | headers: { 78 | 'Accept': 'application/json', 79 | 'User-Agent': 'EasyImg-Version-Check' 80 | }, 81 | // 设置超时时间为 10 秒 82 | signal: AbortSignal.timeout(10000) 83 | }) 84 | 85 | if (response.ok) { 86 | return await response.json() 87 | } 88 | } catch (err) { 89 | // 请求失败,返回 null 90 | console.log(`版本检测请求失败 (${url}):`, err.message) 91 | } 92 | return null 93 | } 94 | 95 | /** 96 | * 比较两个语义化版本号 97 | * @param {string} v1 - 版本号1 98 | * @param {string} v2 - 版本号2 99 | * @returns {number} - 1: v1 > v2, -1: v1 < v2, 0: v1 = v2 100 | */ 101 | function compareVersions(v1, v2) { 102 | const parts1 = v1.replace(/^v/, '').split('.').map(Number) 103 | const parts2 = v2.replace(/^v/, '').split('.').map(Number) 104 | 105 | for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { 106 | const num1 = parts1[i] || 0 107 | const num2 = parts2[i] || 0 108 | 109 | if (num1 > num2) return 1 110 | if (num1 < num2) return -1 111 | } 112 | 113 | return 0 114 | } -------------------------------------------------------------------------------- /server/api/images/preview/[...path].js: -------------------------------------------------------------------------------- 1 | import { createReadStream, existsSync } from 'fs' 2 | import db from '../../../utils/db.js' 3 | import { verifyToken, extractToken } from '../../../utils/jwt.js' 4 | import { getImagePath } from '../../../utils/upload.js' 5 | 6 | export default defineEventHandler(async (event) => { 7 | try { 8 | // 验证登录 - 支持 header 和 query 参数两种方式传递 token 9 | // 因为 标签无法设置 header,所以需要支持 query 参数 10 | let token = extractToken(event) 11 | 12 | // 如果 header 中没有 token,尝试从 query 参数获取 13 | if (!token) { 14 | const query = getQuery(event) 15 | token = query.token 16 | } 17 | 18 | if (!token) { 19 | throw createError({ 20 | statusCode: 401, 21 | message: '请先登录' 22 | }) 23 | } 24 | 25 | const user = await verifyToken(token) 26 | if (!user) { 27 | throw createError({ 28 | statusCode: 401, 29 | message: 'Token 无效或已过期' 30 | }) 31 | } 32 | 33 | // 获取路径参数,例如 /api/images/preview/uuid.webp 34 | let path = getRouterParam(event, 'path') 35 | 36 | // [...path] 可能返回数组,需要处理 37 | if (Array.isArray(path)) { 38 | path = path.join('/') 39 | } 40 | 41 | if (!path) { 42 | throw createError({ 43 | statusCode: 404, 44 | message: '图片不存在' 45 | }) 46 | } 47 | 48 | // 解析 uuid 和扩展名 49 | const match = path.match(/^([a-f0-9-]+)\.(\w+)$/i) 50 | 51 | if (!match) { 52 | throw createError({ 53 | statusCode: 404, 54 | message: '图片不存在' 55 | }) 56 | } 57 | 58 | const [, uuid, ext] = match 59 | 60 | // 从数据库查找图片(不检查 isDeleted,管理员可以查看所有图片) 61 | const image = await db.images.findOne({ uuid: uuid }) 62 | 63 | if (!image) { 64 | throw createError({ 65 | statusCode: 404, 66 | message: '图片不存在' 67 | }) 68 | } 69 | 70 | // 检查文件是否存在 71 | const filePath = getImagePath(image.filename) 72 | 73 | if (!existsSync(filePath)) { 74 | console.log('[Admin Preview] File does not exist:', filePath) 75 | throw createError({ 76 | statusCode: 404, 77 | message: '图片文件不存在' 78 | }) 79 | } 80 | 81 | // 设置响应头 82 | const mimeTypes = { 83 | 'jpg': 'image/jpeg', 84 | 'jpeg': 'image/jpeg', 85 | 'png': 'image/png', 86 | 'gif': 'image/gif', 87 | 'webp': 'image/webp', 88 | 'bmp': 'image/bmp', 89 | 'ico': 'image/x-icon', 90 | 'svg': 'image/svg+xml', 91 | 'avif': 'image/avif', 92 | 'tiff': 'image/tiff' 93 | } 94 | 95 | const contentType = mimeTypes[image.format] || 'application/octet-stream' 96 | 97 | // 设置响应头(不缓存,因为是管理员预览) 98 | setHeader(event, 'Content-Type', contentType) 99 | setHeader(event, 'Content-Length', image.size) 100 | setHeader(event, 'Cache-Control', 'no-store, no-cache, must-revalidate') 101 | setHeader(event, 'X-Content-Type-Options', 'nosniff') 102 | 103 | // 返回文件流 104 | return sendStream(event, createReadStream(filePath)) 105 | } catch (error) { 106 | if (error.statusCode) { 107 | throw error 108 | } 109 | 110 | console.error('[Admin Preview] 获取图片失败:', error) 111 | throw createError({ 112 | statusCode: 500, 113 | message: '获取图片失败' 114 | }) 115 | } 116 | }) -------------------------------------------------------------------------------- /server/utils/ipBlacklist.js: -------------------------------------------------------------------------------- 1 | import db from './db.js' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | /** 5 | * IP 黑名单管理工具 6 | */ 7 | 8 | /** 9 | * 检查 IP 是否在黑名单中 10 | * @param {string} ip - IP 地址 11 | * @returns {Promise} 12 | */ 13 | export async function isBlacklisted(ip) { 14 | if (!ip || ip === 'unknown') { 15 | return false 16 | } 17 | const record = await db.ipBlacklist.findOne({ ip }) 18 | return !!record 19 | } 20 | 21 | /** 22 | * 将 IP 添加到黑名单 23 | * @param {string} ip - IP 地址 24 | * @param {string} reason - 拉黑原因 25 | * @returns {Promise} 26 | */ 27 | export async function addToBlacklist(ip, reason = '') { 28 | if (!ip || ip === 'unknown') { 29 | return { success: false, error: '无效的 IP 地址' } 30 | } 31 | 32 | // 检查是否已在黑名单中 33 | const existing = await db.ipBlacklist.findOne({ ip }) 34 | if (existing) { 35 | return { success: false, error: 'IP 已在黑名单中', existing: true } 36 | } 37 | 38 | const record = { 39 | _id: uuidv4(), 40 | ip: ip, 41 | reason: reason, 42 | createdAt: new Date().toISOString() 43 | } 44 | 45 | await db.ipBlacklist.insert(record) 46 | console.log(`[IPBlacklist] IP ${ip} 已加入黑名单: ${reason}`) 47 | 48 | return { success: true, record } 49 | } 50 | 51 | /** 52 | * 从黑名单中移除 IP 53 | * @param {string} ip - IP 地址 54 | * @returns {Promise} 55 | */ 56 | export async function removeFromBlacklist(ip) { 57 | if (!ip) { 58 | return { success: false, error: '无效的 IP 地址' } 59 | } 60 | 61 | const numRemoved = await db.ipBlacklist.remove({ ip }) 62 | if (numRemoved > 0) { 63 | console.log(`[IPBlacklist] IP ${ip} 已从黑名单中移除`) 64 | return { success: true } 65 | } 66 | 67 | return { success: false, error: 'IP 不在黑名单中' } 68 | } 69 | 70 | /** 71 | * 通过 ID 从黑名单中移除 72 | * @param {string} id - 记录 ID 73 | * @returns {Promise} 74 | */ 75 | export async function removeFromBlacklistById(id) { 76 | if (!id) { 77 | return { success: false, error: '无效的 ID' } 78 | } 79 | 80 | const record = await db.ipBlacklist.findOne({ _id: id }) 81 | if (!record) { 82 | return { success: false, error: '记录不存在' } 83 | } 84 | 85 | await db.ipBlacklist.remove({ _id: id }) 86 | console.log(`[IPBlacklist] IP ${record.ip} 已从黑名单中移除`) 87 | 88 | return { success: true, ip: record.ip } 89 | } 90 | 91 | /** 92 | * 获取黑名单列表 93 | * @param {object} options - 选项 { page, limit } 94 | * @returns {Promise} 95 | */ 96 | export async function getBlacklist(options = {}) { 97 | const page = options.page || 1 98 | const limit = options.limit || 20 99 | const skip = (page - 1) * limit 100 | 101 | const total = await db.ipBlacklist.count({}) 102 | let records = await db.ipBlacklist.find({}) 103 | 104 | // 按创建时间倒序排序 105 | records.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) 106 | 107 | // 分页 108 | records = records.slice(skip, skip + limit) 109 | 110 | return { 111 | records, 112 | pagination: { 113 | page, 114 | limit, 115 | total, 116 | totalPages: Math.ceil(total / limit) 117 | } 118 | } 119 | } 120 | 121 | /** 122 | * 获取黑名单数量 123 | * @returns {Promise} 124 | */ 125 | export async function getBlacklistCount() { 126 | return await db.ipBlacklist.count({}) 127 | } 128 | 129 | export default { 130 | isBlacklisted, 131 | addToBlacklist, 132 | removeFromBlacklist, 133 | removeFromBlacklistById, 134 | getBlacklist, 135 | getBlacklistCount 136 | } -------------------------------------------------------------------------------- /server/routes/i/[...path].js: -------------------------------------------------------------------------------- 1 | import { createReadStream, existsSync } from 'fs' 2 | import { createHash } from 'crypto' 3 | import db from '../../utils/db.js' 4 | import { getImagePath } from '../../utils/upload.js' 5 | 6 | export default defineEventHandler(async (event) => { 7 | try { 8 | // 获取路径参数,例如 /i/uuid.webp 9 | let path = getRouterParam(event, 'path') 10 | 11 | // [...path] 可能返回数组,需要处理 12 | if (Array.isArray(path)) { 13 | path = path.join('/') 14 | } 15 | 16 | if (!path) { 17 | throw createError({ 18 | statusCode: 404, 19 | message: '图片不存在' 20 | }) 21 | } 22 | 23 | // 解析 uuid 和扩展名 24 | const match = path.match(/^([a-f0-9-]+)\.(\w+)$/i) 25 | 26 | if (!match) { 27 | throw createError({ 28 | statusCode: 404, 29 | message: '图片不存在' 30 | }) 31 | } 32 | 33 | const [, uuid, ext] = match 34 | 35 | // 从数据库查找图片 36 | const image = await db.images.findOne({ uuid: uuid }) 37 | 38 | if (!image) { 39 | throw createError({ 40 | statusCode: 404, 41 | message: '图片不存在' 42 | }) 43 | } 44 | 45 | // 检查是否已删除 46 | if (image.isDeleted) { 47 | throw createError({ 48 | statusCode: 404, 49 | message: '图片不存在' 50 | }) 51 | } 52 | 53 | // 检查是否为违规图片(NSFW) 54 | if (image.isNsfw) { 55 | throw createError({ 56 | statusCode: 403, 57 | message: '该图片因违规已被禁止访问' 58 | }) 59 | } 60 | 61 | // 检查文件是否存在 62 | const filePath = getImagePath(image.filename) 63 | 64 | if (!existsSync(filePath)) { 65 | console.log('[Image Route] File does not exist') 66 | throw createError({ 67 | statusCode: 404, 68 | message: '图片文件不存在' 69 | }) 70 | } 71 | 72 | // 设置响应头 73 | const mimeTypes = { 74 | 'jpg': 'image/jpeg', 75 | 'jpeg': 'image/jpeg', 76 | 'png': 'image/png', 77 | 'gif': 'image/gif', 78 | 'webp': 'image/webp', 79 | 'bmp': 'image/bmp', 80 | 'ico': 'image/x-icon', 81 | 'svg': 'image/svg+xml', 82 | 'avif': 'image/avif', 83 | 'tiff': 'image/tiff' 84 | } 85 | 86 | const contentType = mimeTypes[image.format] || 'application/octet-stream' 87 | 88 | // 生成 ETag(基于文件UUID和更新时间) 89 | const etag = `"${createHash('md5').update(`${image.uuid}-${image.updatedAt || image.createdAt}`).digest('hex')}"` 90 | 91 | // 检查 If-None-Match 头,支持 304 响应 92 | const ifNoneMatch = getHeader(event, 'if-none-match') 93 | if (ifNoneMatch === etag) { 94 | setResponseStatus(event, 304) 95 | return '' 96 | } 97 | 98 | // 计算过期时间(365天后) 99 | const maxAge = 365 * 24 * 60 * 60 // 365天,单位秒 100 | const expires = new Date(Date.now() + maxAge * 1000).toUTCString() 101 | 102 | // 设置完善的缓存响应头 103 | setHeader(event, 'Content-Type', contentType) 104 | setHeader(event, 'Content-Length', image.size) 105 | setHeader(event, 'Cache-Control', `public, max-age=${maxAge}, immutable`) // 365天缓存,immutable表示内容不会变化 106 | setHeader(event, 'Expires', expires) // 兼容旧浏览器 107 | setHeader(event, 'ETag', etag) // 支持条件请求 108 | setHeader(event, 'Last-Modified', new Date(image.createdAt).toUTCString()) // 最后修改时间 109 | setHeader(event, 'X-Content-Type-Options', 'nosniff') // 安全头 110 | 111 | // 返回文件流 112 | return sendStream(event, createReadStream(filePath)) 113 | } catch (error) { 114 | if (error.statusCode) { 115 | throw error 116 | } 117 | 118 | console.error('[Image] 获取图片失败:', error) 119 | throw createError({ 120 | statusCode: 500, 121 | message: '获取图片失败' 122 | }) 123 | } 124 | }) 125 | -------------------------------------------------------------------------------- /app/components/ThemeToggle.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 126 | -------------------------------------------------------------------------------- /server/api/settings/stats.get.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { verifyToken, extractToken } from '../../utils/jwt.js' 3 | 4 | // 统计缓存,避免频繁计算 5 | let statsCache = null 6 | let statsCacheTime = 0 7 | const CACHE_TTL = 60 * 1000 // 缓存 60 秒 8 | 9 | // 计算存储大小的函数(分批处理以减少内存压力) 10 | async function calculateStorageStats() { 11 | const allImages = await db.images.find({}) 12 | 13 | let totalSize = 0 14 | let deletedSize = 0 15 | 16 | for (const img of allImages) { 17 | const size = img.size || 0 18 | totalSize += size 19 | if (img.isDeleted) { 20 | deletedSize += size 21 | } 22 | } 23 | 24 | return { totalSize, deletedSize, activeSize: totalSize - deletedSize } 25 | } 26 | 27 | export default defineEventHandler(async (event) => { 28 | try { 29 | // 验证登录 30 | const token = extractToken(event) 31 | if (!token) { 32 | throw createError({ 33 | statusCode: 401, 34 | message: '请先登录' 35 | }) 36 | } 37 | 38 | const user = await verifyToken(token) 39 | if (!user) { 40 | throw createError({ 41 | statusCode: 401, 42 | message: 'Token 无效或已过期' 43 | }) 44 | } 45 | 46 | const now = Date.now() 47 | const forceRefresh = getQuery(event).refresh === 'true' 48 | 49 | // 检查缓存是否有效 50 | if (!forceRefresh && statsCache && (now - statsCacheTime) < CACHE_TTL) { 51 | return { 52 | success: true, 53 | data: statsCache, 54 | cached: true 55 | } 56 | } 57 | 58 | // 使用 count 查询获取各类图片数量(这些查询很快) 59 | const [ 60 | totalActiveImages, 61 | publicImagesCount, 62 | privateImagesCount, 63 | apiKeyImagesCount, 64 | deletedImagesCount, 65 | nsfwImagesCount, 66 | moderatedImagesCount 67 | ] = await Promise.all([ 68 | db.images.count({ isDeleted: false }), 69 | db.images.count({ isDeleted: false, uploadedByType: 'public' }), 70 | db.images.count({ isDeleted: false, uploadedByType: 'private' }), 71 | db.images.count({ isDeleted: false, uploadedByType: 'apikey' }), 72 | db.images.count({ isDeleted: true }), 73 | db.images.count({ isNsfw: true }), // 违规图片总数 74 | db.images.count({ moderationChecked: true }) // 检测图片总数 75 | ]) 76 | 77 | // 计算存储大小(这个操作较慢,所以使用缓存) 78 | const storageStats = await calculateStorageStats() 79 | 80 | // 计算违规率(相对于检测图片总数) 81 | const nsfwRate = moderatedImagesCount > 0 ? (nsfwImagesCount / moderatedImagesCount * 100).toFixed(2) : 0 82 | 83 | const stats = { 84 | // 活跃图片统计 85 | totalImages: totalActiveImages, 86 | publicImages: publicImagesCount, 87 | privateImages: privateImagesCount + apiKeyImagesCount, 88 | 89 | // 存储空间统计 90 | totalSize: storageStats.totalSize, // 总存储空间(包含软删除) 91 | activeSize: storageStats.activeSize, // 活跃图片占用空间 92 | deletedSize: storageStats.deletedSize, // 软删除图片占用空间 93 | 94 | // 软删除统计 95 | deletedImagesCount: deletedImagesCount, 96 | 97 | // 内容安全统计 98 | moderatedImagesCount: moderatedImagesCount, // 检测图片总数 99 | nsfwImagesCount: nsfwImagesCount, // 违规图片总数 100 | nsfwRate: parseFloat(nsfwRate) // 违规率(百分比) 101 | } 102 | 103 | // 更新缓存 104 | statsCache = stats 105 | statsCacheTime = now 106 | 107 | return { 108 | success: true, 109 | data: stats, 110 | cached: false 111 | } 112 | } catch (error) { 113 | if (error.statusCode) { 114 | throw error 115 | } 116 | 117 | console.error('[Settings] 获取统计数据失败:', error) 118 | throw createError({ 119 | statusCode: 500, 120 | message: '获取统计数据失败' 121 | }) 122 | } 123 | }) 124 | 125 | // 导出清除缓存的函数,供其他模块调用(如上传、删除图片后) 126 | export function clearStatsCache() { 127 | statsCache = null 128 | statsCacheTime = 0 129 | } -------------------------------------------------------------------------------- /server/api/notification/test.post.js: -------------------------------------------------------------------------------- 1 | import { verifyToken, extractToken } from '../../utils/jwt.js' 2 | import { testWebhook, testTelegram, testEmail, testServerChan } from '../../utils/notification.js' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | // 验证登录 7 | const token = extractToken(event) 8 | if (!token) { 9 | throw createError({ 10 | statusCode: 401, 11 | message: '请先登录' 12 | }) 13 | } 14 | 15 | const user = await verifyToken(token) 16 | if (!user) { 17 | throw createError({ 18 | statusCode: 401, 19 | message: 'Token 无效或已过期' 20 | }) 21 | } 22 | 23 | // 获取请求体中的配置 24 | const body = await readBody(event) 25 | const { webhook, telegram, email, serverchan, method } = body 26 | 27 | // 根据通知方式测试 28 | if (method === 'serverchan') { 29 | // 测试 Server酱 30 | if (!serverchan?.sendKey) { 31 | throw createError({ 32 | statusCode: 400, 33 | message: '请提供 Server酱 SendKey' 34 | }) 35 | } 36 | 37 | const result = await testServerChan({ 38 | sendKey: serverchan.sendKey 39 | }) 40 | 41 | if (!result.success) { 42 | return { 43 | success: false, 44 | message: result.error || '测试失败' 45 | } 46 | } 47 | 48 | return { 49 | success: true, 50 | message: '测试通知发送成功' 51 | } 52 | } else if (method === 'email') { 53 | // 测试 Email 54 | if (!email?.service || !email?.user || !email?.pass) { 55 | throw createError({ 56 | statusCode: 400, 57 | message: '请提供完整的邮件配置(service、user、pass)' 58 | }) 59 | } 60 | 61 | const result = await testEmail({ 62 | service: email.service, 63 | user: email.user, 64 | pass: email.pass, 65 | to: email.to || '' 66 | }) 67 | 68 | if (!result.success) { 69 | return { 70 | success: false, 71 | message: result.error || '测试失败' 72 | } 73 | } 74 | 75 | return { 76 | success: true, 77 | message: '测试通知发送成功' 78 | } 79 | } else if (method === 'telegram') { 80 | // 测试 Telegram 81 | if (!telegram?.token || !telegram?.chatId) { 82 | throw createError({ 83 | statusCode: 400, 84 | message: '请提供 Telegram Token 和 Chat ID' 85 | }) 86 | } 87 | 88 | const result = await testTelegram({ 89 | token: telegram.token, 90 | chatId: telegram.chatId 91 | }) 92 | 93 | if (!result.success) { 94 | return { 95 | success: false, 96 | message: result.error || '测试失败' 97 | } 98 | } 99 | 100 | return { 101 | success: true, 102 | message: '测试通知发送成功' 103 | } 104 | } else { 105 | // 默认测试 Webhook 106 | if (!webhook?.url) { 107 | throw createError({ 108 | statusCode: 400, 109 | message: '请提供 Webhook URL' 110 | }) 111 | } 112 | 113 | const result = await testWebhook({ 114 | url: webhook.url, 115 | method: webhook.method || 'POST', 116 | contentType: webhook.contentType || 'application/json', 117 | headers: webhook.headers || {}, 118 | bodyTemplate: webhook.bodyTemplate || JSON.stringify({ 119 | type: '{{type}}', 120 | title: '{{title}}', 121 | message: '{{message}}', 122 | timestamp: '{{timestamp}}', 123 | data: '{{data}}' 124 | }, null, 2) 125 | }) 126 | 127 | if (!result.success) { 128 | return { 129 | success: false, 130 | message: result.error || '测试失败' 131 | } 132 | } 133 | 134 | return { 135 | success: true, 136 | message: '测试通知发送成功' 137 | } 138 | } 139 | } catch (error) { 140 | if (error.statusCode) { 141 | throw error 142 | } 143 | 144 | console.error('[Notification] 测试通知失败:', error) 145 | throw createError({ 146 | statusCode: 500, 147 | message: '测试失败: ' + error.message 148 | }) 149 | } 150 | }) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # EasyImg 4 | 5 | _✨ 简单易用的个人图床系统,基于 Nuxt.js 构建 ✨_ 6 | 7 | 8 | release 9 | 10 | 11 | 12 | License 13 | 14 | 15 | 16 | easyimg 17 | 18 | 19 | 20 | 21 | [功能特性](#功能特性) • [快速开始](#快速开始) • [配置说明](#配置说明) • [API 文档](#api-文档) • [常见问题](#常见问题) 22 | 23 |
24 | 25 | ## 项目预览 26 | 27 |
28 | 点击展开查看项目截图 29 | 30 | ![项目预览1](md-images/img1.jpg) 31 | 32 | ![项目预览2](md-images/img2.jpg) 33 | 34 | ![项目预览3](md-images/img3.jpg) 35 | 36 | ![项目预览4](md-images/img4.jpg) 37 | 38 | ![项目预览5](md-images/img5.jpg) 39 | 40 | ![项目预览6](md-images/img6.jpg) 41 | 42 | ![项目预览7](md-images/img7.jpg) 43 | 44 | ![项目预览8](md-images/img8.jpg) 45 | 46 |
47 | 48 | ## 功能特性 49 | 50 | ### 🖼️ 图片管理 51 | - **多种上传方式**:支持点击、拖拽、粘贴上传,支持多图批量上传 52 | - **URL 上传**:支持从 URL 直接下载图片到本地图库 53 | - **瀑布流展示**:响应式瀑布流布局,自适应不同屏幕尺寸 54 | - **图片预览**:支持大图预览,显示图片详细信息 55 | - **批量操作**:支持批量选择、批量删除图片 56 | - **回收站**:软删除机制,支持清空回收站释放空间 57 | 58 | ### 🔐 权限控制 59 | - **公共/私有上传**:支持访客上传和登录后私有上传两种模式 60 | - **API Key 管理**:支持创建多个 API Key,方便第三方工具调用 61 | - **IP 黑名单**:支持手动或自动拉黑恶意 IP 62 | 63 | ### 🛡️ 内容安全 64 | - **NSFW 检测**:支持多种鉴黄服务(nsfwdet.com、elysiatools.com、自建 nsfw_detector) 65 | - **自动处理**:违规图片自动软删除,可选自动拉黑上传者 IP 66 | - **违规管理**:支持查看违规图片列表,可手动取消违规标记 67 | 68 | ### 📊 数据统计 69 | - **存储统计**:实时统计活跃图片数、存储空间占用 70 | - **分类统计**:区分公共上传和私有上传数量 71 | - **内容安全统计**:检测图片总数、违规图片数、违规率 72 | 73 | ### 🔔 通知推送 74 | - **多种通知方式**:支持 Webhook、Telegram、Email、Server酱 75 | - **事件通知**:登录通知、图片上传通知、鉴黄检测结果通知 76 | - **自定义模板**:Webhook 支持自定义请求体模板 77 | 78 | ### ⚙️ 系统设置 79 | - **应用配置**:自定义应用名称、Logo、全局背景图片 80 | - **公告系统**:支持弹窗和横幅两种公告展示形式 81 | - **上传配置**:可配置允许的格式、文件大小限制、WebP 压缩等 82 | - **频率限制**:支持配置同一 IP 的请求频率限制 83 | 84 | ### 🎨 界面特性 85 | - **深色模式**:支持亮色/深色主题切换 86 | - **响应式设计**:完美适配桌面端和移动端 87 | - **毛玻璃效果**:支持背景图片毛玻璃模糊效果 88 | 89 | ## 快速开始 90 | 91 | ### Docker Compose 部署(推荐) 92 | 93 | ```bash 94 | # 1. 创建 easyimg 目录 95 | mkdir -p /root/easyimg && cd /root/easyimg 96 | 97 | 98 | # 2. 下载docker-compose.yml文件 99 | wget https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easyimg/refs/heads/main/docker-compose.yml 100 | 101 | # 使用 docker-compose 102 | docker compose up -d 103 | ``` 104 | 105 | ### Docker run部署 106 | 107 | ```bash 108 | docker run -d --name easyimg -p 3000:3000 -v ./db:/app/db -v ./uploads:/app/uploads ghcr.io/chaos-zhu/easyimg:latest 109 | ``` 110 | 111 | 112 | ### 手动部署 113 | 114 | ```bash 115 | # 安装依赖 116 | pnpm install 117 | 118 | # 开发模式 119 | pnpm dev 120 | 121 | # 构建生产版本 122 | pnpm build 123 | 124 | # 启动生产服务 125 | node .output/server/index.mjs 126 | ``` 127 | 128 | ### 默认账户 129 | 130 | 首次启动后,使用以下默认账户登录: 131 | 132 | - **用户名**:`easyimg` 133 | - **密码**:`easyimg` 134 | 135 | > ⚠️ 请登录后立即修改默认用户名密码! 136 | 137 | 146 | 147 | ### 数据持久化 148 | 149 | - `db/` - 数据库文件(NeDB) 150 | - `uploads/` - 上传的图片文件 151 | 152 | 使用 Docker 部署时,请确保挂载数据目录: 153 | 154 | ```yaml 155 | volumes: 156 | - ./data:/app/data 157 | - ./uploads:/app/uploads 158 | ``` 159 | 160 | ## 常见问题 161 | 162 | ### Q: 如何重置管理员密码? 163 | 164 | 删除 `db/admin.db` 文件后重启服务,系统会重新创建默认账户。 165 | 166 | ### Q: 如何备份数据? 167 | 168 | 备份 `db和uploads` 目录即可,包含所有数据库文件和上传的图片。 169 | 170 | ### Q: 支持哪些图片格式? 171 | 172 | 默认支持:JPEG、JPG、PNG、GIF、WebP、AVIF、SVG、BMP、ICO、APNG、TIFF 173 | 174 | ## 作者其他项目 175 | 176 | - [EasyNode](https://github.com/chaos-zhu/easynode) - 多功能 Linux & Windows 服务器 WEB 终端面板 177 | - [EasyNavTab](https://github.com/chaos-zhu/easynavtab) - 开源浏览器插件,自定义新标签页 178 | 179 | ## 交流反馈 180 | 181 | - **Telegram 频道**:[https://t.me/easynode_notify](https://t.me/easynode_notify) 182 | - **GitHub Issues**:[提交问题](https://github.com/chaos-zhu/easyimg/issues) 183 | 184 | ## 开源协议 185 | 186 | [Apache-License2.0](LICENSE) -------------------------------------------------------------------------------- /server/utils/image.js: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { join, extname } from 'path' 4 | import { existsSync, mkdirSync, unlinkSync } from 'fs' 5 | import { writeFile } from 'fs/promises' 6 | 7 | // 上传目录:生产环境使用 /app/uploads,开发环境使用项目根目录下的 uploads 8 | const uploadsDir = process.env.NODE_ENV === 'production' 9 | ? '/app/uploads' 10 | : join(process.cwd(), 'uploads') 11 | 12 | // 确保 uploads 目录存在 13 | if (!existsSync(uploadsDir)) { 14 | mkdirSync(uploadsDir, { recursive: true }) 15 | } 16 | 17 | // 支持的图片格式 18 | export const COMMON_FORMATS = ['jpeg', 'jpg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico', 'apng', 'tiff', 'tif'] 19 | export const ALL_FORMATS = ['jpeg', 'jpg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico', 'apng', 'tiff', 'tif'] 20 | 21 | /** 22 | * 获取图片元信息 23 | */ 24 | export async function getImageMetadata(buffer) { 25 | try { 26 | const metadata = await sharp(buffer).metadata() 27 | return { 28 | width: metadata.width, 29 | height: metadata.height, 30 | format: metadata.format, 31 | size: buffer.length 32 | } 33 | } catch (error) { 34 | console.error('获取图片元信息失败:', error) 35 | return { width: 0, height: 0, format: 'unknown', size: buffer.length } 36 | } 37 | } 38 | 39 | /** 40 | * 处理图片(压缩/转换格式) 41 | */ 42 | export async function processImage(buffer, options = {}) { 43 | const { format = 'webp', quality = 80 } = options 44 | try { 45 | // GIF 不转换,保持原格式 46 | if (options.skipGif) { 47 | const metadata = await sharp(buffer).metadata() 48 | if (metadata.format === 'gif') { 49 | return buffer 50 | } 51 | } 52 | 53 | const processed = await sharp(buffer) 54 | .toFormat(format, { quality }) 55 | .toBuffer() 56 | return processed 57 | } catch (error) { 58 | console.error('处理图片失败:', error) 59 | throw error 60 | } 61 | } 62 | 63 | /** 64 | * 保存上传的文件到磁盘 65 | */ 66 | export async function saveUploadedFile(buffer, filename) { 67 | const filepath = join(uploadsDir, filename) 68 | await writeFile(filepath, buffer) 69 | return filepath 70 | } 71 | 72 | /** 73 | * 压缩并转换为 WebP 74 | */ 75 | export async function compressToWebP(buffer, quality = 80) { 76 | try { 77 | const compressed = await sharp(buffer) 78 | .webp({ quality }) 79 | .toBuffer() 80 | return compressed 81 | } catch (error) { 82 | console.error('压缩图片失败:', error) 83 | throw error 84 | } 85 | } 86 | 87 | /** 88 | * 转换为 WebP(不压缩) 89 | */ 90 | export async function convertToWebP(buffer) { 91 | try { 92 | const converted = await sharp(buffer) 93 | .webp({ lossless: true }) 94 | .toBuffer() 95 | return converted 96 | } catch (error) { 97 | console.error('转换图片失败:', error) 98 | throw error 99 | } 100 | } 101 | 102 | /** 103 | * 保存图片到磁盘 104 | */ 105 | export async function saveImage(buffer, filename) { 106 | const filepath = join(uploadsDir, filename) 107 | await sharp(buffer).toFile(filepath) 108 | return filepath 109 | } 110 | 111 | /** 112 | * 删除图片文件 113 | */ 114 | export function deleteImage(filename) { 115 | const filepath = join(uploadsDir, filename) 116 | if (existsSync(filepath)) { 117 | unlinkSync(filepath) 118 | return true 119 | } 120 | return false 121 | } 122 | 123 | /** 124 | * 生成唯一文件名 125 | */ 126 | export function generateFilename(extension) { 127 | const uuid = uuidv4() 128 | return `${uuid}.${extension}` 129 | } 130 | 131 | /** 132 | * 获取文件扩展名 133 | */ 134 | export function getExtension(filename) { 135 | return extname(filename).toLowerCase().replace('.', '') 136 | } 137 | 138 | /** 139 | * 验证图片格式 140 | */ 141 | export function isValidFormat(format, allowedFormats) { 142 | return allowedFormats.map(f => f.toLowerCase()).includes(format.toLowerCase()) 143 | } 144 | 145 | /** 146 | * 获取 uploads 目录路径 147 | */ 148 | export function getUploadsDir() { 149 | return uploadsDir 150 | } 151 | 152 | /** 153 | * 获取图片文件路径 154 | */ 155 | export function getImageFilePath(filename) { 156 | return join(uploadsDir, filename) 157 | } 158 | 159 | export default { 160 | COMMON_FORMATS, 161 | ALL_FORMATS, 162 | getImageMetadata, 163 | compressToWebP, 164 | convertToWebP, 165 | saveImage, 166 | deleteImage, 167 | generateFilename, 168 | getExtension, 169 | isValidFormat, 170 | getUploadsDir 171 | } 172 | -------------------------------------------------------------------------------- /server/utils/upload.js: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, unlinkSync } from 'fs' 2 | import { writeFile } from 'fs/promises' 3 | import { join } from 'path' 4 | import { v4 as uuidv4 } from 'uuid' 5 | import { processImage, getImageMetadata } from './image.js' 6 | import db from './db.js' 7 | 8 | // 上传目录:生产环境使用 /app/uploads,开发环境使用项目根目录下的 uploads 9 | const uploadsDir = process.env.NODE_ENV === 'production' 10 | ? '/app/uploads' 11 | : join(process.cwd(), 'uploads') 12 | 13 | // 确保上传目录存在 14 | if (!existsSync(uploadsDir)) { 15 | mkdirSync(uploadsDir, { recursive: true }) 16 | } 17 | 18 | console.log('[Upload] 上传目录:', uploadsDir) 19 | 20 | /** 21 | * 解析 multipart/form-data 请求 22 | */ 23 | export async function parseFormData(event) { 24 | const formData = await readMultipartFormData(event) 25 | 26 | if (!formData || formData.length === 0) { 27 | return { file: null } 28 | } 29 | 30 | // 查找文件字段 31 | const fileField = formData.find(field => field.name === 'file' || field.name === 'image') 32 | 33 | if (!fileField || !fileField.data) { 34 | return { file: null } 35 | } 36 | 37 | return { 38 | file: { 39 | buffer: fileField.data, 40 | originalFilename: fileField.filename || 'unknown', 41 | mimetype: fileField.type, 42 | size: fileField.data.length 43 | } 44 | } 45 | } 46 | 47 | /** 48 | * 获取文件扩展名(内部使用) 49 | */ 50 | function getFileExtension(filename) { 51 | const ext = filename.split('.').pop().toLowerCase() 52 | return ext 53 | } 54 | 55 | /** 56 | * 验证文件格式 57 | */ 58 | export function validateFormat(filename, allowedFormats) { 59 | const ext = getFileExtension(filename) 60 | return allowedFormats.map(f => f.toLowerCase()).includes(ext) 61 | } 62 | 63 | /** 64 | * 验证文件大小 65 | */ 66 | export function validateSize(size, maxSize) { 67 | return size <= maxSize 68 | } 69 | 70 | /** 71 | * 保存图片文件 72 | * @param {Buffer} buffer - 图片数据 73 | * @param {Object} options - 配置选项 74 | * @param {string} options.originalName - 原始文件名 75 | * @param {boolean} options.convertToWebp - 是否转换为 WebP 76 | * @param {number} options.webpQuality - WebP 质量 77 | * @param {string} options.uploadedBy - 上传者 78 | * @param {string} options.ip - 上传者 IP 79 | * @param {boolean} options.isPublic - 是否为公共上传 80 | */ 81 | export async function saveUploadedImage(buffer, options) { 82 | const { 83 | originalName, 84 | convertToWebp = false, 85 | webpQuality = 80, 86 | uploadedBy = '访客', 87 | ip = '', 88 | isPublic = true 89 | } = options 90 | 91 | const uuid = uuidv4() 92 | const originalExt = getFileExtension(originalName) 93 | 94 | let finalBuffer = buffer 95 | let finalExt = originalExt 96 | let isWebp = false 97 | 98 | // 如果需要转换为 WebP 99 | if (convertToWebp && originalExt !== 'gif') { 100 | finalBuffer = await processImage(buffer, { 101 | format: 'webp', 102 | quality: webpQuality 103 | }) 104 | finalExt = 'webp' 105 | isWebp = true 106 | } 107 | 108 | // 获取图片信息 109 | const imageInfo = await getImageMetadata(finalBuffer) 110 | 111 | // 生成文件名和路径 112 | const filename = `${uuid}.${finalExt}` 113 | const filepath = join(uploadsDir, filename) 114 | 115 | // 保存文件 116 | await writeFile(filepath, finalBuffer) 117 | 118 | // 保存到数据库 119 | const imageRecord = { 120 | _id: uuidv4(), 121 | uuid, 122 | originalName, 123 | filename, 124 | size: finalBuffer.length, 125 | format: finalExt, 126 | width: imageInfo.width, 127 | height: imageInfo.height, 128 | isWebp, 129 | isPublic, 130 | uploadedBy, 131 | ip, 132 | isDeleted: false, 133 | uploadedAt: new Date().toISOString(), 134 | updatedAt: new Date().toISOString() 135 | } 136 | 137 | await db.images.insert(imageRecord) 138 | 139 | // 返回图片信息(不包含敏感信息) 140 | return { 141 | uuid, 142 | filename, 143 | format: finalExt, 144 | size: finalBuffer.length, 145 | width: imageInfo.width, 146 | height: imageInfo.height, 147 | url: `/i/${uuid}.${finalExt}` 148 | } 149 | } 150 | 151 | /** 152 | * 删除图片文件 153 | */ 154 | export async function deleteImageFile(filename) { 155 | const filepath = join(uploadsDir, filename) 156 | if (existsSync(filepath)) { 157 | unlinkSync(filepath) 158 | return true 159 | } 160 | return false 161 | } 162 | 163 | /** 164 | * 获取图片文件路径 165 | */ 166 | export function getImagePath(filename) { 167 | return join(uploadsDir, filename) 168 | } 169 | 170 | /** 171 | * 获取上传目录路径 172 | */ 173 | export function getUploadsDirPath() { 174 | return uploadsDir 175 | } 176 | -------------------------------------------------------------------------------- /app/pages/login.vue: -------------------------------------------------------------------------------- 1 | 93 | 94 | -------------------------------------------------------------------------------- /server/api/upload/private.post.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { processImage, getImageMetadata, saveUploadedFile } from '../../utils/image.js' 3 | import { parseFormData } from '../../utils/upload.js' 4 | import { v4 as uuidv4 } from 'uuid' 5 | import { sendUploadNotification } from '../../utils/notification.js' 6 | 7 | export default defineEventHandler(async (event) => { 8 | const clientIP = getRequestIP(event, { xForwardedFor: true }) || 'unknown' 9 | 10 | try { 11 | // 获取 ApiKey(从 header 或 query) 12 | const apiKey = getHeader(event, 'x-api-key') || getQuery(event).apiKey 13 | 14 | if (!apiKey) { 15 | throw createError({ 16 | statusCode: 401, 17 | message: '缺少 API Key' 18 | }) 19 | } 20 | 21 | // 验证 ApiKey 22 | const keyDoc = await db.apikeys.findOne({ key: apiKey, enabled: true }) 23 | if (!keyDoc) { 24 | throw createError({ 25 | statusCode: 401, 26 | message: 'API Key 无效或已禁用' 27 | }) 28 | } 29 | 30 | // 获取私有 API 配置 31 | const configDoc = await db.settings.findOne({ key: 'privateApiConfig' }) 32 | const config = configDoc?.value || {} 33 | 34 | // 解析表单数据 35 | const { file } = await parseFormData(event) 36 | 37 | if (!file) { 38 | throw createError({ 39 | statusCode: 400, 40 | message: '请选择要上传的图片' 41 | }) 42 | } 43 | 44 | // 私有 API 支持所有格式,只检查是否为图片 45 | const fileExt = file.originalFilename?.split('.').pop()?.toLowerCase() || '' 46 | 47 | // 检查文件大小 48 | const maxFileSize = config.maxFileSize || 100 * 1024 * 1024 49 | if (file.size > maxFileSize) { 50 | throw createError({ 51 | statusCode: 400, 52 | message: `文件大小超过限制 (最大 ${Math.round(maxFileSize / 1024 / 1024)}MB)` 53 | }) 54 | } 55 | 56 | // 生成 UUID 57 | const imageUuid = uuidv4() 58 | 59 | // 处理图片(可选转换为 WebP) 60 | let processedBuffer = file.buffer 61 | let finalFormat = fileExt 62 | let isWebp = false 63 | 64 | if (config.convertToWebp && fileExt !== 'gif') { 65 | processedBuffer = await processImage(file.buffer, { 66 | format: 'webp', 67 | quality: config.webpQuality || 80 68 | }) 69 | finalFormat = 'webp' 70 | isWebp = true 71 | } 72 | 73 | // 获取图片元数据 74 | const metadata = await getImageMetadata(processedBuffer) 75 | 76 | // 保存文件 77 | const filename = `${imageUuid}.${finalFormat}` 78 | await saveUploadedFile(processedBuffer, filename) 79 | 80 | // 获取用户信息(通过 ApiKey 关联) 81 | const uploadedBy = keyDoc.name || 'API用户' 82 | 83 | // 保存到数据库 84 | const imageDoc = { 85 | _id: uuidv4(), 86 | uuid: imageUuid, 87 | originalName: file.originalFilename, 88 | filename: filename, 89 | format: finalFormat, 90 | size: processedBuffer.length, 91 | width: metadata.width || 0, 92 | height: metadata.height || 0, 93 | isWebp: isWebp, 94 | isDeleted: false, 95 | uploadedBy: uploadedBy, 96 | uploadedByType: 'private', 97 | apiKeyId: keyDoc._id, 98 | ip: clientIP, 99 | uploadedAt: new Date().toISOString(), 100 | updatedAt: new Date().toISOString() 101 | } 102 | 103 | await db.images.insert(imageDoc) 104 | 105 | // 获取站点 URL 配置,用于生成完整图片链接 106 | const appSettingsDoc = await db.settings.findOne({ key: 'appSettings' }) 107 | let siteUrl = appSettingsDoc?.value?.siteUrl || '' 108 | 109 | // 如果没有配置站点 URL,使用请求的 Host 作为兜底 110 | if (!siteUrl) { 111 | const protocol = getHeader(event, 'x-forwarded-proto') || 'http' 112 | const host = getHeader(event, 'host') || 'localhost' 113 | siteUrl = `${protocol}://${host}` 114 | } 115 | 116 | // 移除末尾斜杠,确保 URL 拼接正确 117 | siteUrl = siteUrl.replace(/\/+$/, '') 118 | const fullImageUrl = `${siteUrl}/i/${imageUuid}.${finalFormat}` 119 | 120 | // 发送上传通知(异步,不阻塞响应) 121 | sendUploadNotification( 122 | { 123 | id: imageDoc._id, 124 | filename: filename, 125 | format: finalFormat, 126 | size: processedBuffer.length, 127 | url: fullImageUrl 128 | }, 129 | { 130 | name: uploadedBy, 131 | type: 'private', 132 | ip: clientIP 133 | } 134 | ).catch(err => { 135 | console.error('[Upload] 发送上传通知失败:', err) 136 | }) 137 | 138 | // 返回结果 139 | return { 140 | success: true, 141 | message: '上传成功', 142 | data: { 143 | id: imageDoc._id, 144 | uuid: imageUuid, 145 | filename: filename, 146 | format: finalFormat, 147 | size: processedBuffer.length, 148 | width: metadata.width || 0, 149 | height: metadata.height || 0, 150 | url: `/i/${imageUuid}.${finalFormat}`, 151 | uploadedAt: imageDoc.uploadedAt 152 | } 153 | } 154 | } catch (error) { 155 | if (error.statusCode) { 156 | throw error 157 | } 158 | 159 | console.error('[Upload] 私有上传失败:', error) 160 | throw createError({ 161 | statusCode: 500, 162 | message: '上传失败,请稍后重试' 163 | }) 164 | } 165 | }) 166 | -------------------------------------------------------------------------------- /app/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* 基础样式 */ 6 | body { 7 | @apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 9 | } 10 | 11 | /* 隐藏 html 和 body 的滚动条,但保持滚动功能 */ 12 | html, body { 13 | scrollbar-width: none; /* Firefox */ 14 | -ms-overflow-style: none; /* IE and Edge */ 15 | } 16 | 17 | html::-webkit-scrollbar, 18 | body::-webkit-scrollbar { 19 | display: none; /* Chrome, Safari, Opera */ 20 | } 21 | 22 | /* 其他元素的滚动条样式 */ 23 | *:not(html):not(body)::-webkit-scrollbar { 24 | width: 8px; 25 | height: 8px; 26 | } 27 | 28 | *:not(html):not(body)::-webkit-scrollbar-track { 29 | @apply bg-gray-100 dark:bg-gray-800; 30 | } 31 | 32 | *:not(html):not(body)::-webkit-scrollbar-thumb { 33 | @apply bg-gray-300 dark:bg-gray-600 rounded-full; 34 | } 35 | 36 | *:not(html):not(body)::-webkit-scrollbar-thumb:hover { 37 | @apply bg-gray-400 dark:bg-gray-500; 38 | } 39 | 40 | /* 图片加载占位 */ 41 | .image-placeholder { 42 | @apply bg-gray-200 dark:bg-gray-700 animate-pulse; 43 | } 44 | 45 | /* 按钮基础样式 */ 46 | .btn { 47 | @apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed; 48 | } 49 | 50 | .btn-primary { 51 | @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500; 52 | } 53 | 54 | .btn-secondary { 55 | @apply btn bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 focus:ring-gray-500; 56 | } 57 | 58 | .btn-danger { 59 | @apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500; 60 | } 61 | 62 | .btn-ghost { 63 | @apply btn bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800; 64 | } 65 | 66 | /* 输入框样式 */ 67 | .input { 68 | @apply w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200; 69 | } 70 | 71 | /* 下拉选择框样式 */ 72 | select.input { 73 | @apply pr-10 appearance-none cursor-pointer; 74 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 75 | background-position: right 0.75rem center; 76 | background-repeat: no-repeat; 77 | background-size: 1.25em 1.25em; 78 | } 79 | 80 | .dark select.input { 81 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 82 | } 83 | 84 | /* 卡片样式 */ 85 | .card { 86 | @apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700; 87 | } 88 | 89 | /* 页面过渡动画 */ 90 | .page-enter-active, 91 | .page-leave-active { 92 | transition: all 0.2s; 93 | } 94 | 95 | .page-enter-from, 96 | .page-leave-to { 97 | opacity: 0; 98 | transform: translateY(10px); 99 | } 100 | 101 | /* Toast 动画 */ 102 | .toast-enter-active { 103 | animation: toast-in 0.3s ease-out; 104 | } 105 | 106 | .toast-leave-active { 107 | animation: toast-out 0.3s ease-in forwards; 108 | } 109 | 110 | .toast-move { 111 | transition: transform 0.3s ease; 112 | } 113 | 114 | @keyframes toast-in { 115 | from { 116 | opacity: 0; 117 | transform: translateY(-20px); 118 | } 119 | to { 120 | opacity: 1; 121 | transform: translateY(0); 122 | } 123 | } 124 | 125 | @keyframes toast-out { 126 | from { 127 | opacity: 1; 128 | transform: translateY(0); 129 | } 130 | to { 131 | opacity: 0; 132 | transform: translateY(-20px); 133 | } 134 | } 135 | 136 | /* 图片查看器过渡 */ 137 | .viewer-enter-active, 138 | .viewer-leave-active { 139 | transition: all 0.3s ease; 140 | } 141 | 142 | .viewer-enter-from, 143 | .viewer-leave-to { 144 | opacity: 0; 145 | } 146 | 147 | .viewer-enter-from .viewer-image, 148 | .viewer-leave-to .viewer-image { 149 | transform: scale(0.9); 150 | } 151 | 152 | /* 瀑布流布局 */ 153 | .masonry-grid { 154 | display: flex; 155 | margin-left: -16px; 156 | width: auto; 157 | } 158 | 159 | .masonry-grid-column { 160 | padding-left: 16px; 161 | background-clip: padding-box; 162 | } 163 | 164 | /* 移动端适配 */ 165 | @media (max-width: 640px) { 166 | .masonry-grid { 167 | margin-left: -8px; 168 | } 169 | 170 | .masonry-grid-column { 171 | padding-left: 8px; 172 | } 173 | } 174 | 175 | /* 暗黑模式下的 focus ring offset */ 176 | .dark .btn:focus { 177 | --tw-ring-offset-color: #111827; 178 | } 179 | 180 | .dark .input:focus { 181 | --tw-ring-offset-color: #111827; 182 | } 183 | 184 | /* 选择框样式 */ 185 | input[type="range"] { 186 | @apply accent-primary-600; 187 | } 188 | 189 | /* 隐藏数字输入框的上下按钮 */ 190 | input[type="number"]::-webkit-outer-spin-button, 191 | input[type="number"]::-webkit-inner-spin-button { 192 | -webkit-appearance: none; 193 | margin: 0; 194 | } 195 | 196 | input[type="number"] { 197 | -moz-appearance: textfield; 198 | } 199 | 200 | /* 链接悬停效果 */ 201 | a { 202 | @apply transition-colors; 203 | } 204 | 205 | /* 禁用状态 */ 206 | .disabled { 207 | @apply opacity-50 cursor-not-allowed pointer-events-none; 208 | } 209 | 210 | /* 主题切换过渡效果 */ 211 | ::view-transition-old(root), 212 | ::view-transition-new(root) { 213 | animation: none; 214 | mix-blend-mode: normal; 215 | } 216 | -------------------------------------------------------------------------------- /app/components/Announcement.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 143 | 144 | -------------------------------------------------------------------------------- /server/plugins/database.js: -------------------------------------------------------------------------------- 1 | import db from '../utils/db.js' 2 | import bcrypt from 'bcryptjs' 3 | import crypto from 'crypto' 4 | import { v4 as uuidv4 } from 'uuid' 5 | import { getDefaultContentSafetyConfig } from '../utils/moderation.js' 6 | 7 | // 初始化默认管理员用户 8 | async function initDefaultUser() { 9 | const existingUser = await db.users.findOne({ username: 'easyimg' }) 10 | if (!existingUser) { 11 | const hashedPassword = await bcrypt.hash('easyimg', 10) 12 | await db.users.insert({ 13 | _id: uuidv4(), 14 | username: 'easyimg', 15 | password: hashedPassword, 16 | createdAt: new Date().toISOString(), 17 | updatedAt: new Date().toISOString() 18 | }) 19 | console.log('[Database] 默认管理员用户已创建 (用户名: easyimg, 密码: easyimg)') 20 | } 21 | } 22 | 23 | // 初始化 JWT 密钥 24 | async function initJwtSecret() { 25 | let jwtSetting = await db.settings.findOne({ key: 'jwtSecret' }) 26 | if (!jwtSetting) { 27 | const secret = crypto.randomBytes(64).toString('hex') 28 | await db.settings.insert({ 29 | _id: uuidv4(), 30 | key: 'jwtSecret', 31 | value: secret, 32 | createdAt: new Date().toISOString() 33 | }) 34 | console.log('[Database] JWT 密钥已生成') 35 | } 36 | } 37 | 38 | // 初始化默认 ApiKey 39 | async function initDefaultApiKey() { 40 | const existingKey = await db.apikeys.findOne({ isDefault: true }) 41 | if (!existingKey) { 42 | const apiKey = `sk-${uuidv4().replace(/-/g, '')}` 43 | await db.apikeys.insert({ 44 | _id: uuidv4(), 45 | key: apiKey, 46 | name: '默认密钥', 47 | isDefault: true, 48 | enabled: true, 49 | createdAt: new Date().toISOString(), 50 | updatedAt: new Date().toISOString() 51 | }) 52 | console.log('[Database] 默认 ApiKey 已创建') 53 | } 54 | } 55 | 56 | // 初始化公共 API 配置 57 | async function initPublicApiConfig() { 58 | const existingConfig = await db.settings.findOne({ key: 'publicApiConfig' }) 59 | if (!existingConfig) { 60 | await db.settings.insert({ 61 | _id: uuidv4(), 62 | key: 'publicApiConfig', 63 | value: { 64 | enabled: false, // 是否启用公共 API(默认关闭) 65 | allowedFormats: ['jpg', 'jpeg', 'png', 'gif', 'webp'], // 允许的图片格式 66 | maxFileSize: 10 * 1024 * 1024, // 最大文件大小 10MB 67 | compressToWebp: true, // 是否压缩转换为 WebP 68 | webpQuality: 80, // WebP 压缩质量 (100 - 20% = 80) 69 | rateLimit: 10, // 每分钟请求限制 70 | allowConcurrent: false, // 是否允许并发上传 71 | // 内容安全配置(从 moderation.js 获取默认配置) 72 | contentSafety: getDefaultContentSafetyConfig() 73 | }, 74 | createdAt: new Date().toISOString(), 75 | updatedAt: new Date().toISOString() 76 | }) 77 | console.log('[Database] 公共 API 配置已初始化') 78 | } 79 | } 80 | 81 | // 初始化私有 API 配置 82 | async function initPrivateApiConfig() { 83 | const existingConfig = await db.settings.findOne({ key: 'privateApiConfig' }) 84 | if (!existingConfig) { 85 | await db.settings.insert({ 86 | _id: uuidv4(), 87 | key: 'privateApiConfig', 88 | value: { 89 | maxFileSize: 100 * 1024 * 1024, // 最大文件大小 100MB 90 | convertToWebp: false, // 是否转换为 WebP 91 | rateLimit: 100, // 每分钟请求限制 92 | maxConcurrent: 5 // 最大并发数 93 | }, 94 | createdAt: new Date().toISOString(), 95 | updatedAt: new Date().toISOString() 96 | }) 97 | console.log('[Database] 私有 API 配置已初始化') 98 | } 99 | } 100 | 101 | // 初始化应用设置 102 | async function initAppSettings() { 103 | const existingSettings = await db.settings.findOne({ key: 'appSettings' }) 104 | if (!existingSettings) { 105 | await db.settings.insert({ 106 | _id: uuidv4(), 107 | key: 'appSettings', 108 | value: { 109 | appName: 'EasyImg', // 应用名称 110 | appLogo: '' // 应用 Logo URL 111 | }, 112 | createdAt: new Date().toISOString(), 113 | updatedAt: new Date().toISOString() 114 | }) 115 | console.log('[Database] 应用设置已初始化') 116 | } 117 | } 118 | 119 | // 创建数据库索引 120 | async function createIndexes() { 121 | // 用户表索引 122 | await db.users.ensureIndex({ fieldName: 'username', unique: true }) 123 | 124 | // 图片表索引 125 | await db.images.ensureIndex({ fieldName: 'uuid', unique: true }) 126 | await db.images.ensureIndex({ fieldName: 'uploadedAt' }) 127 | await db.images.ensureIndex({ fieldName: 'isDeleted' }) 128 | await db.images.ensureIndex({ fieldName: 'uploadedBy' }) 129 | await db.images.ensureIndex({ fieldName: 'moderationStatus' }) // 审核状态索引 130 | await db.images.ensureIndex({ fieldName: 'isNsfw' }) // 违规标记索引 131 | 132 | // ApiKey 表索引 133 | await db.apikeys.ensureIndex({ fieldName: 'key', unique: true }) 134 | 135 | // 设置表索引 136 | await db.settings.ensureIndex({ fieldName: 'key', unique: true }) 137 | 138 | // 审核任务表索引 139 | await db.moderationTasks.ensureIndex({ fieldName: 'imageId' }) 140 | await db.moderationTasks.ensureIndex({ fieldName: 'status' }) 141 | await db.moderationTasks.ensureIndex({ fieldName: 'createdAt' }) 142 | 143 | // IP 黑名单表索引 144 | await db.ipBlacklist.ensureIndex({ fieldName: 'ip', unique: true }) 145 | await db.ipBlacklist.ensureIndex({ fieldName: 'createdAt' }) 146 | 147 | console.log('[Database] 数据库索引已创建') 148 | } 149 | 150 | // 数据库初始化 151 | export async function initDatabase() { 152 | try { 153 | await createIndexes() 154 | await initDefaultUser() 155 | await initJwtSecret() 156 | await initDefaultApiKey() 157 | await initPublicApiConfig() 158 | await initPrivateApiConfig() 159 | await initAppSettings() 160 | console.log('[Database] 数据库初始化完成') 161 | } catch (error) { 162 | console.error('[Database] 数据库初始化失败:', error) 163 | throw error 164 | } 165 | } 166 | 167 | export default defineNitroPlugin(async () => { 168 | await initDatabase() 169 | }) 170 | -------------------------------------------------------------------------------- /app/stores/images.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useAuthStore } from './auth' 3 | 4 | export const useImagesStore = defineStore('images', { 5 | state: () => ({ 6 | images: [], 7 | loading: false, 8 | uploading: false, 9 | pagination: { 10 | page: 1, 11 | limit: 20, 12 | total: 0, 13 | totalPages: 0 14 | }, 15 | hasMore: true, 16 | selectedIds: [] 17 | }), 18 | 19 | actions: { 20 | // 获取图片列表 21 | async fetchImages(reset = false) { 22 | if (this.loading) return 23 | 24 | if (reset) { 25 | this.pagination.page = 1 26 | this.images = [] 27 | this.hasMore = true 28 | } 29 | 30 | if (!this.hasMore) return 31 | 32 | this.loading = true 33 | 34 | try { 35 | const authStore = useAuthStore() 36 | const response = await $fetch('/api/images', { 37 | params: { 38 | page: this.pagination.page, 39 | limit: this.pagination.limit 40 | }, 41 | headers: authStore.authHeader 42 | }) 43 | 44 | if (response.success) { 45 | if (reset) { 46 | this.images = response.data.images 47 | } else { 48 | this.images.push(...response.data.images) 49 | } 50 | 51 | this.pagination = response.data.pagination 52 | this.hasMore = this.pagination.page < this.pagination.totalPages 53 | } 54 | } catch (error) { 55 | console.error('获取图片列表失败:', error) 56 | } finally { 57 | this.loading = false 58 | } 59 | }, 60 | 61 | // 加载更多 62 | async loadMore() { 63 | if (!this.hasMore || this.loading) return 64 | 65 | this.pagination.page++ 66 | await this.fetchImages() 67 | }, 68 | 69 | // 上传图片 70 | async uploadImage(file) { 71 | const authStore = useAuthStore() 72 | 73 | this.uploading = true 74 | 75 | try { 76 | const formData = new FormData() 77 | formData.append('file', file) 78 | 79 | // 根据登录状态选择 API 80 | const endpoint = authStore.isAuthenticated ? '/api/upload/private' : '/api/upload/public' 81 | 82 | const headers = {} 83 | if (authStore.isAuthenticated) { 84 | // 获取默认 ApiKey 85 | const keysResponse = await $fetch('/api/apikeys', { 86 | headers: authStore.authHeader 87 | }) 88 | 89 | if (keysResponse.success && keysResponse.data.length > 0) { 90 | const defaultKey = keysResponse.data.find(k => k.isDefault) || keysResponse.data[0] 91 | headers['X-API-Key'] = defaultKey.key 92 | } 93 | } 94 | 95 | const response = await $fetch(endpoint, { 96 | method: 'POST', 97 | body: formData, 98 | headers 99 | }) 100 | 101 | if (response.success) { 102 | // 将新图片添加到列表顶部 103 | this.images.unshift({ 104 | ...response.data, 105 | id: response.data.id, 106 | uploadedBy: authStore.isAuthenticated ? authStore.user?.username : '访客' 107 | }) 108 | this.pagination.total++ 109 | 110 | return { success: true, data: response.data } 111 | } 112 | 113 | return { success: false, message: response.message || '上传失败' } 114 | } catch (error) { 115 | const message = error.data?.message || '上传失败,请稍后重试' 116 | return { success: false, message } 117 | } finally { 118 | this.uploading = false 119 | } 120 | }, 121 | 122 | // 删除图片 123 | async deleteImage(id) { 124 | const authStore = useAuthStore() 125 | 126 | try { 127 | const response = await $fetch(`/api/images/${id}`, { 128 | method: 'DELETE', 129 | headers: authStore.authHeader 130 | }) 131 | 132 | if (response.success) { 133 | // 从列表中移除 134 | this.images = this.images.filter(img => img.id !== id) 135 | this.pagination.total-- 136 | return { success: true } 137 | } 138 | 139 | return { success: false, message: response.message || '删除失败' } 140 | } catch (error) { 141 | return { success: false, message: error.data?.message || '删除失败,请稍后重试' } 142 | } 143 | }, 144 | 145 | // 批量删除 146 | async batchDelete() { 147 | if (this.selectedIds.length === 0) return { success: false, message: '请选择要删除的图片' } 148 | 149 | const authStore = useAuthStore() 150 | 151 | try { 152 | const response = await $fetch('/api/images/batch', { 153 | method: 'DELETE', 154 | body: { ids: this.selectedIds }, 155 | headers: authStore.authHeader 156 | }) 157 | 158 | if (response.success) { 159 | // 从列表中移除 160 | this.images = this.images.filter(img => !this.selectedIds.includes(img.id)) 161 | this.pagination.total -= response.data.deletedCount 162 | this.selectedIds = [] 163 | return { success: true, message: response.message } 164 | } 165 | 166 | return { success: false, message: response.message || '删除失败' } 167 | } catch (error) { 168 | return { success: false, message: error.data?.message || '批量删除失败' } 169 | } 170 | }, 171 | 172 | // 切换选中状态 173 | toggleSelect(id) { 174 | const index = this.selectedIds.indexOf(id) 175 | if (index > -1) { 176 | this.selectedIds.splice(index, 1) 177 | } else { 178 | this.selectedIds.push(id) 179 | } 180 | }, 181 | 182 | // 清空选中 183 | clearSelection() { 184 | this.selectedIds = [] 185 | }, 186 | 187 | // 全选/取消全选 188 | toggleSelectAll() { 189 | if (this.selectedIds.length === this.images.length) { 190 | this.selectedIds = [] 191 | } else { 192 | this.selectedIds = this.images.map(img => img.id) 193 | } 194 | } 195 | }, 196 | 197 | getters: { 198 | isAllSelected: (state) => { 199 | return state.images.length > 0 && state.selectedIds.length === state.images.length 200 | }, 201 | 202 | hasSelection: (state) => { 203 | return state.selectedIds.length > 0 204 | } 205 | } 206 | }) 207 | -------------------------------------------------------------------------------- /server/utils/rateLimit.js: -------------------------------------------------------------------------------- 1 | import db from '../utils/db.js' 2 | 3 | // 存储请求计数的 Map 4 | const requestCounts = new Map() 5 | // 存储正在上传的 IP/ApiKey 6 | const uploadingClients = new Map() 7 | 8 | // 清理过期的计数记录(每分钟清理一次) 9 | setInterval(() => { 10 | const now = Date.now() 11 | for (const [key, data] of requestCounts.entries()) { 12 | if (now - data.startTime > 60000) { 13 | requestCounts.delete(key) 14 | } 15 | } 16 | }, 60000) 17 | 18 | /** 19 | * 频率限制中间件 20 | * @param {string} type - 'public' 或 'private' 21 | * @param {string} identifier - IP 地址或 ApiKey 22 | */ 23 | export async function checkRateLimit(type, identifier) { 24 | const configKey = type === 'public' ? 'publicApiConfig' : 'privateApiConfig' 25 | const config = await db.settings.findOne({ key: configKey }) 26 | 27 | if (!config) { 28 | return { allowed: true } 29 | } 30 | 31 | const rateLimit = config.value.rateLimit 32 | const key = `${type}:${identifier}` 33 | const now = Date.now() 34 | 35 | let record = requestCounts.get(key) 36 | 37 | if (!record || now - record.startTime > 60000) { 38 | // 新的一分钟周期 39 | record = { count: 1, startTime: now } 40 | requestCounts.set(key, record) 41 | return { allowed: true, remaining: rateLimit - 1 } 42 | } 43 | 44 | if (record.count >= rateLimit) { 45 | return { 46 | allowed: false, 47 | remaining: 0, 48 | retryAfter: Math.ceil((record.startTime + 60000 - now) / 1000) 49 | } 50 | } 51 | 52 | record.count++ 53 | return { allowed: true, remaining: rateLimit - record.count } 54 | } 55 | 56 | /** 57 | * 并发限制中间件 58 | * @param {string} type - 'public' 或 'private' 59 | * @param {string} identifier - IP 地址或 ApiKey 60 | */ 61 | export async function checkConcurrency(type, identifier) { 62 | const configKey = type === 'public' ? 'publicApiConfig' : 'privateApiConfig' 63 | const config = await db.settings.findOne({ key: configKey }) 64 | 65 | if (!config) { 66 | return { allowed: true } 67 | } 68 | 69 | const key = `${type}:${identifier}` 70 | 71 | if (type === 'public') { 72 | // 公共 API 不允许并发 73 | if (!config.value.allowConcurrent) { 74 | if (uploadingClients.has(key)) { 75 | return { allowed: false, message: '请等待上一张图片上传完成' } 76 | } 77 | } 78 | } else { 79 | // 私有 API 限制并发数 80 | const maxConcurrent = config.value.maxConcurrent || 5 81 | const currentCount = uploadingClients.get(key) || 0 82 | 83 | if (currentCount >= maxConcurrent) { 84 | return { allowed: false, message: `并发上传数已达上限 (${maxConcurrent})` } 85 | } 86 | } 87 | 88 | return { allowed: true } 89 | } 90 | 91 | /** 92 | * 标记开始上传 93 | */ 94 | export function markUploadStart(type, identifier) { 95 | const key = `${type}:${identifier}` 96 | const current = uploadingClients.get(key) || 0 97 | uploadingClients.set(key, current + 1) 98 | } 99 | 100 | /** 101 | * 标记上传结束 102 | */ 103 | export function markUploadEnd(type, identifier) { 104 | const key = `${type}:${identifier}` 105 | const current = uploadingClients.get(key) || 0 106 | if (current <= 1) { 107 | uploadingClients.delete(key) 108 | } else { 109 | uploadingClients.set(key, current - 1) 110 | } 111 | } 112 | 113 | /** 114 | * 公共 API 频率限制检查 115 | */ 116 | export function checkPublicRateLimit(ip, rateLimit = 10) { 117 | const key = `public:${ip}` 118 | const now = Date.now() 119 | 120 | let record = requestCounts.get(key) 121 | 122 | if (!record || now - record.startTime > 60000) { 123 | record = { count: 1, startTime: now } 124 | requestCounts.set(key, record) 125 | return { allowed: true, remaining: rateLimit - 1 } 126 | } 127 | 128 | if (record.count >= rateLimit) { 129 | return { 130 | allowed: false, 131 | remaining: 0, 132 | retryAfter: Math.ceil((record.startTime + 60000 - now) / 1000) 133 | } 134 | } 135 | 136 | record.count++ 137 | return { allowed: true, remaining: rateLimit - record.count } 138 | } 139 | 140 | /** 141 | * 私有 API 频率限制检查 142 | */ 143 | export function checkPrivateRateLimit(apiKey, rateLimit = 100) { 144 | const key = `private:${apiKey}` 145 | const now = Date.now() 146 | 147 | let record = requestCounts.get(key) 148 | 149 | if (!record || now - record.startTime > 60000) { 150 | record = { count: 1, startTime: now } 151 | requestCounts.set(key, record) 152 | return { allowed: true, remaining: rateLimit - 1 } 153 | } 154 | 155 | if (record.count >= rateLimit) { 156 | return { 157 | allowed: false, 158 | remaining: 0, 159 | retryAfter: Math.ceil((record.startTime + 60000 - now) / 1000) 160 | } 161 | } 162 | 163 | record.count++ 164 | return { allowed: true, remaining: rateLimit - record.count } 165 | } 166 | 167 | /** 168 | * 公共 API 并发检查 169 | */ 170 | export function checkPublicConcurrency(ip) { 171 | const key = `public:${ip}` 172 | if (uploadingClients.has(key) && uploadingClients.get(key) > 0) { 173 | return { allowed: false } 174 | } 175 | return { allowed: true } 176 | } 177 | 178 | /** 179 | * 释放公共 API 并发锁 180 | */ 181 | export function releasePublicConcurrency(ip) { 182 | const key = `public:${ip}` 183 | uploadingClients.delete(key) 184 | } 185 | 186 | /** 187 | * 获取并标记公共并发 188 | */ 189 | export function acquirePublicConcurrency(ip) { 190 | const key = `public:${ip}` 191 | uploadingClients.set(key, 1) 192 | } 193 | 194 | /** 195 | * 私有 API 并发检查 196 | */ 197 | export function checkPrivateConcurrency(ip, maxConcurrent = 5) { 198 | const key = `private:${ip}` 199 | const current = uploadingClients.get(key) || 0 200 | if (current >= maxConcurrent) { 201 | return { allowed: false, current } 202 | } 203 | return { allowed: true, current } 204 | } 205 | 206 | /** 207 | * 获取私有 API 并发锁 208 | */ 209 | export function acquirePrivateConcurrency(ip) { 210 | const key = `private:${ip}` 211 | const current = uploadingClients.get(key) || 0 212 | uploadingClients.set(key, current + 1) 213 | } 214 | 215 | /** 216 | * 释放私有 API 并发锁 217 | */ 218 | export function releasePrivateConcurrency(ip) { 219 | const key = `private:${ip}` 220 | const current = uploadingClients.get(key) || 0 221 | if (current <= 1) { 222 | uploadingClients.delete(key) 223 | } else { 224 | uploadingClients.set(key, current - 1) 225 | } 226 | } 227 | 228 | export default { 229 | checkRateLimit, 230 | checkConcurrency, 231 | markUploadStart, 232 | markUploadEnd, 233 | checkPublicRateLimit, 234 | checkPrivateRateLimit, 235 | checkPublicConcurrency, 236 | releasePublicConcurrency, 237 | acquirePublicConcurrency, 238 | checkPrivateConcurrency, 239 | acquirePrivateConcurrency, 240 | releasePrivateConcurrency 241 | } 242 | -------------------------------------------------------------------------------- /server/api/upload/url.post.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { processImage, getImageMetadata, saveUploadedFile } from '../../utils/image.js' 3 | import { authMiddleware } from '../../utils/authMiddleware.js' 4 | import { v4 as uuidv4 } from 'uuid' 5 | 6 | export default defineEventHandler(async (event) => { 7 | const clientIP = getRequestIP(event, { xForwardedFor: true }) || 'unknown' 8 | 9 | try { 10 | // 验证登录状态(仅端内调用,不使用API Key) 11 | await authMiddleware(event) 12 | const user = event.context.user 13 | 14 | // 解析请求体 15 | const body = await readBody(event) 16 | const { url } = body 17 | 18 | if (!url) { 19 | throw createError({ 20 | statusCode: 400, 21 | message: '请提供图片URL' 22 | }) 23 | } 24 | 25 | // 验证URL格式 26 | let imageUrl 27 | try { 28 | imageUrl = new URL(url) 29 | if (!['http:', 'https:'].includes(imageUrl.protocol)) { 30 | throw new Error('协议不支持') 31 | } 32 | } catch (e) { 33 | throw createError({ 34 | statusCode: 400, 35 | message: '无效的URL格式' 36 | }) 37 | } 38 | 39 | // 获取私有 API 配置 40 | const configDoc = await db.settings.findOne({ key: 'privateApiConfig' }) 41 | const config = configDoc?.value || {} 42 | 43 | // 下载图片 44 | let response 45 | try { 46 | const controller = new AbortController() 47 | const timeoutId = setTimeout(() => controller.abort(), 30000) // 30秒超时 48 | 49 | // 构建请求头,绕过防盗链检测 50 | const fetchHeaders = { 51 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 52 | 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 53 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 54 | 'Accept-Encoding': 'gzip, deflate, br', 55 | 'Cache-Control': 'no-cache', 56 | 'Pragma': 'no-cache', 57 | 'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', 58 | 'Sec-Ch-Ua-Mobile': '?0', 59 | 'Sec-Ch-Ua-Platform': '"Windows"', 60 | 'Sec-Fetch-Dest': 'image', 61 | 'Sec-Fetch-Mode': 'no-cors', 62 | 'Sec-Fetch-Site': 'cross-site', 63 | // 使用图片URL的origin作为Referer,绕过防盗链 64 | 'Referer': imageUrl.origin + '/', 65 | 'Origin': imageUrl.origin 66 | } 67 | 68 | response = await fetch(url, { 69 | signal: controller.signal, 70 | headers: fetchHeaders, 71 | redirect: 'follow' // 自动跟随重定向 72 | }) 73 | 74 | clearTimeout(timeoutId) 75 | 76 | if (!response.ok) { 77 | throw new Error(`HTTP ${response.status}`) 78 | } 79 | } catch (e) { 80 | if (e.name === 'AbortError') { 81 | throw createError({ 82 | statusCode: 408, 83 | message: '下载超时,请稍后重试' 84 | }) 85 | } 86 | throw createError({ 87 | statusCode: 400, 88 | message: `无法下载图片: ${e.message}` 89 | }) 90 | } 91 | 92 | // 检查 Content-Type 93 | const contentType = response.headers.get('content-type') || '' 94 | if (!contentType.startsWith('image/')) { 95 | throw createError({ 96 | statusCode: 400, 97 | message: 'URL指向的不是有效的图片' 98 | }) 99 | } 100 | 101 | // 获取图片数据 102 | const arrayBuffer = await response.arrayBuffer() 103 | const buffer = Buffer.from(arrayBuffer) 104 | 105 | // 检查文件大小 106 | const maxFileSize = config.maxFileSize || 100 * 1024 * 1024 107 | if (buffer.length > maxFileSize) { 108 | throw createError({ 109 | statusCode: 400, 110 | message: `图片大小超过限制 (最大 ${Math.round(maxFileSize / 1024 / 1024)}MB)` 111 | }) 112 | } 113 | 114 | // 从URL或Content-Type推断文件格式 115 | let fileExt = '' 116 | const urlPath = imageUrl.pathname.toLowerCase() 117 | const extMatch = urlPath.match(/\.([a-z0-9]+)$/i) 118 | if (extMatch) { 119 | fileExt = extMatch[1] 120 | } else { 121 | // 从 Content-Type 推断 122 | const mimeToExt = { 123 | 'image/jpeg': 'jpg', 124 | 'image/jpg': 'jpg', 125 | 'image/png': 'png', 126 | 'image/gif': 'gif', 127 | 'image/webp': 'webp', 128 | 'image/avif': 'avif', 129 | 'image/svg+xml': 'svg', 130 | 'image/bmp': 'bmp', 131 | 'image/x-icon': 'ico', 132 | 'image/apng': 'apng', 133 | 'image/tiff': 'tiff' 134 | } 135 | fileExt = mimeToExt[contentType.split(';')[0]] || 'jpg' 136 | } 137 | 138 | // 生成 UUID 139 | const imageUuid = uuidv4() 140 | 141 | // 处理图片(可选转换为 WebP) 142 | let processedBuffer = buffer 143 | let finalFormat = fileExt 144 | let isWebp = false 145 | 146 | if (config.convertToWebp && fileExt !== 'gif') { 147 | processedBuffer = await processImage(buffer, { 148 | format: 'webp', 149 | quality: 90 // 私有 API 使用更高质量 150 | }) 151 | finalFormat = 'webp' 152 | isWebp = true 153 | } 154 | 155 | // 获取图片元数据 156 | const metadata = await getImageMetadata(processedBuffer) 157 | 158 | // 保存文件 159 | const filename = `${imageUuid}.${finalFormat}` 160 | await saveUploadedFile(processedBuffer, filename) 161 | 162 | // 从URL提取原始文件名 163 | let originalName = imageUrl.pathname.split('/').pop() || 'image' 164 | if (!originalName.includes('.')) { 165 | originalName += `.${fileExt}` 166 | } 167 | 168 | // 保存到数据库 169 | const imageDoc = { 170 | _id: uuidv4(), 171 | uuid: imageUuid, 172 | originalName: originalName, 173 | filename: filename, 174 | format: finalFormat, 175 | size: processedBuffer.length, 176 | width: metadata.width || 0, 177 | height: metadata.height || 0, 178 | isWebp: isWebp, 179 | isDeleted: false, 180 | uploadedBy: user.username || '管理员', 181 | uploadedByType: 'url', // 标记为URL上传 182 | sourceUrl: url, // 保存原始URL 183 | ip: clientIP, 184 | uploadedAt: new Date().toISOString(), 185 | updatedAt: new Date().toISOString() 186 | } 187 | 188 | await db.images.insert(imageDoc) 189 | 190 | // 返回结果 191 | return { 192 | success: true, 193 | message: '上传成功', 194 | data: { 195 | id: imageDoc._id, 196 | uuid: imageUuid, 197 | filename: filename, 198 | format: finalFormat, 199 | size: processedBuffer.length, 200 | width: metadata.width || 0, 201 | height: metadata.height || 0, 202 | url: `/i/${imageUuid}.${finalFormat}`, 203 | uploadedAt: imageDoc.uploadedAt 204 | } 205 | } 206 | } catch (error) { 207 | if (error.statusCode) { 208 | throw error 209 | } 210 | 211 | console.error('[Upload] URL上传失败:', error) 212 | throw createError({ 213 | statusCode: 500, 214 | message: '上传失败,请稍后重试' 215 | }) 216 | } 217 | }) -------------------------------------------------------------------------------- /server/api/upload/public.post.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { processImage, getImageMetadata, saveUploadedFile } from '../../utils/image.js' 3 | import { parseFormData } from '../../utils/upload.js' 4 | import { v4 as uuidv4 } from 'uuid' 5 | import { 6 | checkPublicRateLimit, 7 | checkPublicConcurrency, 8 | acquirePublicConcurrency, 9 | releasePublicConcurrency 10 | } from '../../utils/rateLimit.js' 11 | import { createModerationTask } from '../../utils/moderationQueue.js' 12 | import { isBlacklisted } from '../../utils/ipBlacklist.js' 13 | import { sendUploadNotification } from '../../utils/notification.js' 14 | 15 | export default defineEventHandler(async (event) => { 16 | const clientIP = getRequestIP(event, { xForwardedFor: true }) || 'unknown' 17 | 18 | try { 19 | // 检查 IP 是否在黑名单中 20 | if (await isBlacklisted(clientIP)) { 21 | throw createError({ 22 | statusCode: 403, 23 | message: '您的 IP 已被禁止上传' 24 | }) 25 | } 26 | 27 | // 获取公共 API 配置 28 | const configDoc = await db.settings.findOne({ key: 'publicApiConfig' }) 29 | const config = configDoc?.value || {} 30 | 31 | // 检查公共 API 是否启用 32 | if (!config.enabled) { 33 | throw createError({ 34 | statusCode: 403, 35 | message: '公共上传已禁用' 36 | }) 37 | } 38 | 39 | // 检查频率限制 40 | const rateLimitResult = checkPublicRateLimit(clientIP, config.rateLimit || 10) 41 | if (!rateLimitResult.allowed) { 42 | throw createError({ 43 | statusCode: 429, 44 | message: `请求过于频繁,请 ${rateLimitResult.retryAfter} 秒后重试` 45 | }) 46 | } 47 | 48 | // 检查并发限制 49 | if (!config.allowConcurrent) { 50 | const concurrencyResult = checkPublicConcurrency(clientIP) 51 | if (!concurrencyResult.allowed) { 52 | throw createError({ 53 | statusCode: 429, 54 | message: '请等待上一张图片上传完成' 55 | }) 56 | } 57 | // 获取并发锁 58 | acquirePublicConcurrency(clientIP) 59 | } 60 | 61 | // 解析表单数据 62 | const { file } = await parseFormData(event) 63 | 64 | if (!file) { 65 | releasePublicConcurrency(clientIP) 66 | throw createError({ 67 | statusCode: 400, 68 | message: '请选择要上传的图片' 69 | }) 70 | } 71 | 72 | // 检查文件格式 73 | const fileExt = file.originalFilename?.split('.').pop()?.toLowerCase() || '' 74 | const allowedFormats = config.allowedFormats || ['jpg', 'jpeg', 'png', 'gif', 'webp'] 75 | if (!allowedFormats.includes(fileExt)) { 76 | releasePublicConcurrency(clientIP) 77 | throw createError({ 78 | statusCode: 400, 79 | message: `不支持的图片格式,允许的格式: ${allowedFormats.join(', ')}` 80 | }) 81 | } 82 | 83 | // 检查文件大小 84 | const maxFileSize = config.maxFileSize || 10 * 1024 * 1024 85 | if (file.size > maxFileSize) { 86 | releasePublicConcurrency(clientIP) 87 | throw createError({ 88 | statusCode: 400, 89 | message: `文件大小超过限制 (最大 ${Math.round(maxFileSize / 1024 / 1024)}MB)` 90 | }) 91 | } 92 | 93 | // 生成 UUID 94 | const imageUuid = uuidv4() 95 | 96 | // 处理图片(压缩和转换) 97 | let processedBuffer = file.buffer 98 | let finalFormat = fileExt 99 | let isWebp = false 100 | 101 | if (config.compressToWebp && fileExt !== 'gif') { 102 | const quality = config.webpQuality || 80 103 | processedBuffer = await processImage(file.buffer, { 104 | format: 'webp', 105 | quality: quality 106 | }) 107 | finalFormat = 'webp' 108 | isWebp = true 109 | } 110 | 111 | // 获取图片元数据 112 | const metadata = await getImageMetadata(processedBuffer) 113 | 114 | // 保存文件 115 | const filename = `${imageUuid}.${finalFormat}` 116 | await saveUploadedFile(processedBuffer, filename) 117 | 118 | // 判断是否启用内容安全检测 119 | const contentSafetyEnabled = config.contentSafety?.enabled || false 120 | 121 | // 保存到数据库 122 | const imageDoc = { 123 | _id: uuidv4(), 124 | uuid: imageUuid, 125 | originalName: file.originalFilename, 126 | filename: filename, 127 | format: finalFormat, 128 | size: processedBuffer.length, 129 | width: metadata.width || 0, 130 | height: metadata.height || 0, 131 | isWebp: isWebp, 132 | isDeleted: false, 133 | uploadedBy: '访客', 134 | uploadedByType: 'public', 135 | ip: clientIP, 136 | uploadedAt: new Date().toISOString(), 137 | updatedAt: new Date().toISOString(), 138 | // 内容审核相关字段 139 | // 如果未启用内容安全检测,状态直接设为 skipped 140 | moderationStatus: contentSafetyEnabled ? 'pending' : 'skipped', 141 | moderationResult: contentSafetyEnabled ? null : { skipped: true, reason: '内容安全检测未启用' }, 142 | moderationChecked: !contentSafetyEnabled, // 未启用时标记为已检测(跳过) 143 | isNsfw: false 144 | } 145 | 146 | await db.images.insert(imageDoc) 147 | 148 | // 如果启用了内容安全检测,创建审核任务 149 | if (contentSafetyEnabled) { 150 | try { 151 | await createModerationTask(imageDoc._id, imageUuid, filename) 152 | } catch (err) { 153 | console.error('[Upload] 创建审核任务失败:', err) 154 | // 审核任务创建失败不影响上传结果 155 | } 156 | } 157 | 158 | // 释放并发锁 159 | releasePublicConcurrency(clientIP) 160 | 161 | // 获取站点 URL 配置,用于生成完整图片链接 162 | const appSettingsDoc = await db.settings.findOne({ key: 'appSettings' }) 163 | let siteUrl = appSettingsDoc?.value?.siteUrl || '' 164 | 165 | // 如果没有配置站点 URL,使用请求的 Host 作为兜底 166 | if (!siteUrl) { 167 | const protocol = getHeader(event, 'x-forwarded-proto') || 'http' 168 | const host = getHeader(event, 'host') || 'localhost' 169 | siteUrl = `${protocol}://${host}` 170 | } 171 | 172 | // 移除末尾斜杠,确保 URL 拼接正确 173 | siteUrl = siteUrl.replace(/\/+$/, '') 174 | const fullImageUrl = `${siteUrl}/i/${imageUuid}.${finalFormat}` 175 | 176 | // 发送上传通知(异步,不阻塞响应) 177 | sendUploadNotification( 178 | { 179 | id: imageDoc._id, 180 | filename: filename, 181 | format: finalFormat, 182 | size: processedBuffer.length, 183 | url: fullImageUrl 184 | }, 185 | { 186 | name: '访客', 187 | type: 'public', 188 | ip: clientIP 189 | } 190 | ).catch(err => { 191 | console.error('[Upload] 发送上传通知失败:', err) 192 | }) 193 | 194 | // 返回结果(不包含敏感信息) 195 | return { 196 | success: true, 197 | message: '上传成功', 198 | data: { 199 | id: imageDoc._id, 200 | uuid: imageUuid, 201 | filename: filename, 202 | format: finalFormat, 203 | size: processedBuffer.length, 204 | width: metadata.width || 0, 205 | height: metadata.height || 0, 206 | url: `/i/${imageUuid}.${finalFormat}`, 207 | uploadedAt: imageDoc.uploadedAt 208 | } 209 | } 210 | } catch (error) { 211 | // 确保释放并发锁 212 | releasePublicConcurrency(clientIP) 213 | 214 | if (error.statusCode) { 215 | throw error 216 | } 217 | 218 | console.error('[Upload] 公共上传失败:', error) 219 | throw createError({ 220 | statusCode: 500, 221 | message: '上传失败,请稍后重试' 222 | }) 223 | } 224 | }) 225 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 147 | 148 | 193 | 194 | -------------------------------------------------------------------------------- /app/components/ImageCard.vue: -------------------------------------------------------------------------------- 1 | 134 | 135 | 203 | 204 | -------------------------------------------------------------------------------- /app/stores/settings.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useAuthStore } from './auth' 3 | 4 | export const useSettingsStore = defineStore('settings', { 5 | state: () => ({ 6 | appSettings: { 7 | appName: 'EasyImg', 8 | appLogo: '', 9 | backgroundUrl: '', 10 | backgroundBlur: 0, 11 | deletedImagesCount: 0, 12 | announcement: { 13 | enabled: false, 14 | content: '', 15 | displayType: 'modal' // 'modal' | 'banner' 16 | } 17 | }, 18 | publicApiConfig: { 19 | enabled: true, 20 | allowedFormats: ['jpg', 'jpeg', 'png', 'gif', 'webp'], 21 | maxFileSize: 10 * 1024 * 1024, 22 | compressToWebp: true, 23 | webpQuality: 80, 24 | rateLimit: 10, 25 | allowConcurrent: false 26 | }, 27 | privateApiConfig: { 28 | maxFileSize: 100 * 1024 * 1024, 29 | convertToWebp: false, 30 | webpQuality: 80, 31 | showOnHomepage: false 32 | }, 33 | apiKeys: [], 34 | loading: false 35 | }), 36 | 37 | actions: { 38 | // 获取公共应用设置(无需登录) 39 | async fetchPublicAppSettings() { 40 | try { 41 | const response = await $fetch('/api/settings/public') 42 | 43 | if (response.success) { 44 | // 只更新公共字段,保留其他字段 45 | this.appSettings.appName = response.data.appName 46 | this.appSettings.appLogo = response.data.appLogo 47 | this.appSettings.backgroundUrl = response.data.backgroundUrl 48 | this.appSettings.backgroundBlur = response.data.backgroundBlur 49 | this.appSettings.announcement = response.data.announcement || { 50 | enabled: false, 51 | content: '', 52 | displayType: 'modal' 53 | } 54 | } 55 | } catch (error) { 56 | console.error('获取公共应用设置失败:', error) 57 | } 58 | }, 59 | 60 | // 获取应用设置(需要登录) 61 | async fetchAppSettings() { 62 | const authStore = useAuthStore() 63 | 64 | try { 65 | const response = await $fetch('/api/settings', { 66 | headers: authStore.authHeader 67 | }) 68 | 69 | if (response.success) { 70 | this.appSettings = response.data 71 | } 72 | } catch (error) { 73 | console.error('获取应用设置失败:', error) 74 | } 75 | }, 76 | 77 | // 保存应用设置 78 | async saveAppSettings(settings) { 79 | const authStore = useAuthStore() 80 | 81 | try { 82 | const response = await $fetch('/api/settings', { 83 | method: 'PUT', 84 | body: settings, 85 | headers: authStore.authHeader 86 | }) 87 | 88 | if (response.success) { 89 | this.appSettings = { ...this.appSettings, ...response.data } 90 | return { success: true } 91 | } 92 | 93 | return { success: false, message: response.message } 94 | } catch (error) { 95 | return { success: false, message: error.data?.message || '保存失败' } 96 | } 97 | }, 98 | 99 | // 获取公共 API 配置 100 | async fetchPublicApiConfig() { 101 | const authStore = useAuthStore() 102 | 103 | try { 104 | const response = await $fetch('/api/config/public', { 105 | headers: authStore.authHeader 106 | }) 107 | 108 | if (response.success) { 109 | this.publicApiConfig = response.data 110 | } 111 | } catch (error) { 112 | console.error('获取公共 API 配置失败:', error) 113 | } 114 | }, 115 | 116 | // 保存公共 API 配置 117 | async savePublicApiConfig(config) { 118 | const authStore = useAuthStore() 119 | 120 | try { 121 | const response = await $fetch('/api/config/public', { 122 | method: 'PUT', 123 | body: config, 124 | headers: authStore.authHeader 125 | }) 126 | 127 | if (response.success) { 128 | this.publicApiConfig = response.data 129 | return { success: true } 130 | } 131 | 132 | return { success: false, message: response.message } 133 | } catch (error) { 134 | return { success: false, message: error.data?.message || '保存失败' } 135 | } 136 | }, 137 | 138 | // 获取私有 API 配置 139 | async fetchPrivateApiConfig() { 140 | const authStore = useAuthStore() 141 | 142 | try { 143 | const response = await $fetch('/api/config/private', { 144 | headers: authStore.authHeader 145 | }) 146 | 147 | if (response.success) { 148 | this.privateApiConfig = response.data 149 | } 150 | } catch (error) { 151 | console.error('获取私有 API 配置失败:', error) 152 | } 153 | }, 154 | 155 | // 保存私有 API 配置 156 | async savePrivateApiConfig(config) { 157 | const authStore = useAuthStore() 158 | 159 | try { 160 | const response = await $fetch('/api/config/private', { 161 | method: 'PUT', 162 | body: config, 163 | headers: authStore.authHeader 164 | }) 165 | 166 | if (response.success) { 167 | this.privateApiConfig = response.data 168 | return { success: true } 169 | } 170 | 171 | return { success: false, message: response.message } 172 | } catch (error) { 173 | return { success: false, message: error.data?.message || '保存失败' } 174 | } 175 | }, 176 | 177 | // 获取 ApiKey 列表 178 | async fetchApiKeys() { 179 | const authStore = useAuthStore() 180 | 181 | try { 182 | const response = await $fetch('/api/apikeys', { 183 | headers: authStore.authHeader 184 | }) 185 | 186 | if (response.success) { 187 | this.apiKeys = response.data 188 | } 189 | } catch (error) { 190 | console.error('获取 ApiKey 列表失败:', error) 191 | } 192 | }, 193 | 194 | // 创建 ApiKey 195 | async createApiKey(name) { 196 | const authStore = useAuthStore() 197 | 198 | try { 199 | const response = await $fetch('/api/apikeys', { 200 | method: 'POST', 201 | body: { name }, 202 | headers: authStore.authHeader 203 | }) 204 | 205 | if (response.success) { 206 | this.apiKeys.push(response.data) 207 | return { success: true, data: response.data } 208 | } 209 | 210 | return { success: false, message: response.message } 211 | } catch (error) { 212 | return { success: false, message: error.data?.message || '创建失败' } 213 | } 214 | }, 215 | 216 | // 更新 ApiKey 217 | async updateApiKey(id, data) { 218 | const authStore = useAuthStore() 219 | 220 | try { 221 | const response = await $fetch(`/api/apikeys/${id}`, { 222 | method: 'PUT', 223 | body: data, 224 | headers: authStore.authHeader 225 | }) 226 | 227 | if (response.success) { 228 | const index = this.apiKeys.findIndex(k => k.id === id) 229 | if (index > -1) { 230 | this.apiKeys[index] = response.data 231 | } 232 | return { success: true, data: response.data } 233 | } 234 | 235 | return { success: false, message: response.message } 236 | } catch (error) { 237 | return { success: false, message: error.data?.message || '更新失败' } 238 | } 239 | }, 240 | 241 | // 删除 ApiKey 242 | async deleteApiKey(id) { 243 | const authStore = useAuthStore() 244 | 245 | try { 246 | const response = await $fetch(`/api/apikeys/${id}`, { 247 | method: 'DELETE', 248 | headers: authStore.authHeader 249 | }) 250 | 251 | if (response.success) { 252 | this.apiKeys = this.apiKeys.filter(k => k.id !== id) 253 | return { success: true } 254 | } 255 | 256 | return { success: false, message: response.message } 257 | } catch (error) { 258 | return { success: false, message: error.data?.message || '删除失败' } 259 | } 260 | }, 261 | 262 | // 硬删除所有软删除的图片 263 | async hardDeleteImages() { 264 | const authStore = useAuthStore() 265 | 266 | try { 267 | const response = await $fetch('/api/settings/hard-delete', { 268 | method: 'POST', 269 | headers: authStore.authHeader 270 | }) 271 | 272 | if (response.success) { 273 | this.appSettings.deletedImagesCount = 0 274 | return { success: true, message: response.message, count: response.data.deletedCount } 275 | } 276 | 277 | return { success: false, message: response.message } 278 | } catch (error) { 279 | return { success: false, message: error.data?.message || '删除失败' } 280 | } 281 | } 282 | } 283 | }) 284 | -------------------------------------------------------------------------------- /app/pages/about.vue: -------------------------------------------------------------------------------- 1 | 179 | 180 | -------------------------------------------------------------------------------- /server/api/upload/urls.post.js: -------------------------------------------------------------------------------- 1 | import db from '../../utils/db.js' 2 | import { processImage, getImageMetadata, saveUploadedFile } from '../../utils/image.js' 3 | import { authMiddleware } from '../../utils/authMiddleware.js' 4 | import { v4 as uuidv4 } from 'uuid' 5 | 6 | // 下载单个URL的图片 7 | async function downloadAndSaveImage(url, config, user, clientIP) { 8 | // 验证URL格式 9 | let imageUrl 10 | try { 11 | imageUrl = new URL(url) 12 | if (!['http:', 'https:'].includes(imageUrl.protocol)) { 13 | throw new Error('协议不支持') 14 | } 15 | } catch (e) { 16 | throw new Error('无效的URL格式') 17 | } 18 | 19 | // 下载图片 20 | let response 21 | try { 22 | const controller = new AbortController() 23 | const timeoutId = setTimeout(() => controller.abort(), 30000) // 30秒超时 24 | 25 | // 构建请求头,绕过防盗链检测 26 | const fetchHeaders = { 27 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 28 | 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 29 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 30 | 'Accept-Encoding': 'gzip, deflate, br', 31 | 'Cache-Control': 'no-cache', 32 | 'Pragma': 'no-cache', 33 | 'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', 34 | 'Sec-Ch-Ua-Mobile': '?0', 35 | 'Sec-Ch-Ua-Platform': '"Windows"', 36 | 'Sec-Fetch-Dest': 'image', 37 | 'Sec-Fetch-Mode': 'no-cors', 38 | 'Sec-Fetch-Site': 'cross-site', 39 | // 使用图片URL的origin作为Referer,绕过防盗链 40 | 'Referer': imageUrl.origin + '/', 41 | 'Origin': imageUrl.origin 42 | } 43 | 44 | response = await fetch(url, { 45 | signal: controller.signal, 46 | headers: fetchHeaders, 47 | redirect: 'follow' // 自动跟随重定向 48 | }) 49 | 50 | clearTimeout(timeoutId) 51 | 52 | if (!response.ok) { 53 | throw new Error(`HTTP ${response.status}`) 54 | } 55 | } catch (e) { 56 | if (e.name === 'AbortError') { 57 | throw new Error('下载超时,请稍后重试') 58 | } 59 | throw new Error(`无法下载图片: ${e.message}`) 60 | } 61 | 62 | // 检查 Content-Type 63 | const contentType = response.headers.get('content-type') || '' 64 | if (!contentType.startsWith('image/')) { 65 | throw new Error('URL指向的不是有效的图片') 66 | } 67 | 68 | // 获取图片数据 69 | const arrayBuffer = await response.arrayBuffer() 70 | const buffer = Buffer.from(arrayBuffer) 71 | 72 | // 检查文件大小 73 | const maxFileSize = config.maxFileSize || 100 * 1024 * 1024 74 | if (buffer.length > maxFileSize) { 75 | throw new Error(`图片大小超过限制 (最大 ${Math.round(maxFileSize / 1024 / 1024)}MB)`) 76 | } 77 | 78 | // 从URL或Content-Type推断文件格式 79 | let fileExt = '' 80 | const urlPath = imageUrl.pathname.toLowerCase() 81 | const extMatch = urlPath.match(/\.([a-z0-9]+)$/i) 82 | if (extMatch) { 83 | fileExt = extMatch[1] 84 | } else { 85 | // 从 Content-Type 推断 86 | const mimeToExt = { 87 | 'image/jpeg': 'jpg', 88 | 'image/jpg': 'jpg', 89 | 'image/png': 'png', 90 | 'image/gif': 'gif', 91 | 'image/webp': 'webp', 92 | 'image/avif': 'avif', 93 | 'image/svg+xml': 'svg', 94 | 'image/bmp': 'bmp', 95 | 'image/x-icon': 'ico', 96 | 'image/apng': 'apng', 97 | 'image/tiff': 'tiff' 98 | } 99 | fileExt = mimeToExt[contentType.split(';')[0]] || 'jpg' 100 | } 101 | 102 | // 生成 UUID 103 | const imageUuid = uuidv4() 104 | 105 | // 处理图片(可选转换为 WebP) 106 | let processedBuffer = buffer 107 | let finalFormat = fileExt 108 | let isWebp = false 109 | 110 | if (config.convertToWebp && fileExt !== 'gif') { 111 | processedBuffer = await processImage(buffer, { 112 | format: 'webp', 113 | quality: 90 // 私有 API 使用更高质量 114 | }) 115 | finalFormat = 'webp' 116 | isWebp = true 117 | } 118 | 119 | // 获取图片元数据 120 | const metadata = await getImageMetadata(processedBuffer) 121 | 122 | // 保存文件 123 | const filename = `${imageUuid}.${finalFormat}` 124 | await saveUploadedFile(processedBuffer, filename) 125 | 126 | // 从URL提取原始文件名 127 | let originalName = imageUrl.pathname.split('/').pop() || 'image' 128 | if (!originalName.includes('.')) { 129 | originalName += `.${fileExt}` 130 | } 131 | 132 | // 保存到数据库 133 | const imageDoc = { 134 | _id: uuidv4(), 135 | uuid: imageUuid, 136 | originalName: originalName, 137 | filename: filename, 138 | format: finalFormat, 139 | size: processedBuffer.length, 140 | width: metadata.width || 0, 141 | height: metadata.height || 0, 142 | isWebp: isWebp, 143 | isDeleted: false, 144 | uploadedBy: user.username || '管理员', 145 | uploadedByType: 'url', // 标记为URL上传 146 | sourceUrl: url, // 保存原始URL 147 | ip: clientIP, 148 | uploadedAt: new Date().toISOString(), 149 | updatedAt: new Date().toISOString() 150 | } 151 | 152 | await db.images.insert(imageDoc) 153 | 154 | return { 155 | id: imageDoc._id, 156 | uuid: imageUuid, 157 | filename: filename, 158 | format: finalFormat, 159 | size: processedBuffer.length, 160 | width: metadata.width || 0, 161 | height: metadata.height || 0, 162 | url: `/i/${imageUuid}.${finalFormat}`, 163 | uploadedAt: imageDoc.uploadedAt 164 | } 165 | } 166 | 167 | export default defineEventHandler(async (event) => { 168 | const clientIP = getRequestIP(event, { xForwardedFor: true }) || 'unknown' 169 | 170 | try { 171 | // 验证登录状态(仅端内调用,不使用API Key) 172 | await authMiddleware(event) 173 | const user = event.context.user 174 | 175 | // 解析请求体 176 | const body = await readBody(event) 177 | const { urls } = body 178 | 179 | if (!urls || !Array.isArray(urls) || urls.length === 0) { 180 | throw createError({ 181 | statusCode: 400, 182 | message: '请提供图片URL列表' 183 | }) 184 | } 185 | 186 | // 过滤空URL并自动去重 187 | const validUrls = [...new Set(urls.map(u => u.trim()).filter(u => u.length > 0))] 188 | if (validUrls.length === 0) { 189 | throw createError({ 190 | statusCode: 400, 191 | message: '请提供有效的图片URL' 192 | }) 193 | } 194 | 195 | // 获取私有 API 配置 196 | const configDoc = await db.settings.findOne({ key: 'privateApiConfig' }) 197 | const config = configDoc?.value || {} 198 | 199 | // 设置SSE响应头 200 | setHeader(event, 'Content-Type', 'text/event-stream') 201 | setHeader(event, 'Cache-Control', 'no-cache') 202 | setHeader(event, 'Connection', 'keep-alive') 203 | setHeader(event, 'X-Accel-Buffering', 'no') // 禁用nginx缓冲 204 | 205 | const responseStream = event.node.res 206 | 207 | // 发送SSE事件的辅助函数 208 | const sendEvent = (eventType, data) => { 209 | const eventData = JSON.stringify(data) 210 | responseStream.write(`event: ${eventType}\n`) 211 | responseStream.write(`data: ${eventData}\n\n`) 212 | } 213 | 214 | // 发送开始事件 215 | sendEvent('start', { 216 | total: validUrls.length, 217 | message: '开始下载图片' 218 | }) 219 | 220 | const results = [] 221 | let successCount = 0 222 | let failCount = 0 223 | 224 | // 串行下载每个URL 225 | for (let i = 0; i < validUrls.length; i++) { 226 | const url = validUrls[i] 227 | const index = i + 1 228 | 229 | // 发送进度事件 - 开始下载 230 | sendEvent('progress', { 231 | index, 232 | total: validUrls.length, 233 | url, 234 | status: 'downloading', 235 | message: `正在下载第 ${index}/${validUrls.length} 张图片` 236 | }) 237 | 238 | try { 239 | const result = await downloadAndSaveImage(url, config, user, clientIP) 240 | successCount++ 241 | results.push({ 242 | url, 243 | success: true, 244 | data: result 245 | }) 246 | 247 | // 发送进度事件 - 下载成功 248 | sendEvent('progress', { 249 | index, 250 | total: validUrls.length, 251 | url, 252 | status: 'success', 253 | message: `第 ${index}/${validUrls.length} 张图片下载成功`, 254 | data: result 255 | }) 256 | } catch (error) { 257 | failCount++ 258 | const errorMessage = error.message || '下载失败' 259 | results.push({ 260 | url, 261 | success: false, 262 | error: errorMessage 263 | }) 264 | 265 | // 发送进度事件 - 下载失败 266 | sendEvent('progress', { 267 | index, 268 | total: validUrls.length, 269 | url, 270 | status: 'error', 271 | message: `第 ${index}/${validUrls.length} 张图片下载失败: ${errorMessage}`, 272 | error: errorMessage 273 | }) 274 | } 275 | 276 | // 短暂延迟,避免请求过快 277 | if (i < validUrls.length - 1) { 278 | await new Promise(resolve => setTimeout(resolve, 100)) 279 | } 280 | } 281 | 282 | // 发送完成事件 283 | sendEvent('complete', { 284 | total: validUrls.length, 285 | successCount, 286 | failCount, 287 | message: `下载完成:成功 ${successCount} 张,失败 ${failCount} 张`, 288 | results 289 | }) 290 | 291 | // 结束响应 292 | responseStream.end() 293 | 294 | } catch (error) { 295 | // 如果是在SSE开始之前发生的错误,返回普通JSON错误 296 | if (!event.node.res.headersSent) { 297 | if (error.statusCode) { 298 | throw error 299 | } 300 | 301 | console.error('[Upload] 批量URL上传失败:', error) 302 | throw createError({ 303 | statusCode: 500, 304 | message: '上传失败,请稍后重试' 305 | }) 306 | } else { 307 | // SSE已经开始,发送错误事件并结束 308 | const responseStream = event.node.res 309 | const errorData = JSON.stringify({ 310 | error: error.message || '上传失败', 311 | message: '上传过程中发生错误' 312 | }) 313 | responseStream.write(`event: error\n`) 314 | responseStream.write(`data: ${errorData}\n\n`) 315 | responseStream.end() 316 | } 317 | } 318 | }) --------------------------------------------------------------------------------