NapCatQQ/packages/napcat-onebot/network/plugin/router-registry.ts
手瓜一十雪 78ac36f670 Add plugin no-auth API routes and WebUI handling
Introduce support for plugin API routes that do not require WebUI authentication. Updates include:

- napcat-onebot: Add apiNoAuth route storage and helpers (apiNoAuth, getNoAuth, postNoAuth, putNoAuth, deleteNoAuth), hasApiNoAuthRoutes, buildApiNoAuthRouter, and clear handling in PluginRouterRegistryImpl.
- napcat-onebot types: Extend PluginRouterRegistry interface with no-auth API methods and document that authenticated APIs remain separate.
- napcat-webui-backend: Mount a new unauthenticated plugin route handler at /plugin/:pluginId/api that looks up the plugin router and dispatches requests to the plugin's no-auth router, returning appropriate errors when context or routes are missing.
- napcat-plugin-builtin: Add example no-auth endpoints (public/info and health) and update logger messages to reflect both auth and no-auth API paths.
- Bump napcat-types version to 0.0.16 and update napcat-plugin-builtin dependency accordingly.

These changes enable plugins to expose public endpoints (e.g. health checks or public metadata) under /plugin/{pluginId}/api/ while keeping existing authenticated APIs under /api/Plugin/ext/{pluginId}/.
2026-02-02 19:13:01 +08:00

316 lines
8.0 KiB
TypeScript
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.

import { Router, Request, Response, NextFunction } from 'express';
import path from 'path';
import {
PluginRouterRegistry,
PluginRequestHandler,
PluginApiRouteDefinition,
PluginPageDefinition,
PluginHttpRequest,
PluginHttpResponse,
HttpMethod,
MemoryStaticFile,
} from './types';
/**
* 包装 Express Request 为 PluginHttpRequest
*/
function wrapRequest (req: Request): PluginHttpRequest {
return {
path: req.path,
method: req.method,
query: req.query as Record<string, string | string[] | undefined>,
body: req.body,
headers: req.headers as Record<string, string | string[] | undefined>,
params: req.params,
raw: req,
};
}
/**
* 包装 Express Response 为 PluginHttpResponse
*/
function wrapResponse (res: Response): PluginHttpResponse {
const wrapped: PluginHttpResponse = {
status (code: number) {
res.status(code);
return wrapped;
},
json (data: unknown) {
res.json(data);
},
send (data: string | Buffer) {
res.send(data);
},
setHeader (name: string, value: string) {
res.setHeader(name, value);
return wrapped;
},
sendFile (filePath: string) {
res.sendFile(filePath);
},
redirect (url: string) {
res.redirect(url);
},
raw: res,
};
return wrapped;
}
/**
* 插件路由注册器实现
* 为每个插件创建独立的路由注册器,收集路由定义
*/
/** 内存静态路由定义 */
interface MemoryStaticRoute {
urlPath: string;
files: MemoryStaticFile[];
}
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
private apiRoutes: PluginApiRouteDefinition[] = [];
private apiNoAuthRoutes: PluginApiRouteDefinition[] = [];
private pageDefinitions: PluginPageDefinition[] = [];
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
private memoryStaticRoutes: MemoryStaticRoute[] = [];
constructor (
private readonly pluginId: string,
private readonly pluginPath: string
) { }
// ==================== API 路由注册 ====================
api (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
this.apiRoutes.push({ method, path: routePath, handler });
}
get (routePath: string, handler: PluginRequestHandler): void {
this.api('get', routePath, handler);
}
post (routePath: string, handler: PluginRequestHandler): void {
this.api('post', routePath, handler);
}
put (routePath: string, handler: PluginRequestHandler): void {
this.api('put', routePath, handler);
}
delete (routePath: string, handler: PluginRequestHandler): void {
this.api('delete', routePath, handler);
}
// ==================== 无认证 API 路由注册 ====================
apiNoAuth (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuthRoutes.push({ method, path: routePath, handler });
}
getNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('get', routePath, handler);
}
postNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('post', routePath, handler);
}
putNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('put', routePath, handler);
}
deleteNoAuth (routePath: string, handler: PluginRequestHandler): void {
this.apiNoAuth('delete', routePath, handler);
}
// ==================== 页面注册 ====================
page (pageDef: PluginPageDefinition): void {
this.pageDefinitions.push(pageDef);
}
pages (pageDefs: PluginPageDefinition[]): void {
this.pageDefinitions.push(...pageDefs);
}
// ==================== 静态资源 ====================
static (urlPath: string, localPath: string): void {
// 如果是相对路径,则相对于插件目录
const absolutePath = path.isAbsolute(localPath)
? localPath
: path.join(this.pluginPath, localPath);
this.staticRoutes.push({ urlPath, localPath: absolutePath });
}
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void {
this.memoryStaticRoutes.push({ urlPath, files });
}
// ==================== 构建路由 ====================
/**
* 构建 Express Router用于 API 路由)
* 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理
*/
buildApiRouter (): Router {
const router = Router();
// 注册 API 路由
for (const route of this.apiRoutes) {
const handler = this.wrapHandler(route.handler);
switch (route.method) {
case 'get':
router.get(route.path, handler);
break;
case 'post':
router.post(route.path, handler);
break;
case 'put':
router.put(route.path, handler);
break;
case 'delete':
router.delete(route.path, handler);
break;
case 'patch':
router.patch(route.path, handler);
break;
case 'all':
router.all(route.path, handler);
break;
}
}
return router;
}
/**
* 包装处理器,添加错误处理和请求/响应包装
*/
private wrapHandler (handler: PluginRequestHandler): (req: Request, res: Response, next: NextFunction) => void {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const wrappedReq = wrapRequest(req);
const wrappedRes = wrapResponse(res);
await handler(wrappedReq, wrappedRes, next);
} catch (error: any) {
console.error(`[Plugin: ${this.pluginId}] Route error:`, error);
if (!res.headersSent) {
res.status(500).json({
code: -1,
message: `Plugin error: ${error.message || 'Unknown error'}`,
});
}
}
};
}
// ==================== 查询方法 ====================
/**
* 检查是否有注册的 API 路由(需要认证)
*/
hasApiRoutes (): boolean {
return this.apiRoutes.length > 0;
}
/**
* 检查是否有注册的无认证 API 路由
*/
hasApiNoAuthRoutes (): boolean {
return this.apiNoAuthRoutes.length > 0;
}
/**
* 构建无认证 Express Router用于 /plugin/{pluginId}/api/ 路径)
*/
buildApiNoAuthRouter (): Router {
const router = Router();
for (const route of this.apiNoAuthRoutes) {
const handler = this.wrapHandler(route.handler);
switch (route.method) {
case 'get':
router.get(route.path, handler);
break;
case 'post':
router.post(route.path, handler);
break;
case 'put':
router.put(route.path, handler);
break;
case 'delete':
router.delete(route.path, handler);
break;
case 'patch':
router.patch(route.path, handler);
break;
case 'all':
router.all(route.path, handler);
break;
}
}
return router;
}
/**
* 检查是否有注册的静态资源路由
*/
hasStaticRoutes (): boolean {
return this.staticRoutes.length > 0 || this.memoryStaticRoutes.length > 0;
}
/**
* 检查是否有注册的页面
*/
hasPages (): boolean {
return this.pageDefinitions.length > 0;
}
/**
* 获取所有注册的页面定义
*/
getPages (): PluginPageDefinition[] {
return [...this.pageDefinitions];
}
/**
* 获取插件 ID
*/
getPluginId (): string {
return this.pluginId;
}
/**
* 获取插件路径
*/
getPluginPath (): string {
return this.pluginPath;
}
/**
* 获取所有注册的静态路由
*/
getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> {
return [...this.staticRoutes];
}
/**
* 获取所有注册的内存静态路由
*/
getMemoryStaticRoutes (): MemoryStaticRoute[] {
return [...this.memoryStaticRoutes];
}
/**
* 清空路由(用于插件卸载)
*/
clear (): void {
this.apiRoutes = [];
this.apiNoAuthRoutes = [];
this.pageDefinitions = [];
this.staticRoutes = [];
this.memoryStaticRoutes = [];
}
}