完成网络音乐、工具页面与安装器体验升级

This commit is contained in:
2026-08-18 13:54:33 +08:00
parent 337390f53e
commit 149c28082d
1082 changed files with 26442 additions and 162838 deletions
+656
View File
@@ -0,0 +1,656 @@
/**
* @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 };