import {readFileSync, existsSync, readdirSync, statSync, unlinkSync, mkdirSync, copyFileSync, lstatSync, writeFileSync} from 'fs';
import {createReadStream} from 'fs';
import {execSync} from 'child_process';
import path from 'path';
import {fileURLToPath} from 'url';
import {createHash} from 'crypto';
import {ENV} from '../utils/env.js';
import COOKIE from '../utils/cookieManager.js';
import {validateBasicAuth} from '../utils/api_validate.js';
const COOKIE_AUTH_CODE = process.env.COOKIE_AUTH_CODE || 'drpys';
const IS_VERCEL = process.env.VERCEL;
const DOWNLOAD_AUTH_SECRET = process.env.DOWNLOAD_AUTH_SECRET || 'drpys_download_secret';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRootDir = path.dirname(__dirname);
const pkg = JSON.parse(readFileSync(path.join(projectRootDir, 'package.json'), 'utf-8'));
const generateDownloadToken = (filename) => {
const timestamp = Date.now();
const data = `${filename}-${timestamp}-${DOWNLOAD_AUTH_SECRET}`;
const token = createHash('md5').update(data).digest('hex');
return `${token}-${timestamp}`;
};
const validateDownloadToken = (filename, token) => {
if (!token) return false;
const parts = token.split('-');
if (parts.length < 2) return false;
const timestamp = parseInt(parts.pop());
const hash = parts.join('-');
const data = `${filename}-${timestamp}-${DOWNLOAD_AUTH_SECRET}`;
const expectedHash = createHash('md5').update(data).digest('hex');
const now = Date.now();
return hash === expectedHash && (now - timestamp) < 3600000;
};
const findLatestPackage = (projectDir, packageName) => {
try {
const parentDir = path.dirname(projectDir);
const files = readdirSync(parentDir);
const isGreen = packageName.includes('-green');
const ext = packageName.split('.').pop();
const baseName = packageName.replace(/-green\.[^.]+$/, '').replace(/\.[^.]+$/, '');
const pattern = new RegExp(`^${baseName.replace(/\./g, '\\.')}-\\d{8}${isGreen ? '-green' : ''}\\.${ext}`);
console.log(`查找包: ${packageName}, 正则: ${pattern.source}, 父目录: ${parentDir}`);
console.log('目录中的文件:', files.filter(f => f.includes('drpy-node')));
const packageFiles = files
.filter(file => pattern.test(file))
.map(file => {
const filePath = path.join(parentDir, file);
const stats = statSync(filePath);
return {file, filePath, mtime: stats.mtime, size: stats.size};
})
.sort((a, b) => b.mtime - a.mtime);
console.log('匹配到的文件:', packageFiles.map(f => f.file));
return packageFiles.length > 0 ? packageFiles[0] : null;
} catch (error) {
console.error('查找包失败:', error.message);
return null;
}
};
const buildPackage = (packageName) => {
try {
let command = 'node package.js';
if (packageName.includes('-green')) {
command += ' -g';
}
if (packageName.includes('.zip')) {
command += ' -z';
}
console.log(`执行打包命令: ${command}, 目录: ${projectRootDir}`);
const output = execSync(command, {cwd: projectRootDir, stdio: 'pipe'});
console.log('打包输出:', output.toString());
const result = findLatestPackage(projectRootDir, packageName);
console.log('打包后查找结果:', result ? result.file : '未找到');
return result;
} catch (error) {
console.error('打包失败:', error.message);
console.error('错误详情:', error.stdout?.toString(), error.stderr?.toString());
throw error;
}
};
export default (fastify, options, done) => {
fastify.get('/admin/encoder', async (request, reply) => {
const encoderFilePath = path.join(options.appsDir, 'encoder/index.html'); // 获取 encoder.html 文件的路径
// 检查文件是否存在
if (!existsSync(encoderFilePath)) {
return reply.status(404).send({error: 'encoder.html not found'});
}
try {
// 读取 HTML 文件内容
const htmlContent = readFileSync(encoderFilePath, 'utf-8');
reply.type('text/html').send(htmlContent); // 返回 HTML 文件内容
} catch (error) {
fastify.log.error(`Failed to read encoder.html: ${error.message}`);
return reply.status(500).send({error: 'Failed to load encoder page'});
}
});
fastify.post('/admin/cookie-set', async (request, reply) => {
try {
// 从请求体中获取参数
const {cookie_auth_code, key, value} = request.body;
// 验证参数完整性
if (!cookie_auth_code || !key || !value) {
return reply.code(400).send({
success: false,
message: 'Missing required parameters: cookie_auth_code, key, or value',
});
}
// 验证 cookie_auth_code 是否正确
if (cookie_auth_code !== COOKIE_AUTH_CODE) {
return reply.code(403).send({
success: false,
message: 'Invalid cookie_auth_code',
});
}
let cookie_obj = COOKIE.parse(value);
let cookie_str = value;
if (['quark_cookie', 'uc_cookie'].includes(key)) {
// console.log(cookie_obj);
cookie_str = COOKIE.stringify({
__pus: cookie_obj.__pus || '',
__puus: cookie_obj.__puus || '',
});
console.log(cookie_str);
}
// 调用 ENV.set 设置环境变量
ENV.set(key, cookie_str);
// 返回成功响应
return reply.code(200).send({
success: true,
message: 'Cookie value has been successfully set',
data: {key, value},
});
} catch (error) {
// 捕获异常并返回错误响应
console.error('Error setting cookie:', error.message);
return reply.code(500).send({
success: false,
message: 'Internal server error',
});
}
});
fastify.get('/admin/download', {
preHandler: validateBasicAuth
}, async (request, reply) => {
try {
if (IS_VERCEL) {
return reply.code(403).send({
success: false,
message: 'Vercel 环境不支持文件下载功能',
});
}
const projectName = path.basename(projectRootDir);
const templatePath = path.join(projectRootDir, 'public', 'download.html');
if (!existsSync(templatePath)) {
return reply.code(500).send({
success: false,
message: '下载页面模板不存在',
});
}
let html = readFileSync(templatePath, 'utf-8');
const files = [
{name: `${projectName}.7z`, desc: '7z 压缩包(标准版)'},
{name: `${projectName}.zip`, desc: 'ZIP 压缩包(标准版)'},
{name: `${projectName}-green.7z`, desc: '7z 压缩包(绿色版,不含[密]文件)'},
{name: `${projectName}-green.zip`, desc: 'ZIP 压缩包(绿色版,不含[密]文件)'}
];
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '未打包';
const mb = bytes / (1024 * 1024);
return mb.toFixed(2) + ' MB';
};
const downloadItems = files.map(file => {
const latestPackage = findLatestPackage(projectRootDir, file.name);
const fileSize = latestPackage ? formatFileSize(latestPackage.size) : '未打包';
const sizeClass = latestPackage ? '' : ' not-packed';
const token = generateDownloadToken(file.name);
const downloadUrl = `/admin/download/${file.name}?auth=${token}`;
let buildTime = '未打包';
if (latestPackage && latestPackage.mtime) {
const date = new Date(latestPackage.mtime);
buildTime = date.toLocaleString('zh-CN', { hour12: false });
}
return '
' +
'
' +
'
' + file.name + '
' +
'
' + file.desc + '
' +
'
版本: ' + pkg.version + ' | 打包时间: ' + buildTime + '
' +
'
' +
'
' + fileSize + '
' +
'
' +
'
';
}).join('');
html = html.replace(/\{\{projectName\}\}/g, projectName);
html = html.replace(/\{\{downloadItems\}\}/g, downloadItems);
reply.type('text/html').send(html);
} catch (error) {
console.error('获取下载页面失败:', error.message);
return reply.code(500).send({
success: false,
message: '获取下载页面失败',
error: error.message,
});
}
});
fastify.get('/admin/download/:filename', {
preHandler: async (request, reply) => {
const {auth} = request.query;
if (validateDownloadToken(request.params.filename, auth)) {
return;
}
const authHeader = request.headers.authorization;
if (!authHeader) {
reply.header('WWW-Authenticate', 'Basic');
return reply.code(401).send('Authentication required');
}
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const [username, password] = credentials.split(':');
const validUsername = process.env.API_AUTH_NAME || '';
const validPassword = process.env.API_AUTH_CODE || '';
if (username === validUsername && password === validPassword) {
return;
}
reply.header('WWW-Authenticate', 'Basic');
return reply.code(401).send('Invalid credentials');
}
}, async (request, reply) => {
try {
if (IS_VERCEL) {
return reply.code(403).send({
success: false,
message: 'Vercel 环境不支持文件下载功能',
});
}
const {filename} = request.params;
const projectName = path.basename(projectRootDir);
const validFilenames = [
`${projectName}.7z`,
`${projectName}.zip`,
`${projectName}-green.7z`,
`${projectName}-green.zip`
];
if (!validFilenames.includes(filename)) {
return reply.code(400).send({
success: false,
message: '无效的文件名',
});
}
let latestPackage = findLatestPackage(projectRootDir, filename);
if (!latestPackage) {
console.log(`未找到 ${filename},开始打包...`);
latestPackage = buildPackage(filename);
if (!latestPackage) {
return reply.code(500).send({
success: false,
message: '打包失败,无法创建压缩文件',
});
}
}
const fileStream = createReadStream(latestPackage.filePath);
const contentType = filename.endsWith('.zip') ? 'application/zip' : 'application/x-7z-compressed';
reply.header('Content-Type', contentType);
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(latestPackage.file)}"`);
return reply.send(fileStream);
} catch (error) {
console.error('下载文件失败:', error.message);
return reply.code(500).send({
success: false,
message: '下载失败',
error: error.message,
});
}
});
fastify.post('/admin/download/clear', {
preHandler: validateBasicAuth
}, async (request, reply) => {
try {
if (IS_VERCEL) {
return reply.code(403).send({
success: false,
message: 'Vercel 环境不支持文件操作',
});
}
const parentDir = path.dirname(projectRootDir);
const projectName = path.basename(projectRootDir);
const files = readdirSync(parentDir);
const pattern = new RegExp(`^${projectName.replace(/\./g, '\\.')}-\\d{8}(-green)?\\.(7z|zip)$`);
let deletedCount = 0;
const deletedFiles = [];
for (const file of files) {
if (pattern.test(file)) {
const filePath = path.join(parentDir, file);
try {
unlinkSync(filePath);
deletedFiles.push(file);
deletedCount++;
} catch (error) {
console.error(`删除文件失败: ${file}`, error.message);
}
}
}
return reply.send({
success: true,
count: deletedCount,
deletedFiles,
message: `已清除 ${deletedCount} 个历史文件`
});
} catch (error) {
console.error('清除历史文件失败:', error.message);
return reply.code(500).send({
success: false,
message: '清除历史文件失败',
error: error.message,
});
}
});
const BACKUP_PATHS = [
'.env',
'.plugins.js',
'config/env.json',
'config/map.txt',
'config/parses.conf',
'config/player.json',
'scripts/cron',
'plugins'
];
const BACKINFO_FILENAME = '.backinfo';
const getBackupRootDir = () => {
return path.join(path.dirname(projectRootDir), path.basename(projectRootDir) + '-backup');
};
const getBackinfoPath = (backupDir) => {
return path.join(backupDir, BACKINFO_FILENAME);
};
const loadBackinfo = (backupDir) => {
const infoPath = getBackinfoPath(backupDir);
if (!existsSync(infoPath)) {
return null;
}
try {
const content = readFileSync(infoPath, 'utf-8');
return JSON.parse(content);
} catch (e) {
return null;
}
};
const saveBackinfo = (backupDir, data) => {
const infoPath = getBackinfoPath(backupDir);
writeFileSync(infoPath, JSON.stringify(data, null, 2), 'utf-8');
};
const getEffectiveBackupPaths = (backupDir) => {
const info = loadBackinfo(backupDir);
if (info && Array.isArray(info.paths) && info.paths.length > 0) {
return {paths: info.paths, info};
}
return {paths: BACKUP_PATHS, info};
};
const copyRecursiveSync = (src, dest) => {
const stats = lstatSync(src);
if (stats.isDirectory()) {
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}
readdirSync(src).forEach((childItemName) => {
copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName));
});
} else {
const destDir = path.dirname(dest);
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true });
}
copyFileSync(src, dest);
}
};
fastify.get('/admin/backup/config', {
preHandler: validateBasicAuth
}, async (request, reply) => {
const backupDir = getBackupRootDir();
let paths;
let lastBackupAt = null;
let lastRestoreAt = null;
if (!existsSync(backupDir)) {
paths = BACKUP_PATHS;
} else {
const result = getEffectiveBackupPaths(backupDir);
paths = result.paths;
if (result.info) {
lastBackupAt = result.info.lastBackupAt || null;
lastRestoreAt = result.info.lastRestoreAt || null;
}
}
return reply.send({success: true, paths, lastBackupAt, lastRestoreAt});
});
fastify.post('/admin/backup', {
preHandler: validateBasicAuth
}, async (request, reply) => {
if (IS_VERCEL) {
return reply.code(403).send({ success: false, message: 'Vercel环境不支持备份' });
}
try {
const backupDir = getBackupRootDir();
if (!existsSync(backupDir)) {
mkdirSync(backupDir, { recursive: true });
}
const {paths, info} = getEffectiveBackupPaths(backupDir);
const details = [];
for (const item of paths) {
const srcPath = path.join(projectRootDir, item);
const destPath = path.join(backupDir, item);
if (existsSync(srcPath)) {
copyRecursiveSync(srcPath, destPath);
details.push(`Backed up: ${item}`);
} else {
details.push(`Skipped (not found): ${item}`);
}
}
const now = new Date().toISOString();
const customPaths = info && Array.isArray(info.paths) && info.paths.length > 0 ? info.paths : [];
const backinfoData = {
paths: customPaths,
lastBackupAt: now,
lastRestoreAt: info && info.lastRestoreAt ? info.lastRestoreAt : null
};
saveBackinfo(backupDir, backinfoData);
return reply.send({ success: true, message: '备份完成', backupDir, details });
} catch (error) {
fastify.log.error(`Backup failed: ${error.message}`);
return reply.code(500).send({ success: false, message: '备份失败: ' + error.message });
}
});
fastify.post('/admin/restore', {
preHandler: validateBasicAuth
}, async (request, reply) => {
if (IS_VERCEL) {
return reply.code(403).send({ success: false, message: 'Vercel环境不支持恢复' });
}
try {
const backupDir = getBackupRootDir();
if (!existsSync(backupDir)) {
return reply.code(404).send({ success: false, message: '备份目录不存在' });
}
const {paths, info} = getEffectiveBackupPaths(backupDir);
const details = [];
for (const item of paths) {
const srcPath = path.join(backupDir, item);
const destPath = path.join(projectRootDir, item);
if (existsSync(srcPath)) {
copyRecursiveSync(srcPath, destPath);
details.push(`Restored: ${item}`);
} else {
details.push(`Skipped (not found in backup): ${item}`);
}
}
const now = new Date().toISOString();
const customPaths = info && Array.isArray(info.paths) && info.paths.length > 0 ? info.paths : [];
const backinfoData = {
paths: customPaths,
lastBackupAt: info && info.lastBackupAt ? info.lastBackupAt : null,
lastRestoreAt: now
};
saveBackinfo(backupDir, backinfoData);
return reply.send({ success: true, message: '恢复完成', backupDir, details });
} catch (error) {
fastify.log.error(`Restore failed: ${error.message}`);
return reply.code(500).send({ success: false, message: '恢复失败: ' + error.message });
}
});
done();
};