v2.5 安全加固 + 权限体系 + 封面网络化
- 双密码(管理员/用户) + requireAdmin 角色隔离 - 登录失败按IP锁定 + 搜索缓存限流 - 封面远程URL优先+本地回退 - sessions.role / songs.cover_remote 运行时迁移
This commit is contained in:
1 parent
16df0a7d08
commit
f07fbbc21f
13 files changed
+236
-40
No files matched your search
+42
@@ -415,3 +415,45 @@ python test_enrich.py # 给 5 首补封面(已跑过,5/5 成功)
|
||||
- `login.html` 独立暗色主题(自己的 `:root`)。
|
||||
- `body` 字体栈、`--log-bg` 终端风格:保留。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 18. v2.5 安全加固 + 权限体系 + 封面网络化(已完成 2026-09-25)
|
||||
|
||||
> 老板提出 6 点问题,本轮解决其中 5 点(延迟调节因复杂度暂缓)。
|
||||
|
||||
### 18.1 双密码 + 角色权限(核心)
|
||||
- **config.js**:`ADMIN_PASSWORD`(兼容旧 `PANEL_PASSWORD`)/ `USER_PASSWORD`;新增 `LOGIN_MAX_FAILS`/`LOGIN_LOCK_MS`/`SEARCH_CACHE_TTL_MS`/`SEARCH_RATE_MAX`/`SEARCH_RATE_WINDOW_MS`。
|
||||
- **db.js**:`sessions` 表加 `role` 列;`songs` 表加 `cover_remote` 列。均带**运行时迁移**(PRAGMA table_info 检测后 ALTER)。
|
||||
- **auth.js**:重写。`verifyLogin` 返回 `"admin"|"user"|null`;`createSession(role)`;新增 `requireAdmin` 中间件、`getRole/isAdmin`;**按 IP 登录失败锁定**(默认 5 次锁 10 分钟,内存 Map)。
|
||||
- **routes/auth.js**:登录返回 `{ok, role}`;锁定返回 429;`/me` 返回 role。
|
||||
- **权限矩阵**:用户 = 管理员 − 上传 − 设置页。`/api/music/import` 加 `requireAdmin`;`/api/system/*` 整块 `requireAdmin`。其余接口(资源/流/播放列表/搜索/下载)两者一致。
|
||||
- **前端**:`init()` 先查 `/api/auth/me` 拿 role → `applyRoleUI()` 隐藏设置页 tab + 本地导入卡片;`loadSettings()` 非管理员直接 return。
|
||||
|
||||
### 18.2 搜索缓存 + 限流(防服务器 IP 被封)
|
||||
- **实测确认**:网易云搜索 API **无 CORS 头**(返回 text/plain),**无法下放到浏览器**,必须后端代理。
|
||||
- **对策**:`routes/music.js` 的 `/search` 加 ①内存缓存(同关键词 TTL 5 分钟内直接返回,上限 200 条 LRU);②按 IP 限流(默认 60 秒 20 次,超限 429)。命中缓存不消耗限流额度。
|
||||
|
||||
### 18.3 封面网络化(远程优先 + 本地回退)
|
||||
- **实测确认**:网易云图片 CDN 返回 `Access-Control-Allow-Origin: *` 且不校验 Referer,浏览器可直接加载。
|
||||
- **改动**:`enrichSong` 下载封面时**同时**存远程 URL(`cover_remote`)和本地(`cover`);API 返回 `albumImage`(远程优先)+ `coverLocal`(本地兜底)。
|
||||
- **前端**:`coverSrcAttr(item)` 生成 `<img src=远程 data-local=本地 onerror=回退>`;mini player / 收听页封面同样 onerror 回退。
|
||||
|
||||
### 18.4 刷新优化
|
||||
- init 时统一获取角色;SSE 事件保持增量刷新(`loadPlaylist`+`refreshLive` 节流)。
|
||||
|
||||
### 18.5 验证
|
||||
- 回归:**api 16/16、e2e 17/17、write 5/5** 全绿。
|
||||
- 新增功能手测(curl):
|
||||
- admin 登录 → `/api/system/cache` 200、`/logs` 200;user 登录 → 同接口 **403**,`/api/music/import` **403**,`/api/resources` 200、`/api/streams` 200。
|
||||
- `/api/auth/me` 正确返回 role。
|
||||
- 搜索同关键词第二次返回 `cached:true`。
|
||||
|
||||
### 18.6 启动方式
|
||||
- `start.cmd` 更新为:`set "ADMIN_PASSWORD=test123"` + `set "USER_PASSWORD=user123"` + `node index.js`。
|
||||
- 托管启动:`cmd /c "set ADMIN_PASSWORD=test123&& set USER_PASSWORD=user123&& node index.js"`(注意用 `&&` 且不加尾空格,否则密码带空格)。
|
||||
|
||||
### 18.7 未做
|
||||
- **播放延迟调节**(HLS_TIME 运行时热更新):老板明确暂缓。
|
||||
- 用户密码/管理员密码的 UI 设置界面(现走环境变量)。
|
||||
- 会话表已有 role 列,但登录失败锁定的 failMap 是内存态,重启清零(可接受)。
|
||||
+22
-2
@@ -35,6 +35,15 @@ function formatBytes(b) {
|
||||
if (b >= 1024) return (b / 1024).toFixed(1) + " KB";
|
||||
return b + " B";
|
||||
}
|
||||
// 封面:远程 URL 优先,失败自动回退本地
|
||||
function coverSrcAttr(item) {
|
||||
const remote = item.albumImage || "";
|
||||
const local = item.coverLocal || "";
|
||||
if (remote && local && remote !== local) {
|
||||
return `src="${esc(remote)}" data-local="${esc(local)}" onerror="this.onerror=null;this.src=this.dataset.local;"`;
|
||||
}
|
||||
return `src="${esc(remote || local)}"`;
|
||||
}
|
||||
|
||||
// ==================== API(统一带 cookie,401 跳登录) ====================
|
||||
async function api(url, options = {}) {
|
||||
@@ -49,6 +58,14 @@ async function api(url, options = {}) {
|
||||
if (!res.ok) throw new Error((data && data.error) || text || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
// 角色 UI:非管理员隐藏 设置页 + 本地导入
|
||||
function applyRoleUI(role) {
|
||||
const isAdmin = role === "admin";
|
||||
const tab = document.querySelector('.nav-tab[data-page="pageSettings"]');
|
||||
if (tab) tab.style.display = isAdmin ? "" : "none";
|
||||
const imp = document.getElementById("importCard");
|
||||
if (imp) imp.style.display = isAdmin ? "" : "none";
|
||||
}
|
||||
function post(url, body) {
|
||||
return api(url, {
|
||||
method: "POST",
|
||||
@@ -238,7 +255,7 @@ function reconcileList(container, items, keyOf, create, update) {
|
||||
// ==================== Resources ====================
|
||||
function renderResItem(item) {
|
||||
const cover = item.albumImage
|
||||
? `<img class="res-cover" src="${esc(item.albumImage)}" alt="" loading="lazy">`
|
||||
? `<img class="res-cover" ${coverSrcAttr(item)} alt="" loading="lazy">`
|
||||
: `<div class="res-cover-ph"><i class="bi bi-music-note"></i></div>`;
|
||||
const bg = item.albumImage
|
||||
? `<div class="res-bg" style="background-image:url('${esc(item.albumImage)}')"></div>`
|
||||
@@ -456,7 +473,7 @@ const mpExpand = document.getElementById("mpExpand");
|
||||
function showMiniPlayer(info) {
|
||||
mpName.textContent = info.name || "未知";
|
||||
mpArtist.textContent = info.artist || "--";
|
||||
if (info.albumImage) { mpCover.src = info.albumImage; mpCover.style.display = ""; }
|
||||
if (info.albumImage) { mpCover.onerror = info.coverLocal ? () => { mpCover.onerror = null; mpCover.src = info.coverLocal; } : null; mpCover.src = info.albumImage; mpCover.style.display = ""; }
|
||||
else mpCover.style.display = "none";
|
||||
miniPlayer.classList.add("show");
|
||||
document.body.classList.add("mini-player-visible");
|
||||
@@ -755,6 +772,7 @@ async function loadLiveCover() {
|
||||
const r = await api(`/api/streams/${currentStreamId}/playlist`);
|
||||
const cur = (r.list || []).find((x) => x.isCurrent) || (r.list || [])[0];
|
||||
if (cur && cur.albumImage) {
|
||||
liveCover.onerror = cur.coverLocal ? () => { liveCover.onerror = null; liveCover.src = cur.coverLocal; } : null;
|
||||
liveCover.src = cur.albumImage;
|
||||
liveCover.style.display = "";
|
||||
liveCoverPh.style.display = "none";
|
||||
@@ -824,6 +842,7 @@ livePlayBtn.addEventListener("click", toggleLive);
|
||||
function fmtMB(b) { return (b / 1048576).toFixed(1) + " MB"; }
|
||||
|
||||
async function loadSettings() {
|
||||
if (state.role !== "admin") return;
|
||||
// 缓存
|
||||
try {
|
||||
const c = await api("/api/system/cache");
|
||||
@@ -882,6 +901,7 @@ document.getElementById("logTail").addEventListener("change", loadLogs);
|
||||
|
||||
// ==================== Init ====================
|
||||
(async function init() {
|
||||
try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role); } catch (_) {}
|
||||
await loadStreams();
|
||||
loadLiveCover();
|
||||
await loadResources();
|
||||
|
||||
+77
-20
@@ -6,15 +6,43 @@ const store = require("./store");
|
||||
|
||||
const COOKIE_NAME = "mr_session";
|
||||
|
||||
// 密码来源:环境变量 PANEL_PASSWORD;未设置则首次启动随机生成并打印
|
||||
let panelPassword = config.PANEL_PASSWORD;
|
||||
let generated = false;
|
||||
if (!panelPassword) {
|
||||
panelPassword = crypto.randomBytes(6).toString("hex");
|
||||
generated = true;
|
||||
/* ==================== 密码来源 ==================== */
|
||||
// 管理员密码:ADMIN_PASSWORD(兼容旧 PANEL_PASSWORD);未设置则首次随机生成并打印
|
||||
// 用户密码:USER_PASSWORD;未设置则「无用户密码」——只有管理员能登录
|
||||
let adminPassword = config.ADMIN_PASSWORD;
|
||||
let userPassword = config.USER_PASSWORD;
|
||||
let adminGenerated = false;
|
||||
if (!adminPassword) {
|
||||
adminPassword = crypto.randomBytes(6).toString("hex");
|
||||
adminGenerated = true;
|
||||
}
|
||||
|
||||
/* ==================== 登录失败锁定(按 IP) ==================== */
|
||||
const failMap = new Map(); // ip -> { count, lockedUntil }
|
||||
|
||||
function isLocked(ip) {
|
||||
const rec = failMap.get(ip);
|
||||
if (!rec) return 0;
|
||||
if (rec.lockedUntil && rec.lockedUntil > Date.now()) {
|
||||
return Math.ceil((rec.lockedUntil - Date.now()) / 1000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function recordFail(ip) {
|
||||
const rec = failMap.get(ip) || { count: 0, lockedUntil: 0 };
|
||||
rec.count += 1;
|
||||
if (rec.count >= config.LOGIN_MAX_FAILS) {
|
||||
rec.lockedUntil = Date.now() + config.LOGIN_LOCK_MS;
|
||||
rec.count = 0;
|
||||
logger.warn(`[AUTH] IP ${ip} 登录失败次数过多,锁定 ${config.LOGIN_LOCK_MS / 60000} 分钟`);
|
||||
}
|
||||
failMap.set(ip, rec);
|
||||
}
|
||||
function clearFail(ip) {
|
||||
failMap.delete(ip);
|
||||
}
|
||||
|
||||
/* ==================== Cookie ==================== */
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
if (!header) return out;
|
||||
@@ -25,22 +53,29 @@ function parseCookies(header) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
/* ==================== 会话 ==================== */
|
||||
function createSession(role) {
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const expiresAt = Date.now() + config.SESSION_TTL_MS;
|
||||
store.createSession(token, expiresAt);
|
||||
return { token, expiresAt };
|
||||
store.createSession(token, role, expiresAt);
|
||||
return { token, expiresAt, role };
|
||||
}
|
||||
|
||||
function timingEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
if (ba.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
// 返回角色 "admin" | "user" | null
|
||||
function verifyLogin(password) {
|
||||
// 定长比较,防时序攻击
|
||||
const a = Buffer.from(String(password));
|
||||
const b = Buffer.from(panelPassword);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
if (!password) return null;
|
||||
if (timingEqual(password, adminPassword)) return "admin";
|
||||
if (userPassword && timingEqual(password, userPassword)) return "user";
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从请求里取有效会话
|
||||
function getValidSession(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const token = cookies[COOKIE_NAME];
|
||||
@@ -51,6 +86,13 @@ function getValidSession(req) {
|
||||
function isAuthed(req) {
|
||||
return !!getValidSession(req);
|
||||
}
|
||||
function getRole(req) {
|
||||
const s = getValidSession(req);
|
||||
return s ? s.role || "user" : null;
|
||||
}
|
||||
function isAdmin(req) {
|
||||
return getRole(req) === "admin";
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token, expiresAt) {
|
||||
const maxAge = Math.floor((expiresAt - Date.now()) / 1000);
|
||||
@@ -63,7 +105,7 @@ function clearSessionCookie(res) {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
|
||||
// 中间件:保护 /api(登录相关除外)与面板
|
||||
/* ==================== 中间件 ==================== */
|
||||
function requireAuth(req, res, next) {
|
||||
if (isAuthed(req)) return next();
|
||||
const isApi = (req.originalUrl || req.url || "").startsWith("/api/");
|
||||
@@ -71,6 +113,13 @@ function requireAuth(req, res, next) {
|
||||
return res.redirect("/login");
|
||||
}
|
||||
|
||||
// 需要管理员角色
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!isAuthed(req)) return res.status(401).json({ ok: false, error: "未登录" });
|
||||
if (!isAdmin(req)) return res.status(403).json({ ok: false, error: "需要管理员权限" });
|
||||
next();
|
||||
}
|
||||
|
||||
function logout(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const token = cookies[COOKIE_NAME];
|
||||
@@ -78,14 +127,16 @@ function logout(req) {
|
||||
}
|
||||
|
||||
function initPasswordLog() {
|
||||
if (generated) {
|
||||
if (adminGenerated) {
|
||||
logger.warn("========================================");
|
||||
logger.warn(`[AUTH] 未设置 PANEL_PASSWORD,已生成随机登录密码: ${panelPassword}`);
|
||||
logger.warn("[AUTH] 请用此密码登录面板,或在环境变量中设置 PANEL_PASSWORD 固定密码");
|
||||
logger.warn(`[AUTH] 未设置 ADMIN_PASSWORD,已生成随机管理员密码: ${adminPassword}`);
|
||||
logger.warn("[AUTH] 请用此密码登录面板,或在环境变量中设置 ADMIN_PASSWORD 固定密码");
|
||||
logger.warn("========================================");
|
||||
} else {
|
||||
logger.info("[AUTH] 已启用面板鉴权(PANEL_PASSWORD 已设置)");
|
||||
logger.info("[AUTH] 已启用管理员鉴权(ADMIN_PASSWORD 已设置)");
|
||||
}
|
||||
if (userPassword) logger.info("[AUTH] 已启用用户密码(USER_PASSWORD 已设置)");
|
||||
else logger.info("[AUTH] 未设置 USER_PASSWORD,当前仅管理员可登录");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -94,9 +145,15 @@ module.exports = {
|
||||
verifyLogin,
|
||||
getValidSession,
|
||||
isAuthed,
|
||||
getRole,
|
||||
isAdmin,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
logout,
|
||||
initPasswordLog,
|
||||
isLocked,
|
||||
recordFail,
|
||||
clearFail,
|
||||
};
|
||||
+7
-1
@@ -40,8 +40,14 @@ const config = {
|
||||
LOG_LEVEL: envStr("LOG_LEVEL", "info"),
|
||||
LOG_FILE: path.join(ROOT, "server.log"),
|
||||
|
||||
PANEL_PASSWORD: envStr("PANEL_PASSWORD", ""),
|
||||
ADMIN_PASSWORD: envStr("ADMIN_PASSWORD", envStr("PANEL_PASSWORD", "")),
|
||||
USER_PASSWORD: envStr("USER_PASSWORD", ""),
|
||||
SESSION_TTL_MS: envInt("SESSION_TTL_HOURS", 72) * 3600 * 1000,
|
||||
LOGIN_MAX_FAILS: envInt("LOGIN_MAX_FAILS", 5),
|
||||
LOGIN_LOCK_MS: envInt("LOGIN_LOCK_MIN", 10) * 60 * 1000,
|
||||
SEARCH_CACHE_TTL_MS: envInt("SEARCH_CACHE_TTL_SEC", 300) * 1000,
|
||||
SEARCH_RATE_MAX: envInt("SEARCH_RATE_MAX", 20),
|
||||
SEARCH_RATE_WINDOW_MS: envInt("SEARCH_RATE_WINDOW_SEC", 60) * 1000,
|
||||
};
|
||||
|
||||
config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000;
|
||||
|
||||
@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS songs (
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
cover TEXT NOT NULL DEFAULT '',
|
||||
cover_remote TEXT NOT NULL DEFAULT '',
|
||||
duration REAL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
heat INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -47,6 +48,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -61,6 +63,20 @@ function init() {
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("busy_timeout = 5000");
|
||||
db.exec(SCHEMA);
|
||||
|
||||
// 迁移:旧库 sessions 表补 role 列
|
||||
const sessCols = db.prepare("PRAGMA table_info(sessions)").all();
|
||||
if (!sessCols.some((c) => c.name === "role")) {
|
||||
db.exec("ALTER TABLE sessions ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
|
||||
logger.info("[DB] 迁移 sessions 表:新增 role 列");
|
||||
}
|
||||
|
||||
// 迁移:songs 表补 cover_remote 列(远程封面 URL,优先展示,省带宽)
|
||||
const songCols = db.prepare("PRAGMA table_info(songs)").all();
|
||||
if (!songCols.some((c) => c.name === "cover_remote")) {
|
||||
db.exec("ALTER TABLE songs ADD COLUMN cover_remote TEXT NOT NULL DEFAULT ''");
|
||||
logger.info("[DB] 迁移 songs 表:新增 cover_remote 列");
|
||||
}
|
||||
logger.info(`[DB] 已打开 ${config.DB_FILE} (WAL)`);
|
||||
|
||||
ensureDefaultStream();
|
||||
|
||||
@@ -184,6 +184,8 @@ async function enrichSong(file, opts = {}) {
|
||||
if (hit) {
|
||||
if (needArtist && hit.artist) fields.artist = hit.artist;
|
||||
if (needCover && hit.albumImage) {
|
||||
// 远程 URL 优先(省带宽),本地下载作兜底
|
||||
fields.cover_remote = hit.albumImage;
|
||||
const cover = await downloadCover(hit.albumImage, fileHash(file));
|
||||
if (cover) fields.cover = cover;
|
||||
}
|
||||
|
||||
+18
-5
@@ -6,15 +6,27 @@ const logger = require("../logger");
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/login", (req, res) => {
|
||||
const ip = req.ip || req.connection?.remoteAddress || "unknown";
|
||||
const locked = auth.isLocked(ip);
|
||||
if (locked > 0) {
|
||||
return res.status(429).json({ ok: false, error: `登录失败次数过多,请 ${locked} 秒后再试` });
|
||||
}
|
||||
|
||||
const { password } = req.body || {};
|
||||
if (!password) return res.status(400).json({ ok: false, error: "请输入密码" });
|
||||
if (!auth.verifyLogin(password)) {
|
||||
logger.warn(`[AUTH] 登录失败 from ${req.ip}`);
|
||||
|
||||
const role = auth.verifyLogin(password);
|
||||
if (!role) {
|
||||
auth.recordFail(ip);
|
||||
logger.warn(`[AUTH] 登录失败 from ${ip}`);
|
||||
return res.status(401).json({ ok: false, error: "密码错误" });
|
||||
}
|
||||
const { token, expiresAt } = auth.createSession();
|
||||
|
||||
auth.clearFail(ip);
|
||||
const { token, expiresAt } = auth.createSession(role);
|
||||
auth.setSessionCookie(res, token, expiresAt);
|
||||
res.json({ ok: true });
|
||||
logger.info(`[AUTH] 登录成功 (${role}) from ${ip}`);
|
||||
res.json({ ok: true, role });
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
@@ -24,7 +36,8 @@ router.post("/logout", (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/me", (req, res) => {
|
||||
res.json({ ok: true, authed: auth.isAuthed(req) });
|
||||
const role = auth.getRole(req);
|
||||
res.json({ ok: true, authed: !!role, role: role || null });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+37
-2
@@ -13,13 +13,48 @@ const { sanitizeFileName, isValidFileName, isAudioFile, fileHash, getDirSize } =
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* ==================== 搜索缓存 + 限流 ==================== */
|
||||
const searchCache = new Map(); // keyword -> { at, data }
|
||||
const searchRate = new Map(); // ip -> { count, resetAt }
|
||||
|
||||
function searchRateLimited(ip) {
|
||||
const now = Date.now();
|
||||
const rec = searchRate.get(ip);
|
||||
if (!rec || rec.resetAt < now) {
|
||||
searchRate.set(ip, { count: 1, resetAt: now + config.SEARCH_RATE_WINDOW_MS });
|
||||
return false;
|
||||
}
|
||||
rec.count += 1;
|
||||
return rec.count > config.SEARCH_RATE_MAX;
|
||||
}
|
||||
|
||||
/* ==================== 网易云搜索/详情 ==================== */
|
||||
router.get("/search", async (req, res) => {
|
||||
const { keyword } = req.query;
|
||||
if (!keyword || !String(keyword).trim())
|
||||
return res.status(400).json({ ok: false, error: "缺少搜索关键词" });
|
||||
const kw = String(keyword).trim();
|
||||
|
||||
// 命中缓存直接返回(不消耗限流额度)
|
||||
const cached = searchCache.get(kw);
|
||||
if (cached && Date.now() - cached.at < config.SEARCH_CACHE_TTL_MS) {
|
||||
return res.json({ ok: true, ...cached.data, cached: true });
|
||||
}
|
||||
|
||||
// 未命中 → 限流检查
|
||||
const ip = req.ip || req.connection?.remoteAddress || "unknown";
|
||||
if (searchRateLimited(ip)) {
|
||||
return res.status(429).json({ ok: false, error: "搜索过于频繁,请稍后再试" });
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await meta.searchNetEase(String(keyword).trim());
|
||||
const r = await meta.searchNetEase(kw);
|
||||
searchCache.set(kw, { at: Date.now(), data: r });
|
||||
// 缓存上限,防内存膨胀
|
||||
if (searchCache.size > 200) {
|
||||
const oldest = [...searchCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (oldest) searchCache.delete(oldest[0]);
|
||||
}
|
||||
res.json({ ok: true, ...r });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
@@ -106,7 +141,7 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
router.post("/import", upload.single("audio"), async (req, res) => {
|
||||
router.post("/import", require("../auth").requireAdmin, upload.single("audio"), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: "未收到文件" });
|
||||
const tmpPath = req.file.path;
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,8 @@ router.get("/", (req, res) => {
|
||||
file: s.file,
|
||||
title: s.name || s.file,
|
||||
artist: s.artist || "",
|
||||
albumImage: s.cover || "",
|
||||
albumImage: s.cover_remote || s.cover || "",
|
||||
coverLocal: s.cover || "",
|
||||
duration: s.duration || null,
|
||||
fileSize: s.file_size || 0,
|
||||
heat: s.heat || 0,
|
||||
|
||||
@@ -71,7 +71,8 @@ function snapshot(streamId) {
|
||||
file: r.file,
|
||||
title: r.name || r.file,
|
||||
artist: r.artist || "",
|
||||
albumImage: r.cover || "",
|
||||
albumImage: r.cover_remote || r.cover || "",
|
||||
coverLocal: r.cover || "",
|
||||
duration: r.duration || null,
|
||||
heat: r.heat || 0,
|
||||
cacheStatus: live.getStatus(r.file),
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ function createApp() {
|
||||
app.use("/api/music", auth.requireAuth, musicRoutes);
|
||||
app.use("/api/resources", auth.requireAuth, resourceRoutes);
|
||||
app.use("/api/streams", auth.requireAuth, streamRoutes);
|
||||
app.use("/api/system", auth.requireAuth, systemRoutes);
|
||||
app.use("/api/system", auth.requireAdmin, systemRoutes);
|
||||
|
||||
// 其余 /api 需要登录
|
||||
app.use("/api", auth.requireAuth);
|
||||
|
||||
+8
-6
@@ -18,11 +18,12 @@ function upsertSong(s) {
|
||||
const ex = getSongByFile(s.file);
|
||||
if (ex) {
|
||||
d.prepare(
|
||||
`UPDATE songs SET name=?, artist=?, cover=?, duration=?, file_size=? WHERE file=?`
|
||||
`UPDATE songs SET name=?, artist=?, cover=?, cover_remote=?, duration=?, file_size=? WHERE file=?`
|
||||
).run(
|
||||
s.name != null ? s.name : ex.name,
|
||||
s.artist != null ? s.artist : ex.artist,
|
||||
s.cover != null ? s.cover : ex.cover,
|
||||
s.cover_remote != null ? s.cover_remote : (ex.cover_remote || ""),
|
||||
s.duration != null ? s.duration : ex.duration,
|
||||
s.file_size != null ? s.file_size : ex.file_size,
|
||||
s.file
|
||||
@@ -30,13 +31,14 @@ function upsertSong(s) {
|
||||
return getSongByFile(s.file);
|
||||
}
|
||||
d.prepare(
|
||||
`INSERT INTO songs (file, name, artist, cover, duration, file_size, heat, cache_status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 'pending', ?)`
|
||||
`INSERT INTO songs (file, name, artist, cover, cover_remote, duration, file_size, heat, cache_status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'pending', ?)`
|
||||
).run(
|
||||
s.file,
|
||||
s.name || "",
|
||||
s.artist || "",
|
||||
s.cover || "",
|
||||
s.cover_remote || "",
|
||||
s.duration != null ? s.duration : null,
|
||||
s.file_size || 0,
|
||||
Date.now()
|
||||
@@ -135,10 +137,10 @@ function clearPlaylist(streamId) {
|
||||
}
|
||||
|
||||
/* ==================== sessions ==================== */
|
||||
function createSession(token, expiresAt) {
|
||||
function createSession(token, role, expiresAt) {
|
||||
getDb()
|
||||
.prepare("INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)")
|
||||
.run(token, Date.now(), expiresAt);
|
||||
.prepare("INSERT INTO sessions (token, role, created_at, expires_at) VALUES (?, ?, ?, ?)")
|
||||
.run(token, role || "user", Date.now(), expiresAt);
|
||||
}
|
||||
function getSession(token) {
|
||||
const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token);
|
||||
|
||||
Reference in new issue
Block a user