/** * 呼籁旅行 - 甄选酒店 · 站点服务(零依赖 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/'); // 缓存策略(2026-08-19 提速优化): // - 酒店图片(hotels/):内容大体稳定、单图曾达 2-3MB,是首屏慢的主因; // 给 1 小时缓存+过期重校验——回头客秒开,后台改图最多 1 小时后全网生效(不用 immutable 是怕改图不生效)。 // - assets/ 构建产物:data.json 注入在服务端,文件名哈希不随内容变,保守 no-cache。 // - data.json / index.html / 其余:内容常变,保持 no-cache 实时。 if (isHotels) headers['Cache-Control'] = 'public, max-age=3600, must-revalidate'; 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(/'; html = html.replace('