/** * 剪贴板推送控制器 * 提供剪贴板内容的添加、清空、读取功能 */ import fs from 'fs/promises'; import path from 'path'; import {validateVercel} from '../utils/api_validate.js'; /** * 认证中间件(callback 风格,方便跟 validateVercel 一致) * @param {Object} request 请求对象 * @param {Object} reply 响应对象 * @param {Function} done 回调函数 */ function authenticate(request, reply, done) { const SECURITY_CODE = process.env.CLIPBOARD_SECURITY_CODE; if (!SECURITY_CODE) return done(); // 不启用安全码 const authHeader = request.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return reply.code(401).send('Invalid authorization header format'); } const token = authHeader.substring(7); if (token !== SECURITY_CODE) { return reply.code(401).send('Invalid security code'); } done(); } /** * 剪贴板推送插件 * @param {Object} fastify Fastify实例 * @param {Object} options 选项 */ export default async function (fastify, options) { // 配置参数 const MAX_TEXT_SIZE = parseInt(process.env.CLIPBOARD_MAX_SIZE) || 100 * 1024; const SECURITY_CODE = process.env.CLIPBOARD_SECURITY_CODE; const ALLOWED_CHARSET = process.env.CLIPBOARD_ALLOWED_CHARSET || 'utf8'; const MAX_READ_SIZE = parseInt(process.env.CLIPBOARD_MAX_READ_SIZE) || 2 * 1024 * 1024; if (!SECURITY_CODE) { fastify.log.warn('CLIPBOARD_SECURITY_CODE is not set, API will be unprotected!'); } // ===== 工具函数 ===== /** * 检查文本是否包含可执行模式 * @param {string} text 待检查文本 * @returns {boolean} 是否包含可执行模式 */ function containsExecutablePatterns(text) { const executablePatterns = [ /\x4D\x5A/, // MZ /\x7F\x45\x4C\x46/, // ELF /\x23\x21/, // Shebang /<\?php/, /]*>/, /eval\(/, /javascript:/i, /vbscript:/i, ]; return executablePatterns.some(pattern => pattern.test(text)); } /** * 验证字符集是否有效 * @param {string} text 待验证文本 * @param {string} allowedCharset 允许的字符集 * @returns {boolean} 字符集是否有效 */ function isValidCharset(text, allowedCharset) { try { if (allowedCharset === 'utf8') { Buffer.from(text, 'utf8').toString('utf8'); } return true; } catch { return false; } } // ============ 添加文本接口 ============ fastify.post('/clipboard/add', { preHandler: [validateVercel, authenticate], }, async (request, reply) => { const {text, mode = 'append'} = request.body; // 验证输入参数 if (!text || typeof text !== 'string') { return reply.code(400).send('Valid text content is required'); } if (!['append', 'overwrite'].includes(mode)) { return reply.code(400).send('Mode must be either "append" or "overwrite"'); } // 检查文本大小 const textSize = Buffer.byteLength(text, 'utf8'); if (textSize > MAX_TEXT_SIZE) { return reply.code(413).send(`Text exceeds maximum size of ${MAX_TEXT_SIZE} bytes`); } // 安全检查 if (containsExecutablePatterns(text)) { return reply.code(400).send('Content contains suspicious patterns'); } if (!isValidCharset(text, ALLOWED_CHARSET)) { return reply.code(400).send('Text contains invalid characters'); } // 文件路径安全检查 const filePath = path.resolve(process.cwd(), 'clipboard.txt'); if (!filePath.startsWith(process.cwd())) { return reply.code(500).send('Invalid file path'); } try { if (mode === 'append') { // 追加模式 await fs.appendFile(filePath, text + '\n'); } else { // 覆盖模式:先备份 try { const currentContent = await fs.readFile(filePath, 'utf8'); const backupPath = path.resolve(process.cwd(), 'clipboard.txt.bak'); if (backupPath.startsWith(process.cwd())) { await fs.writeFile(backupPath, currentContent); fastify.log.info(`Clipboard content backed up to ${backupPath}`); } } catch { fastify.log.info('No existing clipboard file to backup'); } await fs.writeFile(filePath, text + '\n'); } return reply.send({ success: true, message: `Text ${mode === 'append' ? 'appended' : 'written'} successfully`, size: textSize, mode, }); } catch (err) { fastify.log.error(err); return reply.code(500).send('Failed to process the request'); } }); // ============ 清空文本接口 ============ fastify.post('/clipboard/clear', { preHandler: [validateVercel, authenticate], }, async (request, reply) => { const filePath = path.resolve(process.cwd(), 'clipboard.txt'); const backupPath = path.resolve(process.cwd(), 'clipboard.txt.bak'); // 文件路径安全检查 if (!filePath.startsWith(process.cwd()) || !backupPath.startsWith(process.cwd())) { return reply.code(500).send('Invalid file path'); } // 检查文件是否存在 try { await fs.access(filePath); } catch { return reply.send({ success: true, message: 'Clipboard already empty, no backup created', }); } try { // 备份当前内容 const currentContent = await fs.readFile(filePath, 'utf8'); await fs.writeFile(backupPath, currentContent); fastify.log.info(`Clipboard content backed up to ${backupPath}`); // 清空文件 await fs.writeFile(filePath, ''); return reply.send({ success: true, message: 'Clipboard cleared successfully, backup created', backupSize: Buffer.byteLength(currentContent, 'utf8'), }); } catch (err) { fastify.log.error(err); return reply.code(500).send('Failed to clear clipboard'); } }); // ============ 读取文本接口 ============ fastify.get('/clipboard/read', { preHandler: [validateVercel, authenticate], }, async (request, reply) => { const filePath = path.resolve(process.cwd(), 'clipboard.txt'); // 文件路径安全检查 if (!filePath.startsWith(process.cwd())) { return reply.code(500).send('Invalid file path'); } try { // 检查文件是否存在 await fs.access(filePath).catch(() => null); // 检查文件大小 const stats = await fs.stat(filePath).catch(() => ({size: 0})); if (stats.size === 0) { return reply.type('text/plain;charset=utf-8').send(''); } if (stats.size > MAX_READ_SIZE) { return reply.code(413).send(`File size exceeds maximum read size of ${MAX_READ_SIZE} bytes`); } // 读取文件内容 const content = await fs.readFile(filePath, 'utf8'); return reply.type('text/plain;charset=utf-8').send(content); } catch (err) { fastify.log.error(err); return reply.code(500).send('Failed to read clipboard content'); } }); }