- hotels/ 154 张 PNG(186MB) 转 q80 WebP(15MB),省 92%,原 PNG 保留作备份 - data.json/data.seed.json/assets JS 共 1548 处图片引用 .png→.webp - server.js: /hotels/ 图片 Cache-Control 改 public,max-age=3600(原一刀切 no-cache,每次访问全量重下) Co-Authored-By: Claude <noreply@anthropic.com>
317 行
13 KiB
JavaScript
317 行
13 KiB
JavaScript
/**
|
||
* 呼籁旅行 - 甄选酒店 · 站点服务(零依赖 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(/</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, '<') + '</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('==============================================');
|
||
});
|