Files
YMhut-box-C-/third_party/MoeKoeMusic/api/server.js
T

657 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview KuGouMusic API 服务器核心模块
*
* 基于 Express 框架构建的 HTTP 服务,负责:
* - 动态扫描并加载 `module/` 目录下的所有 API 模块,自动注册路由
* - 处理 CORS 跨域请求
* - 解析请求 Cookie 并注入平台标识信息
* - 提供 2 分钟的响应缓存机制
* - 统一错误处理与响应返回
*
* @module server
* @requires node:fs
* @requires node:path
* @requires express
* @requires safe-decode-uri-component
* @requires dotenv
* @requires ./util/util
* @requires ./util/crypto
* @requires ./util/request
* @requires ./util/apicache
*/
const fs = require('node:fs');
const path = require('node:path');
const express = require('express');
const decode = require('safe-decode-uri-component');
const { cookieToJson, randomString, getGuid, calculateMid, generateWebGLHash, isUUIDv4 } = require('./util/util');
const { cryptoMd5 } = require('./util/crypto');
const { createRequest } = require('./util/request');
const dotenv = require('dotenv');
const apiCache = require('./util/apicache');
apiCache.options({ headerBlacklist: ['set-cookie'] });
const cache = apiCache.middleware;
const axios = require('axios');
const crypto = require('node:crypto');
const YMHUT_PROTOCOL_VERSION = 1;
const YMHUT_MOEKOE_COMMIT = '4581660209c9cdd07669e8bd5bcbe136b7d91e02';
const PUBLIC_CACHE_ROUTES = new Set([
'/search',
'/search/default',
'/search/hot',
'/search/lyric',
'/search/suggest',
'/rank/list',
'/rank/top',
'/top/album',
'/top/playlist',
'/top/song',
'/sheet/detail',
'/sheet/explore',
'/sheet/rank',
'/sheet/song',
'/sheet/tags',
'/album',
'/album/detail',
'/album/songs',
'/artist/albums',
'/artist/audios',
'/artist/detail',
'/artist/lists',
'/artist/videos',
'/audio',
'/images',
'/images/audio',
'/lyric',
'/video/detail',
]);
function constantTimeTokenEquals(actual, expected) {
const actualBytes = Buffer.from(typeof actual === 'string' ? actual : '', 'utf8');
const expectedBytes = Buffer.from(typeof expected === 'string' ? expected : '', 'utf8');
return actualBytes.length === expectedBytes.length && crypto.timingSafeEqual(actualBytes, expectedBytes);
}
function isAllowedKugouTarget(rawUrl) {
try {
const target = new URL(rawUrl);
const host = target.hostname.toLowerCase();
return target.protocol === 'https:' &&
!target.username &&
!target.password &&
(!target.port || target.port === '443') &&
(host === 'kugou.com' || host.endsWith('.kugou.com'));
} catch {
return false;
}
}
function filterProxyHeaders(headers) {
const blocked = new Set(['host', 'content-length', 'connection', 'x-ymhut-internal-token']);
const result = {};
for (const [name, value] of Object.entries(headers || {})) {
const normalized = String(name).toLowerCase();
if (!blocked.has(normalized) && typeof value === 'string' && value.length <= 8192) {
result[normalized] = value;
}
}
return result;
}
function isAnonymousPublicRequest(req) {
if (req.headers.authorization || req.query.cookie) return false;
const rawCookie = String(req.headers.cookie || '');
return !/(?:^|;\s*)(?:token|userid|vip_token|t1|MUSIC_U|MUSIC_A)=/i.test(rawCookie);
}
function originalRequestPath(req) {
try {
return new URL(req.originalUrl || req.url, 'http://127.0.0.1').pathname;
} catch {
return '';
}
}
/**
* @typedef {Object} ModuleDefinition
* @description API 模块的定义结构,由 {@link getModulesDefinitions} 生成
* @property {string} [identifier] - 模块标识符,取自文件名(去除 .js 后缀)
* @property {string} route - 该模块对应的 Express 路由路径
* @property {any} module - 模块导出的内容(require 加载时为模块对象,否则为文件路径)
*/
/**
* @typedef {Object} ExpressExtension
* @description 对 Express 实例的扩展,附加了 HTTP Server 引用
* @property {import('http').Server} [server] - 底层 HTTP 服务实例
*/
/**
* 全局唯一设备标识符(GUID
* 对随机生成的 GUID 字符串进行 MD5 哈希,作为默认的设备标识
* @type {string}
* @constant
*/
const guid = cryptoMd5(getGuid());
/**
* 随机生成的 10 位大写字符串,用作默认的开发设备标识(DEV ID)
* @type {string}
* @constant
*/
const serverDev = randomString(10).toUpperCase();
/**
* .env 环境变量配置文件路径
* 优先从项目根目录加载,如果文件存在则通过 dotenv 读取其中的环境变量
* @type {string}
*/
const envPath = path.join(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
// 静默加载 .env 文件中的环境变量到 process.envquiet 抑制加载日志
dotenv.config({ path: envPath, quiet: true });
}
/**
* 动态扫描指定目录,获取所有 API 模块的定义信息
*
* 扫描流程:
* 1. 读取 `modulesPath` 目录下的所有文件
* 2. 过滤出以 `.js` 结尾且不以 `_` 开头的文件(`_` 前缀的文件视为内部模块,跳过注册)
* 3. 对文件列表进行倒序排列(与 app.js 中的模块加载顺序保持一致)
* 4. 根据文件名生成路由路径:默认将文件名中的 `_` 替换为 `/`,如有 `specificRoute` 自定义映射则优先使用
* 5. 如果 `doRequire` 为 true,则通过 require 加载模块;否则仅返回文件路径
*
* @async
* @param {string} modulesPath - 模块目录的绝对路径(例如 `path.join(__dirname, 'module')`
* @param {Record<string, string>} specificRoute - 特定文件名到路由路径的自定义映射表
* - key 为文件名(如 `"album_new.js"`),value 为自定义路由(如 `"/album/create"`
* - 未在映射表中的文件将使用默认的路由生成规则
* @param {boolean} [doRequire=true] - 是否通过 require 加载模块
* - `true`:直接 require 模块文件,返回模块导出对象
* - `false`:仅返回模块文件的绝对路径字符串
* @returns {Promise<ModuleDefinition[]>} 模块定义数组,每个元素包含 identifier、route、module
*
* @example
* // 扫描 module 目录并加载所有模块
* const defs = await getModulesDefinitions(path.join(__dirname, 'module'), {});
*
* @example
* // 自定义部分模块的路由
* const defs = await getModulesDefinitions(
* path.join(__dirname, 'module'),
* { "album_new.js": "/album/create" }
* );
*/
async function getModulesDefinitions(modulesPath, specificRoute, doRequire = true) {
const files = await fs.promises.readdir(modulesPath);
/**
* 根据文件名解析出路由路径
* 优先使用 specificRoute 中的自定义映射,否则按默认规则生成:
* - 去掉 .js 后缀
* - 将下划线 _ 替换为斜杠 /
* - 加上 / 前缀
* 例如:`user_detail.js` → `/user/detail`
* @param {string} fileName - 文件名
* @returns {string} 路由路径
*/
const parseRoute = (fileName) =>
specificRoute && fileName in specificRoute ? specificRoute[fileName] : `/${fileName.replace(/\.(js)$/i, '').replace(/_/g, '/')}`;
return (
files
// 倒序排列,确保模块加载顺序与 app.js 中 readdirSync().reverse() 保持一致
.reverse()
// 仅保留 .js 结尾且不以 _ 开头的文件(跳过内部/私有模块)
.filter((fileName) => fileName.endsWith('.js') && !fileName.startsWith('_'))
.map((fileName) => {
// 模块标识符:取文件名的主体部分(去掉 .js 后缀)
const identifier = fileName.split('.').shift();
// 生成路由路径
const route = parseRoute(fileName);
// 模块文件的完整绝对路径
const modulePath = path.resolve(modulesPath, fileName);
// 根据 doRequire 决定是加载模块还是仅返回路径
const module = doRequire ? require(modulePath) : modulePath;
return { identifier, route, module };
})
);
}
/**
* 构建并配置 Express 应用实例
*
* 该函数完成以下工作:
* 1. 创建 Express 应用
* 2. 配置 CORS 跨域中间件(处理 OPTIONS 预检请求)
* 3. 配置自定义 Cookie 解析中间件
* 4. 注入平台标识 CookiePLATFORM、MID、GUID、DEV、MAC
* 5. 配置 JSON / URL-encoded 请求体解析
* 6. 挂载静态文件服务(public 目录和 docs 目录)
* 7. 配置 2 分钟的 API 响应缓存
* 8. 遍历所有模块定义,动态注册 Express 路由处理器
* - 每个路由处理器负责:合并请求参数、调用模块函数、处理 Cookie、返回响应
*
* @async
* @param {ModuleDefinition[]} [moduleDefs] - 可选的模块定义数组
* - 如果未提供,则自动调用 {@link getModulesDefinitions} 从 `module/` 目录扫描加载
* @returns {Promise<import('express').Express>} 配置完成的 Express 应用实例
*/
async function consturctServer(moduleDefs) {
const app = express();
const internalToken = process.env.YMHUT_INTERNAL_TOKEN;
if (internalToken) {
app.use((req, res, next) => {
if (!constantTimeTokenEquals(req.get('x-ymhut-internal-token'), internalToken)) {
res.status(401).json({ code: 401, message: 'Unauthorized' });
return;
}
next();
});
}
// 从环境变量读取允许的跨域来源,若未配置则回退到请求头中的 origin 或 '*'
const { CORS_ALLOW_ORIGIN } = process.env;
// 启用反向代理信任,确保 req.ip 能正确获取客户端真实 IP(如经过 Nginx 反代)
app.set('trust proxy', true);
/**
* ============================================================
* CORS 跨域资源共享中间件
* ============================================================
*
* 对非根路径且非静态文件的请求设置 CORS 响应头:
* - Access-Control-Allow-Credentials: 允许携带凭证(Cookie
* - Access-Control-Allow-Origin: 允许的来源域名
* - Access-Control-Allow-Headers: 允许的请求头
* - Access-Control-Allow-Methods: 允许的 HTTP 方法
* - Content-Type: 统一使用 UTF-8 编码的 JSON
*
* 对 OPTIONS 预检请求直接返回 204 No Content
*/
app.use((req, res, next) => {
if (req.path !== '/' && !req.path.includes('.')) {
res.set({
'Access-Control-Allow-Credentials': true,
'Access-Control-Allow-Origin': CORS_ALLOW_ORIGIN || req.headers.origin || '*',
'Access-Control-Allow-Headers': 'Authorization,X-Requested-With,Content-Type,Cache-Control',
'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
'Content-Type': 'application/json; charset=utf-8',
});
}
// OPTIONS 预检请求无需继续处理,直接返回 204
req.method === 'OPTIONS' ? res.status(204).end() : next();
});
/**
* ============================================================
* Cookie 解析中间件
* ============================================================
*
* 手动解析请求头中的 Cookie 字符串,将其转换为键值对对象并挂载到 req.cookies。
*
* 解析规则:
* - 按 `; ` 或末尾空格分隔多个 cookie 键值对
* - 每个键值对按第一个 `=` 分割为 key 和 value
* - key 和 value 均通过 safe-decode-uri-component 解码
* - 跳过无效的键值对(无 `=` 或 `=` 在末尾)
*/
app.use((req, _, next) => {
req.cookies = {};
(req.headers.cookie || '').split(/;\s+|(?<!\s)\s+$/g).forEach((pair) => {
const crack = pair.indexOf('=');
// 跳过无效的 cookie:没有 = 号,或 = 号在最后(无 value)
if (crack < 1 || crack === pair.length - 1) {
return;
}
req.cookies[decode(pair.slice(0, crack)).trim()] = decode(pair.slice(crack + 1)).trim();
});
next();
});
/**
* ============================================================
* 平台标识 Cookie 注入中间件
* ============================================================
*
* 自动向请求的 Cookie 对象中注入以下平台标识信息(仅在客户端未提供时补充):
* - KUGOU_API_PLATFORM: 平台类型(标准版/概念版 lite),来自环境变量 platform
* - KUGOU_API_MID: 设备 MID,由 GUID 通过 calculateMid 算法生成
* - KUGOU_API_GUID: 设备全局唯一标识符,优先使用环境变量 KUGOU_API_GUID,否则使用启动时生成的默认值
* - KUGOU_API_DEV: 开发设备标识符,优先使用环境变量 KUGOU_API_DEV
* - KUGOU_API_MAC: 设备 MAC 地址,默认为 '02:00:00:00:00:00'
*
* 同时通过 Set-Cookie 将这些值写回客户端,方便调试和后续请求自动携带。
* 根据请求协议(HTTP/HTTPS)决定 Cookie 的安全属性(SameSite=None; Secure)。
*/
app.use((req, res, next) => {
const cookies = req.cookies || {};
const isHttps = req.protocol === 'https';
// HTTPS 下设置 SameSite=None; Secure 以支持跨站 Cookie 传递
const cookieSuffix = isHttps ? '; PATH=/; SameSite=None; Secure' : '; PATH=/';
/**
* 确保指定的 cookie key 存在,如果不存在则自动写入
* @param {string} key - Cookie 键名
* @param {string} value - Cookie 默认值
*/
const ensureCookie = (key, value) => {
// 如果客户端已经提供了该 cookie,则跳过不覆盖
if (Object.prototype.hasOwnProperty.call(cookies, key)) return;
cookies[key] = String(value);
res.append('Set-Cookie', `${key}=${cookies[key]}${cookieSuffix}`);
};
// 获取 env guid
const env_guid = isUUIDv4(process.env.KUGOU_API_GUID) ? cryptoMd5(process.env.KUGOU_API_GUID) : process.env.KUGOU_API_GUID;
// 计算设备 MID(基于 GUID 的衍生标识)
const mid = calculateMid(env_guid ?? guid);
// 依次注入各平台标识 Cookie
ensureCookie('KUGOU_API_PLATFORM', process.env.platform);
ensureCookie('KUGOU_API_MID', mid);
ensureCookie('KUGOU_API_GUID', env_guid ?? guid);
ensureCookie('KUGOU_API_DEV', (process.env.KUGOU_API_DEV ?? serverDev).toUpperCase());
ensureCookie('KUGOU_API_MAC', (process.env.KUGOU_API_MAC ?? '02:00:00:00:00:00').toUpperCase());
ensureCookie('KUGOU_API_WEBGL', process.env.KUGOU_API_WEBGL ?? generateWebGLHash());
// 将注入后的 cookies 回写到 req 对象上,供后续中间件和路由处理器使用
req.cookies = cookies;
next();
});
/**
* ============================================================
* 请求体解析中间件
* ============================================================
*
* - express.json(): 解析 Content-Type 为 application/json 的请求体
* - express.urlencoded(): 解析 Content-Type 为 application/x-www-form-urlencoded 的请求体
* - extended: false 使用 querystring 库解析(不支持嵌套对象)
*/
app.use(express.json({ limit: '5mb' }));
app.use(express.urlencoded({ extended: false, limit: '5mb' }));
app.use(express.raw({ type: 'application/octet-stream', limit: '100mb' }));
app.get('/ymhut/health', (_, res) => {
res.json({
protocolVersion: YMHUT_PROTOCOL_VERSION,
commit: YMHUT_MOEKOE_COMMIT,
pid: process.pid,
status: 'ready',
});
});
app.post('/ymhut/shutdown', (req, res) => {
res.json({ ok: true });
setImmediate(() => app.emit('ymhut-shutdown'));
});
// Compatibility bridge for the existing signed provider adapter. It keeps every
// production KuGou network hop inside this authenticated loopback process.
app.post('/ymhut/proxy', async (req, res) => {
const timestamp = String(req.query.timestamp || '');
if (!/^\d+$/.test(timestamp)) {
res.status(400).json({ code: 400, message: 'A numeric timestamp is required.' });
return;
}
const payload = req.body || {};
if (!isAllowedKugouTarget(payload.url)) {
res.status(400).json({ code: 400, message: 'Target is not allowed.' });
return;
}
const method = String(payload.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'POST', 'PUT', 'DELETE'].includes(method)) {
res.status(405).json({ code: 405, message: 'Method is not allowed.' });
return;
}
try {
const response = await axios.request({
method,
url: payload.url,
headers: filterProxyHeaders(payload.headers),
data: payload.bodyBase64 ? Buffer.from(payload.bodyBase64, 'base64') : undefined,
responseType: 'arraybuffer',
timeout: 15000,
maxRedirects: 0,
validateStatus: () => true,
});
const responseHeaders = {};
for (const [name, value] of Object.entries(response.headers || {})) {
if (value !== undefined && !['connection', 'content-length', 'transfer-encoding'].includes(name.toLowerCase())) {
responseHeaders[name] = Array.isArray(value) ? value : String(value);
}
}
res.json({
status: response.status,
headers: responseHeaders,
bodyBase64: Buffer.from(response.data || []).toString('base64'),
});
} catch (error) {
const code = error && error.code === 'ECONNABORTED' ? 504 : 502;
res.status(code).json({ code, message: code === 504 ? 'Upstream timeout.' : 'Upstream unavailable.' });
}
});
/**
* ============================================================
* 静态文件服务
* ============================================================
*
* 将项目根目录下的 `public/` 目录作为静态资源目录,
* 可用于提供前端页面、图标等静态文件
*/
app.use(express.static(path.join(__dirname, 'public')));
/**
* API 文档静态服务
*
* 将 `docs/` 目录挂载到 `/docs` 路径下,
* 访问 /docs 可查看项目接口文档
*/
app.use('/docs', express.static(path.join(__dirname, 'docs')));
/**
* ============================================================
* API 响应缓存中间件
* ============================================================
*
* 使用 apicache 对成功的请求(statusCode === 200)进行 2 分钟的缓存。
* 相同 URL 在 2 分钟内只会向酷狗服务器发送一次请求。
*
* 绕过缓存的方式:在请求 URL 后附加不同的 timestamp 参数,
* 例如: /personal/fm?timestamp=1691256061923
*/
app.use(cache(
'2 minutes',
(req, res) => req.method === 'GET' &&
isAnonymousPublicRequest(req) &&
PUBLIC_CACHE_ROUTES.has(originalRequestPath(req)) &&
res.statusCode === 200,
{
appendKey: (req) => `${req.method}:${req.get('host') || 'loopback'}`,
}
));
/**
* ============================================================
* 动态路由注册
* ============================================================
*
* 如果未传入模块定义数组,则自动扫描 module/ 目录加载所有模块。
* 遍历每个模块定义,为其注册 Express 路由处理器。
*/
const moduleDefinitions = moduleDefs || (await getModulesDefinitions(path.join(__dirname, 'module'), {}));
for (const moduleDef of moduleDefinitions) {
/**
* 为每个 API 模块注册路由处理器
*
* 请求处理流程:
* 1. 解析并合并 Cookie(支持 query 和 body 中的 cookie 字符串)
* 2. 合并所有请求参数:query 参数、body 参数、Cookie
* 3. 提取 Authorization 请求头中的认证信息,合并到 cookie 中
* 4. 调用模块处理函数,传入合并后的参数和请求工厂函数
* 5. 处理模块返回的 Cookie(通过 Set-Cookie 写回客户端)
* 6. 返回模块的响应数据
* 7. 异常时记录日志并返回错误响应
*/
app.use(moduleDef.route, async (req, res) => {
// Step 1: 解析 query 和 body 中的 cookie 字符串为 JSON 对象
[req.query, req.body].forEach((item) => {
if (typeof item.cookie === 'string') {
item.cookie = cookieToJson(decode(item.cookie));
}
});
// Step 2: 从 query 中分离出 cookie 参数和其余参数
const { cookie, ...params } = req.query;
// Step 3: 构建统一的 query 对象
// - cookie: 合并请求 Cookie 和 query 中传入的 cookie 参数
// - params: query 中除 cookie 外的其余参数
// - body: 请求体(POST 数据)
const body = Buffer.isBuffer(req.body) ? { data: req.body } : req.body;
const query = Object.assign({}, { cookie: Object.assign({}, req.cookies, cookie) }, params, body);
// Step 4: 如果请求携带了 Authorization 头,将其解析为 Cookie 并合并
// 这样客户端可以通过 Authorization 头传递认证信息,例如: token=xxx;userid=xxx
const authHeader = req.headers['authorization'];
if (authHeader) {
query.cookie = {
...query.cookie,
...cookieToJson(authHeader),
};
}
try {
/**
* Step 5: 调用模块处理函数
*
* 传入两个参数:
* - query: 合并后的所有请求参数
* - 请求工厂函数: 接收请求配置,注入客户端 IP 后调用 createRequest 发起实际请求
*
* @see createRequest - 底层 HTTP 请求函数
*/
const moduleResponse = await moduleDef.module(query, (config) => {
// 获取客户端真实 IP(去除 IPv6-mapped IPv4 前缀)
let ip = req.ip;
if (ip.substring(0, 7) === '::ffff:') {
ip = ip.substring(7);
}
config.ip = ip;
return createRequest(config);
});
// 请求成功日志
if (process.env.YMHUT_QUIET !== '1') console.log('[OK]', req.path);
// Step 6: 处理模块返回的 Cookie
// 将模块设置的 Cookie 通过 Set-Cookie 响应头写回客户端
const cookies = moduleResponse.cookie;
if (!query.noCookie) {
if (Array.isArray(cookies) && cookies.length > 0) {
if (req.protocol === 'https') {
// HTTPS 协议下设置 SameSite=None; Secure 以解决 CORS 环境下的 Cookie 传递问题
res.append(
'Set-Cookie',
cookies.map((cookie) => {
return `${cookie}; PATH=/; SameSite=None; Secure`;
})
);
} else {
// HTTP 协议下仅设置 PATH
res.append(
'Set-Cookie',
cookies.map((cookie) => {
return `${cookie}; PATH=/`;
})
);
}
}
}
// Step 7: 返回模块的响应(包含 headers、status、body
res.header(moduleResponse.headers).status(moduleResponse.status).send(moduleResponse.body);
} catch (e) {
// 异常处理:模块内部错误会被封装为包含 status 和 body 的错误对象抛出
const moduleResponse = e;
// 错误日志
if (process.env.YMHUT_QUIET !== '1') console.log('[ERR]', req.path, moduleResponse.status);
// 如果错误对象没有 body,返回通用 404 响应
if (!moduleResponse.body) {
res.status(404).send({
code: 404,
data: null,
msg: 'Not Found',
});
return;
}
// 返回模块错误的响应体
res.header(moduleResponse.headers).status(moduleResponse.status).send(moduleResponse.body);
}
});
}
return app;
}
/**
* 启动 KuGouMusic API 服务
*
* 完整启动流程:
* 1. 从环境变量读取端口号(默认 3000)和主机地址(默认空字符串,即监听所有可用地址)
* 2. 调用 {@link consturctServer} 构建并配置 Express 应用
* 3. 在指定端口和主机上启动 HTTP 监听
* 4. 输出启动成功日志
* 5. 返回扩展后的 Express 实例(附带底层 server 引用)
*
* @async
* @returns {Promise<import('express').Express & ExpressExtension>} 扩展后的 Express 应用实例,
* 包含 `service` 属性指向底层 HTTP Server
*
* @example
* // 基本用法
* const app = await startService();
* // 服务已启动,可通过 app.service 访问底层 HTTP Server
*
* @example
* // 通过环境变量自定义端口和主机
* // PORT=4000 HOST=127.0.0.1 node index.js
*/
async function startService() {
// 读取端口号配置,默认为 3000
const port = Number(process.env.PORT || '3000');
// 读取主机地址配置,默认为空(监听所有网络接口)
const host = process.env.HOST || '';
// 构建 Express 应用(包含所有中间件和路由)
const app = await consturctServer();
/** @type {import('express').Express & ExpressExtension} */
const appExt = app;
// 启动 HTTP 服务并监听指定端口和主机
appExt.service = app.listen(port, host, () => {
console.log(`server running @ http://${host || 'localhost'}:${port}`);
});
return appExt;
}
module.exports = { startService, getModulesDefinitions, consturctServer };