feat: 呼籁旅行-甄选酒店站点初始化——访客页+后台管理(零依赖Node,data.json内容存储,自动备份50份,图片上传回收)

来源: hulai-travel-site.zip (2026-08-18 Kimi版复刻+CMS,移除Kimi外链脚本)
这个提交包含在:
wx
2026-08-18 11:39:45 +08:00
当前提交 45adc7ad47
共修改 169 个文件,包含 9389 行新增和 0 行删除
+312
查看文件
@@ -0,0 +1,312 @@
/**
* 呼籁旅行 - 甄选酒店 · 站点服务(零依赖 Node.js)
* 启动: node server.js (端口默认 3000,可用环境变量 PORT 修改)
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT = __dirname;
const PORT = parseInt(process.env.PORT || '3000', 10);
const DATA_FILE = path.join(ROOT, 'data.json');
const CONFIG_FILE = path.join(ROOT, 'config.json');
const BACKUP_DIR = path.join(ROOT, 'data-backups');
const TRASH_DIR = path.join(ROOT, 'hotels', '_trash');
const IMG_DIR = path.join(ROOT, 'hotels');
const SESSION_TTL_MS = 12 * 60 * 60 * 1000; // 12 小时
const MAX_BODY = 40 * 1024 * 1024; // 40MB
const DEFAULT_PASSWORD = 'qscy2014@@';
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
};
const IMG_EXT = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
/* ---------- 配置 ---------- */
function loadConfig() {
let cfg = { passwordHash: '' };
if (fs.existsSync(CONFIG_FILE)) {
try { cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch (e) { cfg = { passwordHash: '' }; }
}
const envPwd = process.env.ADMIN_PASSWORD || '';
cfg.passwordHash = envPwd
? sha256(envPwd)
: (cfg.passwordHash || sha256(DEFAULT_PASSWORD));
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
return cfg;
}
function sha256(s) { return crypto.createHash('sha256').update(String(s), 'utf8').digest('hex'); }
const CONFIG = loadConfig();
const sessions = new Map(); // token -> expiresAt
/* ---------- 登录限流 ---------- */
const loginAttempts = new Map(); // ip -> {count, resetAt}
function rateLimited(ip) {
const now = Date.now();
const rec = loginAttempts.get(ip);
if (!rec || now > rec.resetAt) { loginAttempts.set(ip, { count: 1, resetAt: now + 10 * 60 * 1000 }); return false; }
rec.count++;
if (rec.count > 20) return true;
return false;
}
/* ---------- 工具 ---------- */
function send(res, status, body, headers) {
const h = Object.assign({ 'X-Content-Type-Options': 'nosniff' }, headers || {});
if (typeof body === 'object') { h['Content-Type'] = h['Content-Type'] || 'application/json; charset=utf-8'; body = JSON.stringify(body); }
res.writeHead(status, h);
res.end(body);
}
function readBody(req, limit) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (c) => {
size += c.length;
if (size > limit) { reject(new Error('body too large')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function safeToken() { return crypto.randomBytes(32).toString('hex'); }
function cookieOf(req) {
const h = req.headers.cookie || '';
const m = h.match(/(?:^|;\s*)hulai_token=([a-f0-9]{64})/);
return m ? m[1] : null;
}
function isAuthed(req) {
const t = cookieOf(req);
if (!t) return false;
const exp = sessions.get(t);
if (!exp || exp < Date.now()) { sessions.delete(t); return false; }
sessions.set(t, Date.now() + SESSION_TTL_MS); // 滑动续期
return true;
}
function csrfOk(req) { return req.headers['x-requested-with'] === 'hulai-admin'; }
function readData() {
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
catch (e) { return null; }
}
function backupData() {
try {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
fs.copyFileSync(DATA_FILE, path.join(BACKUP_DIR, 'data-' + stamp + '.json'));
const files = fs.readdirSync(BACKUP_DIR).filter(f => f.startsWith('data-')).sort();
while (files.length > 50) { fs.unlinkSync(path.join(BACKUP_DIR, files.shift())); }
} catch (e) { /* 备份失败不阻断 */ }
}
function sanitizeFilename(name) {
let n = String(name || '').replace(/[\/]/g, '').replace(/\s+/g, '_').replace(/[^\w\u4e00-\u9fff.-]/g, '');
if (!n) n = 'img-' + Date.now() + '.png';
const ext = path.extname(n).toLowerCase();
if (!IMG_EXT.includes(ext)) n += '.png';
return n;
}
/* ---------- 静态服务 ---------- */
function serveStatic(req, res, urlPath) {
let p = decodeURIComponent(urlPath.split('?')[0]);
if (p === '/') return serveIndex(res);
if (p === '/index.html') return serveIndex(res);
// 防目录穿越
const file = path.normalize(path.join(ROOT, p));
if (!file.startsWith(ROOT + path.sep) && file !== path.join(ROOT, 'index.html')) {
return send(res, 403, { error: 'forbidden' });
}
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
// SPA 回退
if (!path.extname(p)) return serveIndex(res);
return send(res, 404, { error: 'not found' });
}
const ext = path.extname(file).toLowerCase();
const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream' };
const abs = p.startsWith('/assets/');
const isHotels = p.startsWith('/hotels/');
if (abs) headers['Cache-Control'] = 'public, max-age=31536000, immutable';
else if (isHotels || ext === '.json') headers['Cache-Control'] = 'no-cache';
else headers['Cache-Control'] = 'no-cache';
res.writeHead(200, headers);
fs.createReadStream(file).pipe(res);
}
function serveIndex(res) {
try {
let html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
const data = readData();
if (data) {
const json = JSON.stringify(data).replace(/</g, '\\u003c');
const script = '<script>window.__HULAI_DATA__=' + json + ';</script>';
html = html.replace('<script type="module"', script + '<script type="module"');
if (data.siteTitle) html = html.replace(/<title>[^<]*<\/title>/, '<title>' + data.siteTitle.replace(/</g, '&lt;') + '</title>');
}
send(res, 200, html, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
} catch (e) {
send(res, 500, { error: 'index error' });
}
}
/* ---------- API ---------- */
async function handleApi(req, res, urlPath) {
const method = req.method;
const p = urlPath.split('?')[0];
if (method === 'POST' && p === '/api/login') {
if (rateLimited(req.socket.remoteAddress || 'x')) return send(res, 429, { error: '尝试次数过多,请10分钟后再试' });
let body;
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8')); } catch (e) { return send(res, 400, { error: 'bad request' }); }
const given = sha256(String(body.password || ''));
const ok = crypto.timingSafeEqual(Buffer.from(given), Buffer.from(CONFIG.passwordHash));
if (!ok) return send(res, 401, { error: '密码错误' });
const token = safeToken();
sessions.set(token, Date.now() + SESSION_TTL_MS);
send(res, 200, { ok: true }, { 'Set-Cookie': 'hulai_token=' + token + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200' });
return;
}
if (method === 'POST' && p === '/api/logout') {
const t = cookieOf(req);
if (t) sessions.delete(t);
send(res, 200, { ok: true }, { 'Set-Cookie': 'hulai_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0' });
return;
}
if (method === 'GET' && p === '/api/me') {
return send(res, 200, { authed: isAuthed(req) });
}
if (method === 'GET' && p === '/api/data') {
const data = readData();
if (!data) return send(res, 500, { error: 'data.json 缺失或损坏' });
return send(res, 200, data);
}
// 以下全部需要登录
if (!isAuthed(req)) return send(res, 401, { error: '未登录或登录已过期' });
if (method !== 'GET' && !csrfOk(req)) return send(res, 403, { error: 'csrf' });
if (method === 'PUT' && p === '/api/data') {
let body;
try { body = JSON.parse((await readBody(req, MAX_BODY)).toString('utf8')); } catch (e) { return send(res, 400, { error: 'JSON 解析失败' }); }
if (!body || typeof body !== 'object' || typeof body.Am !== 'object' || typeof body.Kf !== 'object' || !Array.isArray(body.av)) {
return send(res, 400, { error: '数据结构不完整(需要 Am/Kf/av)' });
}
backupData();
const tmp = DATA_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(body, null, 2));
fs.renameSync(tmp, DATA_FILE);
return send(res, 200, { ok: true, savedAt: new Date().toISOString() });
}
if (method === 'POST' && p === '/api/upload') {
let body;
try { body = JSON.parse((await readBody(req, MAX_BODY)).toString('utf8')); } catch (e) { return send(res, 400, { error: 'bad request' }); }
if (!body || !body.data || !body.filename) return send(res, 400, { error: '缺少 data/filename' });
let buf;
try { buf = Buffer.from(String(body.data).replace(/^data:[^,]*,/, ''), 'base64'); } catch (e) { return send(res, 400, { error: '图片数据无效' }); }
if (buf.length === 0 || buf.length > 12 * 1024 * 1024) return send(res, 400, { error: '图片大小需在 0~12MB 之间' });
const name = sanitizeFilename(body.filename);
const dest = path.join(IMG_DIR, name);
fs.writeFileSync(dest, buf);
return send(res, 200, { ok: true, path: '/hotels/' + name });
}
if (method === 'POST' && p === '/api/delete-image') {
let body;
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8')); } catch (e) { return send(res, 400, { error: 'bad request' }); }
const rel = String(body.path || '');
if (!/^\/hotels\/[\w\u4e00-\u9fff.-]+\.(png|jpe?g|webp|gif)$/.test(rel)) return send(res, 400, { error: '路径不合法' });
const file = path.join(ROOT, rel);
if (fs.existsSync(file)) {
fs.mkdirSync(TRASH_DIR, { recursive: true });
fs.renameSync(file, path.join(TRASH_DIR, Date.now() + '-' + path.basename(file)));
}
return send(res, 200, { ok: true });
}
if (method === 'POST' && p === '/api/change-password') {
let body;
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8')); } catch (e) { return send(res, 400, { error: 'bad request' }); }
const old = String(body.old || '');
const next = String(body.next || '');
if (!old || !next) return send(res, 400, { error: '参数不完整' });
if (next.length < 6) return send(res, 400, { error: '新密码至少 6 位' });
const given = sha256(old);
let ok = false;
try { ok = crypto.timingSafeEqual(Buffer.from(given), Buffer.from(CONFIG.passwordHash)); } catch (e) { ok = false; }
if (!ok) return send(res, 401, { error: '当前密码不正确' });
CONFIG.passwordHash = sha256(next);
fs.writeFileSync(CONFIG_FILE, JSON.stringify(CONFIG, null, 2));
return send(res, 200, { ok: true });
}
if (method === 'GET' && p === '/api/backups') {
try {
const files = fs.readdirSync(BACKUP_DIR).filter(f => f.endsWith('.json')).sort().reverse();
return send(res, 200, { files: files.slice(0, 50) });
} catch (e) { return send(res, 200, { files: [] }); }
}
if (method === 'POST' && p === '/api/restore-backup') {
let body;
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8')); } catch (e) { return send(res, 400, { error: 'bad request' }); }
const name = String(body.file || '');
if (!/^data-[\w-]+\.json$/.test(name)) return send(res, 400, { error: '文件名不合法' });
const file = path.join(BACKUP_DIR, name);
if (!fs.existsSync(file)) return send(res, 404, { error: '备份不存在' });
backupData();
fs.copyFileSync(file, DATA_FILE);
return send(res, 200, { ok: true });
}
return send(res, 404, { error: 'not found' });
}
const server = http.createServer(async (req, res) => {
const urlPath = (req.url || '/').split('?')[0];
try {
if (urlPath.startsWith('/api/')) return await handleApi(req, res, urlPath);
return serveStatic(req, res, urlPath);
} catch (e) {
console.error(e);
try { send(res, 500, { error: 'server error' }); } catch (_) {}
}
});
fs.mkdirSync(BACKUP_DIR, { recursive: true });
fs.mkdirSync(TRASH_DIR, { recursive: true });
// 首次运行保存一份初始数据作为"恢复出厂"的底稿
if (!fs.existsSync(path.join(ROOT, 'data.seed.json')) && fs.existsSync(DATA_FILE)) {
fs.copyFileSync(DATA_FILE, path.join(ROOT, 'data.seed.json'));
}
if (!fs.existsSync(DATA_FILE)) {
console.error('[!] 未找到 data.json,请确认与 server.js 位于同一目录');
}
server.listen(PORT, () => {
console.log('==============================================');
console.log(' 呼籁旅行 - 甄选酒店 已启动');
console.log(' 访客页面: http://localhost:' + PORT + '/');
console.log(' 管理入口: http://localhost:' + PORT + '/admin.html');
console.log('==============================================');
});