This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

1
server/packages/sdk/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist

View File

@@ -0,0 +1,63 @@
{
"name": "tailchat-server-sdk",
"version": "0.0.18",
"description": "",
"main": "dist/index.js",
"bin": {
"tailchat-runner": "./dist/runner/cli.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "echo \"Error: no test specified\" && exit 1",
"prepare": "tsc",
"release": "npm version patch && npm publish --registry https://registry.npmjs.com/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/msgbyte/tailchat.git"
},
"keywords": [
"msgbyte",
"moonrailgun",
"tailchat"
],
"author": "moonrailgun <moonrailgun@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/msgbyte/tailchat/issues"
},
"homepage": "https://github.com/msgbyte/tailchat#readme",
"devDependencies": {
"@types/jest": "^29.5.1",
"typescript": "^4.3.3"
},
"dependencies": {
"@fastify/busboy": "^1.1.0",
"@typegoose/typegoose": "9.3.1",
"accept-language": "^3.0.18",
"axios": "^1.3.3",
"body-parser": "^1.20.1",
"crc": "^3.8.0",
"dotenv": "^10.0.0",
"etag": "^1.8.1",
"fastest-validator": "^1.12.0",
"fresh": "^0.5.2",
"i18next": "^20.3.5",
"i18next-fs-backend": "^1.1.1",
"ioredis": "^4.27.6",
"isstream": "^0.1.2",
"kleur": "^4.1.4",
"lodash": "^4.17.21",
"minio": "^7.1.1",
"moleculer": "0.14.23",
"moleculer-db": "0.8.19",
"moleculer-repl": "^0.7.2",
"moment": "^2.29.1",
"mongodb": "4.2.1",
"mongoose": "6.1.1",
"path-to-regexp": "^6.2.1",
"ramda-adjunct": "^4.0.0",
"tailchat-types": "workspace:*"
}
}

View File

@@ -0,0 +1,9 @@
/**
* 系统用户id
*/
export const SYSTEM_USERID = '000000000000000000000000';
/**
* 配置项
*/
export const CONFIG_GATEWAY_AFTER_HOOK = '$gatewayAfterHooks';

View File

@@ -0,0 +1,2 @@
export * from './typegoose';
export * from './mongoose';

View File

@@ -0,0 +1 @@
export { Types, isValidObjectId } from 'mongoose';

View File

@@ -0,0 +1,10 @@
export {
getModelForClass,
prop,
modelOptions,
Severity,
index,
} from '@typegoose/typegoose';
export type { DocumentType, Ref, ReturnModelType } from '@typegoose/typegoose';
export { TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
export type { Base } from '@typegoose/typegoose/lib/defaultClasses';

View File

@@ -0,0 +1,68 @@
export { defaultBrokerConfig } from './runner/moleculer.config';
export { TcService } from './services/base';
export { TcBroker } from './services/broker';
export type { TcDbService } from './services/mixins/db.mixin';
export { TcMinioService } from './services/mixins/minio.mixin';
export type {
TcContext,
TcPureContext,
PureContext,
UserJWTPayload,
GroupBaseInfo,
PureServiceSchema,
PureService,
PanelFeature,
} from './services/types';
export { parseLanguageFromHead } from './services/lib/i18n/parser';
export { t } from './services/lib/i18n';
export { ApiGatewayMixin } from './services/lib/moleculer-web';
export * as ApiGatewayErrors from './services/lib/moleculer-web/errors';
export * from './services/lib/errors';
export { PERMISSION, allPermission } from './services/lib/role';
export { call } from './services/lib/call';
export {
config,
buildUploadUrl,
builtinAuthWhitelist,
checkEnvTrusty,
} from './services/lib/settings';
// struct
export type {
MessageStruct,
MessageReactionStruct,
MessageMetaStruct,
InboxStruct,
} from './structs/chat';
export type { BuiltinEventMap } from './structs/events';
export type {
GroupStruct,
GroupRoleStruct,
GroupPanelStruct,
} from './structs/group';
export { GroupPanelType } from './structs/group';
export { userType } from './structs/user';
export type { UserStruct, UserType, UserStructWithToken } from './structs/user';
// db
export * as db from './db';
// openapi
export * from './openapi';
export * from './const';
// other
export { Utils, Errors } from 'moleculer';
export type { BrokerOptions } from 'moleculer';
/**
* 统一处理未捕获的错误, 防止直接把应用打崩
* NOTICE: 未经测试
*/
process.on('unhandledRejection', (reason, promise) => {
console.error('unhandledRejection', reason);
});
process.on('uncaughtException', (error, origin) => {
console.error('uncaughtException', error);
});

View File

@@ -0,0 +1 @@
export { OAuthClient } from './oauth';

View File

@@ -0,0 +1,66 @@
import axios, { AxiosInstance } from 'axios';
/**
* 用于 Tailchat OAuth 信息集成的实例
*/
export class OAuthClient {
request: AxiosInstance;
constructor(
apiUrl: string,
private appId: string,
private appSecret: string
) {
this.request = axios.create({
baseURL: apiUrl,
transformRequest: [
function (data) {
let ret = '';
for (const it in data) {
ret +=
encodeURIComponent(it) + '=' + encodeURIComponent(data[it]) + '&';
}
ret = ret.substring(0, ret.lastIndexOf('&'));
return ret;
},
],
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
});
}
/**
* 根据获取到的code获取授权码
* @param code 从重定向获取到的临时code
* @param redirectUrl 重定向的地址
*/
async getToken(
code: string,
redirectUrl: string
): Promise<{
access_token: string;
expires_in: string;
id_token: string;
scope: string;
token_type: string;
}> {
const { data: tokenInfo } = await this.request.post('/open/token', {
client_id: this.appId,
client_secret: this.appSecret,
redirect_uri: redirectUrl,
code,
grant_type: 'authorization_code',
});
return tokenInfo;
}
async getUserInfo(accessToken: string): Promise<any> {
const { data: userInfo } = await this.request.post('/open/me', {
access_token: accessToken,
});
return userInfo;
}
}

View File

@@ -0,0 +1,4 @@
import { Runner } from 'moleculer';
const runner = new Runner();
runner.start(process.argv);

View File

@@ -0,0 +1,55 @@
import { Runner } from 'moleculer';
import path from 'path';
import cluster from 'cluster';
import { config } from '../services/lib/settings';
declare module 'moleculer' {
class Runner {
flags?: {
config?: string;
repl?: boolean;
hot?: boolean;
silent?: boolean;
env?: boolean;
envfile?: string;
instances?: number;
mask?: string;
};
servicePaths: string[];
start(args: any[]): void;
startWorkers(instances: number): void;
_run(): void;
}
}
interface DevRunnerOptions {
config?: string;
}
const isProd = config.env === 'production';
/**
* 开始一个启动器
*/
export function startDevRunner(options: DevRunnerOptions) {
const runner = new Runner();
runner.flags = {
hot: isProd ? false : true,
repl: isProd ? false : process.env.DISABLE_REPL ? false : true,
env: true,
config: options.config ?? path.resolve(__dirname, './moleculer.config.ts'),
};
runner.servicePaths = [
'services/**/*.service.ts',
'services/**/*.service.dev.ts', // load plugins in dev mode
'plugins/**/*.service.ts',
'plugins/**/*.service.dev.ts', // load plugins in dev mode
];
if (runner.flags.instances !== undefined && cluster.isPrimary) {
return runner.startWorkers(runner.flags.instances);
}
return runner._run();
}

View File

@@ -0,0 +1,312 @@
'use strict';
import type {
BrokerOptions,
CallingOptions,
Errors,
ServiceBroker,
} from 'moleculer';
import type { UserJWTPayload } from '../services/types';
import moment from 'moment';
import kleur from 'kleur';
import { config } from '../services/lib/settings';
import 'moleculer-repl';
/**
* Moleculer ServiceBroker configuration file
*
* More info about options:
* https://moleculer.services/docs/0.14/configuration.html
*
*
* Overwriting options in production:
* ================================
* You can overwrite any option with environment variables.
* For example to overwrite the "logLevel" value, use `LOGLEVEL=warn` env var.
* To overwrite a nested parameter, e.g. retryPolicy.retries, use `RETRYPOLICY_RETRIES=10` env var.
*
* To overwrite brokers deeply nested default options, which are not presented in "moleculer.config.js",
* use the `MOL_` prefix and double underscore `__` for nested properties in .env file.
* For example, to set the cacher prefix to `MYCACHE`, you should declare an env var as `MOL_CACHER__OPTIONS__PREFIX=mycache`.
* It will set this:
* {
* cacher: {
* options: {
* prefix: "mycache"
* }
* }
* }
*/
export const defaultBrokerConfig: BrokerOptions = {
// Namespace of nodes to segment your nodes on the same network.
namespace: 'tailchat',
// Unique node identifier. Must be unique in a namespace.
nodeID: undefined,
// Custom metadata store. Store here what you want. Accessing: `this.broker.metadata`
metadata: {},
// Enable/disable logging or use custom logger. More info: https://moleculer.services/docs/0.14/logging.html
// Available logger types: "Console", "File", "Pino", "Winston", "Bunyan", "debug", "Log4js", "Datadog"
logger: [
{
type: 'Console',
options: {
// Using colors on the output
colors: true,
// Print module names with different colors (like docker-compose for containers)
moduleColors: false,
// Line formatter. It can be "json", "short", "simple", "full", a `Function` or a template string like "{timestamp} {level} {nodeID}/{mod}: {msg}"
// formatter: 'full',
formatter(type, args, bindings, { printArgs }) {
return [
kleur.grey(`[${moment().format('YYYY-MM-DD HH:mm:ss')}]`),
`${this.levelColorStr[type]}`,
...printArgs(args),
];
},
// Custom object printer. If not defined, it uses the `util.inspect` method.
objectPrinter: null,
// Auto-padding the module name in order to messages begin at the same column.
autoPadding: false,
},
},
{
type: 'File',
options: {
level: {
GATEWAY: 'debug',
'**': false,
},
filename: 'gateway-{nodeID}.log',
},
},
{
type: 'File',
options: {
level: {
GATEWAY: false,
'**': 'debug',
},
filename: '{date}-{nodeID}.log',
},
},
],
// Default log level for built-in console logger. It can be overwritten in logger options above.
// Available values: trace, debug, info, warn, error, fatal
logLevel: 'info',
// Define transporter.
// More info: https://moleculer.services/docs/0.14/networking.html
// Note: During the development, you don't need to define it because all services will be loaded locally.
// In production you can set it via `TRANSPORTER=nats://localhost:4222` environment variable.
transporter: undefined, // "process.env.TRANSPORTER"
// Define a cacher.
// More info: https://moleculer.services/docs/0.14/caching.html
cacher: {
type: 'Redis',
options: {
// Prefix for keys
prefix: 'TC',
// Redis settings
redis: config.redisUrl,
},
},
// Define a serializer.
// Available values: "JSON", "Avro", "ProtoBuf", "MsgPack", "Notepack", "Thrift".
// More info: https://moleculer.services/docs/0.14/networking.html#Serialization
serializer: 'JSON',
// Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0
requestTimeout: config.runner.requestTimeout,
// Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry
retryPolicy: {
// Enable feature
enabled: false,
// Count of retries
retries: 5,
// First delay in milliseconds.
delay: 100,
// Maximum delay in milliseconds.
maxDelay: 1000,
// Backoff factor for delay. 2 means exponential backoff.
factor: 2,
// A function to check failed requests.
check: ((err: Errors.MoleculerError) => err && !!err.retryable) as any,
},
// Limit of calling level. If it reaches the limit, broker will throw an MaxCallLevelError error. (Infinite loop protection)
maxCallLevel: 100,
// Number of seconds to send heartbeat packet to other nodes.
heartbeatInterval: 10,
// Number of seconds to wait before setting node to unavailable status.
heartbeatTimeout: 30,
// Cloning the params of context if enabled. High performance impact, use it with caution!
contextParamsCloning: false,
// Tracking requests and waiting for running requests before shuting down. More info: https://moleculer.services/docs/0.14/context.html#Context-tracking
tracking: {
// Enable feature
enabled: false,
// Number of milliseconds to wait before shuting down the process.
shutdownTimeout: 5000,
},
// Disable built-in request & emit balancer. (Transporter must support it, as well.). More info: https://moleculer.services/docs/0.14/networking.html#Disabled-balancer
disableBalancer: false,
// Settings of Service Registry. More info: https://moleculer.services/docs/0.14/registry.html
registry: {
// Define balancing strategy. More info: https://moleculer.services/docs/0.14/balancing.html
// Available values: "RoundRobin", "Random", "CpuUsage", "Latency", "Shard"
strategy: 'RoundRobin',
// Enable local action call preferring. Always call the local action instance if available.
preferLocal: true,
},
// Settings of Circuit Breaker. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Circuit-Breaker
circuitBreaker: {
// Enable feature
enabled: false,
// Threshold value. 0.5 means that 50% should be failed for tripping.
threshold: 0.5,
// Minimum request count. Below it, CB does not trip.
minRequestCount: 20,
// Number of seconds for time window.
windowTime: 60,
// Number of milliseconds to switch from open to half-open state
halfOpenTime: 10 * 1000,
// A function to check failed requests.
check: ((err: Errors.MoleculerError) => err && err.code >= 500) as any,
},
// Settings of bulkhead feature. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Bulkhead
bulkhead: {
// Enable feature.
enabled: false,
// Maximum concurrent executions.
concurrency: 10,
// Maximum size of queue
maxQueueSize: 100,
},
// Enable action & event parameter validation. More info: https://moleculer.services/docs/0.14/validating.html
validator: true,
errorHandler: undefined,
// Enable/disable built-in metrics function. More info: https://moleculer.services/docs/0.14/metrics.html
metrics: {
enabled: config.enablePrometheus,
// Available built-in reporters: "Console", "CSV", "Event", "Prometheus", "Datadog", "StatsD"
reporter: [
{
type: 'Prometheus',
options: {
// HTTP port
port: 13030,
// HTTP URL path
path: '/metrics',
// Default labels which are appended to all metrics labels
defaultLabels: (registry) => ({
namespace: registry.broker.namespace,
nodeID: registry.broker.nodeID,
}),
},
},
],
},
// Enable built-in tracing function. More info: https://moleculer.services/docs/0.14/tracing.html
tracing: {
enabled: true,
// Available built-in exporters: "Console", "Datadog", "Event", "EventLegacy", "Jaeger", "Zipkin"
exporter: {
type: 'Console', // Console exporter is only for development!
options: {
// Custom logger
logger: null,
// Using colors
colors: true,
// Width of row
width: 100,
// Gauge width in the row
gaugeWidth: 40,
},
},
},
// Register custom middlewares
middlewares: [],
// Register custom REPL commands.
// Reference: https://moleculer.services/docs/0.14/moleculer-repl.html#Custom-commands
replDelimiter: 'tc $',
replCommands: [
{
// NOTICE: 这个方法不要在原始上下文中使用,会造成其他用户使用不正常(登录成功后会拦截所有的call函数)
command: 'login',
description: 'Auto login or register tailchat user for cli test',
options: [{ option: '-u, --username', description: 'Username' }],
alias: null,
allowUnknownOptions: null,
parse: null,
action(broker: ServiceBroker, args) {
const username = args.options.username ?? 'localtest';
const password = 'localtest';
console.log(`开始尝试登录测试账号 ${username}`);
return broker
.call('user.login', { username, password })
.catch((err) => {
if (err.name === 'EntityError') {
// 未注册
console.log('正在注册新的测试账号');
return broker.call('user.register', { username, password });
}
console.error('未知的错误:', err);
})
.then((user: any) => {
const token = user.token;
const userId = user._id;
const originCall = broker.call.bind(broker);
console.log('登录成功');
console.log('> userId:', userId);
console.log('> token:', token);
(broker.call as any) = function (
actionName: string,
params: unknown,
opts?: CallingOptions
): Promise<unknown> {
return originCall(actionName, params, {
...opts,
meta: {
...(opts.meta ?? {}),
user: {
_id: userId,
nickname: user.nickname,
email: user.email,
avatar: user.avatar,
} as UserJWTPayload,
token,
userId,
},
});
};
});
},
},
],
/*
// Called after broker created.
created : (broker: ServiceBroker): void => {},
// Called after broker started.
started: async (broker: ServiceBroker): Promise<void> => {},
stopped: async (broker: ServiceBroker): Promise<void> => {},
*/
};

View File

@@ -0,0 +1,582 @@
import {
ActionSchema,
CallingOptions,
Context,
LoggerInstance,
Service,
ServiceBroker,
ServiceDependency,
ServiceEvent,
ServiceHooks,
ServiceSchema,
WaitForServicesResult,
} from 'moleculer';
import { once } from 'lodash';
import { TcDbService } from './mixins/db.mixin';
import type { PanelFeature, PureContext, TcPureContext } from './types';
import type { TFunction } from 'i18next';
import { t } from './lib/i18n';
import type { ValidationRuleObject } from 'fastest-validator';
import type { BuiltinEventMap } from '../structs/events';
import { CONFIG_GATEWAY_AFTER_HOOK, SYSTEM_USERID } from '../const';
import _ from 'lodash';
import {
decodeNoConflictServiceNameKey,
encodeNoConflictServiceNameKey,
} from '../utils';
type ServiceActionHandler<T = any> = (
ctx: TcPureContext<any>
) => Promise<T> | T;
type ShortValidationRule =
| 'any'
| 'array'
| 'boolean'
| 'custom'
| 'date'
| 'email'
| 'enum'
| 'forbidden'
| 'function'
| 'number'
| 'object'
| 'string'
| 'url'
| 'uuid';
type ServiceActionSchema = Pick<
ActionSchema,
| 'name'
| 'rest'
| 'visibility'
| 'service'
| 'cache'
| 'tracing'
| 'bulkhead'
| 'circuitBreaker'
| 'retryPolicy'
| 'fallback'
| 'hooks'
> & {
params?: Record<
string,
ValidationRuleObject | ValidationRuleObject[] | ShortValidationRule
>;
disableSocket?: boolean;
};
/**
* 生成AfterHook唯一键
*/
function generateAfterHookKey(actionName: string, serviceName = '') {
if (serviceName) {
return encodeNoConflictServiceNameKey(
`${CONFIG_GATEWAY_AFTER_HOOK}.${serviceName}.${actionName}`
);
} else {
return encodeNoConflictServiceNameKey(
`${CONFIG_GATEWAY_AFTER_HOOK}.${actionName}`
);
}
}
interface TcServiceBroker extends ServiceBroker {
// 事件类型重写
emit<K extends string>(
eventName: K,
data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
groups?: string | string[]
): Promise<void>;
emit(eventName: string): Promise<void>;
broadcast<K extends string>(
eventName: K,
data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
groups?: string | string[]
): Promise<void>;
broadcast(eventName: string): Promise<void>;
}
/**
* TcService 微服务抽象基类
*/
export interface TcService extends Service {
broker: TcServiceBroker;
}
export abstract class TcService extends Service {
/**
* 服务名, 全局唯一
*/
abstract get serviceName(): string;
private _mixins: ServiceSchema['mixins'] = [];
private _actions: ServiceSchema['actions'] = {};
private _methods: ServiceSchema['methods'] = {};
private _settings: ServiceSchema['settings'] = {};
private _events: ServiceSchema['events'] = {};
/**
* 全局的配置中心
*/
public globalConfig: Record<string, any> = {};
private _generateAndParseSchema() {
this.parseServiceSchema({
name: this.serviceName,
mixins: this._mixins,
settings: this._settings,
actions: this._actions,
events: this._events,
started: this.onStart,
stopped: this.onStop,
hooks: this.buildHooks(),
});
}
constructor(broker: ServiceBroker) {
super(broker); // Skip 父级的 parseServiceSchema 方法
this.onInit(); // 初始化服务
this.initBuiltin(); // 初始化内部服务
this._generateAndParseSchema();
this.logger = this.buildLoggerWithPrefix(this.logger);
this.onInited(); // 初始化完毕
}
protected abstract onInit(): void;
protected onInited() {}
protected async onStart() {}
protected async onStop() {}
protected initBuiltin() {
this.registerEventListener('config.updated', (payload) => {
this.logger.info('Update global config with:', payload.config);
if (payload.config) {
this.globalConfig = {
...payload.config,
};
}
});
}
/**
* 构建内部hooks
*/
protected buildHooks(): ServiceHooks {
return {
after: _.mapValues(this._actions, (action, name) => {
return (ctx: PureContext, res: unknown) => {
try {
const afterHooks =
this.globalConfig[generateAfterHookKey(name, this.serviceName)];
if (Array.isArray(afterHooks) && afterHooks.length > 0) {
for (const action of afterHooks) {
// 异步调用, 暂时不修改值
ctx.call(String(action), ctx.params, {
meta: {
...ctx.meta,
actionResult: res,
},
});
}
}
} catch (err) {
this.logger.error('Call action after hooks error:', err);
}
return res;
};
}),
};
}
/**
* 获取服务操作列表
*/
getActionList() {
return Object.entries(this._actions).map(
([name, schema]: [string, ServiceActionSchema]) => {
return {
name,
params: _.mapValues(schema.params, (type) => {
if (typeof type === 'string') {
return { type: type };
} else {
return type;
}
}),
};
}
);
}
registerMixin(mixin: Partial<ServiceSchema>): void {
this._mixins.push(mixin);
}
/**
* 注册微服务绑定的数据库
* 不能调用多次
*/
registerLocalDb = once((model) => {
this.registerMixin(TcDbService(model));
});
/**
* 注册数据表可见字段列表
* @param fields 字段列表
*/
registerDbField(fields: string[]) {
this.registerSetting('fields', fields);
}
/**
* 注册一个操作
*
* 该操作会同时生成http请求和socketio事件的处理
* @param name 操作名, 需微服务内唯一
* @param handler 处理方法
* @returns
*/
registerAction(
name: string,
handler: ServiceActionHandler,
schema?: ServiceActionSchema
) {
if (this._actions[name]) {
console.warn(`重复注册操作: ${name}。操作被跳过...`);
return;
}
this._actions[name] = {
...schema,
handler(
this: Service,
ctx: Context<unknown, { language: string; t: TFunction }>
) {
// 调用时生成t函数
ctx.meta.t = (key: string, defaultValue?: string | object) => {
if (typeof defaultValue === 'object') {
// 如果是参数对象的话
return t(key, {
...defaultValue,
lng: ctx.meta.language,
});
}
return t(key, defaultValue, {
lng: ctx.meta.language,
});
};
return handler.call(this, ctx);
},
};
}
/**
* 注册一个内部方法
*/
registerMethod(name: string, method: (...args: any[]) => any) {
if (this._methods[name]) {
console.warn(`重复注册方法: ${name}。操作被跳过...`);
return;
}
this._methods[name] = method;
}
/**
* 注册一个配置项
*/
registerSetting(key: string, value: unknown): void {
if (this._settings[key]) {
console.warn(`重复注册配置: ${key}。之前的设置将被覆盖...`);
}
this._settings[key] = value;
}
/**
* 注册一个事件监听器
*/
registerEventListener<K extends string>(
eventName: K,
handler: (
payload: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
ctx: TcPureContext
) => void,
options: Omit<ServiceEvent, 'handler'> = {}
) {
this._events[eventName] = {
...options,
handler: (ctx: TcPureContext<any>) => {
handler(ctx.params, ctx);
},
};
}
/**
* 注册跳过token鉴权的路由地址
* @param urls 鉴权路由 会自动添加 serviceName 前缀
* @example "/login"
*/
registerAuthWhitelist(urls: string[]) {
this.waitForServices('gateway').then(() => {
this.broker.broadcast(
'gateway.auth.addWhitelists',
{
urls: urls.map((url) => `/${this.serviceName}${url}`),
},
'gateway'
);
});
}
/**
* 注册可用的action请求
*
* 传入检查函数, 函数的返回值作为结果
*/
registerAvailableAction(checkFn: () => boolean) {
this.registerAction('available', checkFn);
this.registerAuthWhitelist(['/available']);
}
/**
* 注册面板功能特性,用于在服务端基础设施开放部分能力
* @param panelFeature 面板功能
*/
async setPanelFeature(panelName: string, panelFeatures: PanelFeature[]) {
await this.setGlobalConfig(
`panelFeature.${encodeNoConflictServiceNameKey(panelName)}`,
panelFeatures
);
}
/**
* 获取拥有某些特性的面板列表
* @param panelFeature 面板功能
*/
getPanelNamesWithFeature(panelFeature: PanelFeature) {
const map =
this.getGlobalConfig<Record<string, PanelFeature[]>>('panelFeature') ??
{};
const matched = Object.entries(map).filter(([name, features]) => {
if (Array.isArray(features)) {
return features.includes(panelFeature);
}
return false;
});
return matched.map((m) => decodeNoConflictServiceNameKey(m[0]));
}
/**
* 等待微服务启动
* @param serviceNames
* @param timeout
* @param interval
* @param logger
* @returns
*/
waitForServices(
serviceNames: string | Array<string> | Array<ServiceDependency>,
timeout?: number,
interval?: number,
logger?: LoggerInstance
): Promise<WaitForServicesResult> {
if (process.env.NODE_ENV === 'test') {
// 测试环境中跳过
return Promise.resolve({
services: [],
statuses: [],
});
}
return super.waitForServices(serviceNames, timeout, interval, logger);
}
getGlobalConfig<T = any>(key: string): T {
return _.get(this.globalConfig, key);
}
/**
* 设置全局配置信息
*/
async setGlobalConfig(key: string, value: any): Promise<void> {
await this.waitForServices('config');
await this.broker.call('config.set', {
key,
value,
});
}
/**
* 注册一个触发了action后的回调
* @param fullActionName 完整的带servicename的action名
* @param callbackAction 当前服务的action名不需要带servicename
*/
async registerAfterActionHook(
fullActionName: string,
callbackAction: string
) {
await this.waitForServices(['gateway', 'config']);
await this.broker.call('config.addToSet', {
key: encodeNoConflictServiceNameKey(
`${CONFIG_GATEWAY_AFTER_HOOK}.${fullActionName}`
),
value: `${this.serviceName}.${callbackAction}`,
});
}
/**
* 清理action缓存
* NOTICE: 这里使用Redis作为缓存管理器因此不需要通知所有的service
*/
async cleanActionCache(actionName: string, keys: string[] = []) {
if (!this.broker.cacher) {
console.error('Can not clean cache because no cacher existed.');
}
if (keys.length === 0) {
await this.broker.cacher.clean(`${this.serviceName}.${actionName}`);
} else {
await this.broker.cacher.clean(
`${this.serviceName}.${actionName}:${keys.join('|')}**`
);
}
}
/**
* 生成一个有命名空间的通知事件名
*/
protected generateNotifyEventName(eventName: string) {
return `notify:${this.serviceName}.${eventName}`;
}
/**
* 本地调用操作,不经过外部转发
* @param actionName 不需要serverName前缀
*/
protected localCall(
actionName: string,
params?: {},
opts?: CallingOptions
): Promise<any> {
return this.actions[actionName](params, opts);
}
protected systemCall<T>(
ctx: PureContext,
actionName: string,
params?: {},
opts?: CallingOptions
): Promise<T> {
return ctx.call(actionName, params, {
...opts,
meta: {
userId: SYSTEM_USERID,
...(opts?.meta ?? {}),
},
});
}
private buildLoggerWithPrefix(_originLogger: LoggerInstance) {
const prefix = `[${this.serviceName}]`;
const originLogger = _originLogger;
return {
info: (...args: any[]) => {
originLogger.info(prefix, ...args);
},
fatal: (...args: any[]) => {
originLogger.fatal(prefix, ...args);
},
error: (...args: any[]) => {
originLogger.error(prefix, ...args);
},
warn: (...args: any[]) => {
originLogger.warn(prefix, ...args);
},
debug: (...args: any[]) => {
originLogger.debug(prefix, ...args);
},
trace: (...args: any[]) => {
originLogger.trace(prefix, ...args);
},
};
}
/**
* 单播推送socket事件
*/
unicastNotify(
ctx: TcPureContext,
userId: string,
eventName: string,
eventData: unknown
): Promise<void> {
return ctx.call('gateway.notify', {
type: 'unicast',
target: userId,
eventName: this.generateNotifyEventName(eventName),
eventData,
});
}
/**
* 列播推送socket事件
*/
listcastNotify(
ctx: TcPureContext,
userIds: string[],
eventName: string,
eventData: unknown
) {
return ctx.call('gateway.notify', {
type: 'listcast',
target: userIds,
eventName: this.generateNotifyEventName(eventName),
eventData,
});
}
/**
* 组播推送socket事件
*/
roomcastNotify(
ctx: TcPureContext,
roomId: string,
eventName: string,
eventData: unknown
): Promise<void> {
return ctx.call('gateway.notify', {
type: 'roomcast',
target: roomId,
eventName: this.generateNotifyEventName(eventName),
eventData,
});
}
/**
* 群播推送socket事件
*/
broadcastNotify(
ctx: TcPureContext,
eventName: string,
eventData: unknown
): Promise<void> {
return ctx.call('gateway.notify', {
type: 'broadcast',
eventName: this.generateNotifyEventName(eventName),
eventData,
});
}
}

View File

@@ -0,0 +1,8 @@
import Moleculer from 'moleculer';
/**
* 用于不暴露moleculer让外部手动启动一个broker
*
* 如tailchat-cli
*/
export class TcBroker extends Moleculer.ServiceBroker {}

View File

@@ -0,0 +1,171 @@
import {
GroupStruct,
UserStruct,
SYSTEM_USERID,
PERMISSION,
TcPureContext,
} from '../../index';
import type { ChatConverseStruct } from '../../structs/chat';
export function call(ctx: TcPureContext) {
return {
/**
* 加入socketio房间
*/
async joinSocketIORoom(roomIds: string[], userId?: string) {
await ctx.call('gateway.joinRoom', {
roomIds,
userId,
});
},
/**
* 离开socketio房间
*/
async leaveSocketIORoom(roomIds: string[], userId?: string) {
await ctx.call('gateway.leaveRoom', {
roomIds,
userId,
});
},
/**
* 检查用户是否在线
*/
async isUserOnline(userIds: string[]): Promise<boolean[]> {
return await ctx.call('gateway.checkUserOnline', { userIds });
},
/**
* 发送系统消息
* 如果为群组消息则需要增加groupId
*/
async sendSystemMessage(
message: string,
converseId: string,
groupId?: string
) {
await ctx.call(
'chat.message.sendMessage',
{
converseId,
groupId,
content: message,
},
{
meta: {
...ctx.meta,
userId: SYSTEM_USERID,
},
}
);
},
/**
* 获取群组大厅会话的id
*/
async getGroupLobbyConverseId(groupId: string): Promise<string | null> {
const lobbyConverseId: string = await ctx.call(
'group.getGroupLobbyConverseId',
{
groupId,
}
);
return lobbyConverseId;
},
/**
* 添加群组系统信息
*/
async addGroupSystemMessage(groupId: string, message: string) {
const lobbyConverseId = await call(ctx).getGroupLobbyConverseId(groupId);
if (!lobbyConverseId) {
// 如果没有文本频道则跳过
return;
}
await ctx.call(
'chat.message.sendMessage',
{
converseId: lobbyConverseId,
groupId: groupId,
content: message,
},
{
meta: {
...ctx.meta,
userId: SYSTEM_USERID,
},
}
);
},
/**
* 获取用户信息
*/
async getUserInfo(userId: string): Promise<UserStruct | null> {
return await ctx.call('user.getUserInfo', {
userId: String(userId),
});
},
/**
* 获取会话信息
*/
async getConverseInfo(
converseId: string
): Promise<ChatConverseStruct | null> {
return await ctx.call('chat.converse.findConverseInfo', {
converseId,
});
},
/**
* 获取群组信息
*/
async getGroupInfo(groupId: string): Promise<GroupStruct | null> {
return await ctx.call('group.getGroupInfo', {
groupId,
});
},
/**
* 检查群组成员权限
*/
async checkUserPermissions(
groupId: string,
userId: string,
permissions: string[]
): Promise<boolean[]> {
const userAllPermissions: string[] = await ctx.call(
'group.getUserAllPermissions',
{
groupId,
userId,
}
);
const hasOwnerPermission = userAllPermissions.includes(
PERMISSION.core.owner
);
return permissions.map((p) =>
hasOwnerPermission
? true // 如果有管理员权限。直接返回true
: (userAllPermissions ?? []).includes(p)
);
},
/**
* 添加到收件箱
* @param type 如果是插件则命名规范为包名加信息名,如: plugin:com.msgbyte.topic
* @param payload 内容体,相关的逻辑由前端处理
* @param userId 如果是添加到当前用户则userId可以不填
*/
async appendInbox(
type: string,
payload: any,
userId?: string
): Promise<boolean> {
return await ctx.call('chat.inbox.append', {
userId,
type,
payload,
});
},
};
}

View File

@@ -0,0 +1,62 @@
import ExtendableError from 'es6-error';
class TcError extends ExtendableError {
public code: number;
public type: string;
public data: any;
public retryable: boolean;
constructor(message?: string, code?: number, type?: string, data?: unknown) {
super(message ?? 'Service Unavailable');
this.code = code ?? this.code ?? 500;
this.type = type ?? this.type;
this.data = data ?? this.data;
this.retryable = this.retryable ?? false;
}
}
export class DataNotFoundError extends TcError {
constructor(message?: string, code?: number, type?: string, data?: unknown) {
super(message ?? 'Not found Data', code ?? 404, type, data);
}
}
export class EntityError extends TcError {
constructor(
message?: string,
code?: number,
type?: string,
data?: { field: string; message: string }[]
) {
super(message ?? 'Form error', code ?? 442, type, data);
}
}
export class NoPermissionError extends TcError {
constructor(message?: string, code?: number, type?: string, data?: unknown) {
super(message ?? 'No operate permission', code ?? 403, type, data);
}
}
export class BannedError extends TcError {
constructor(message?: string, code?: number, type?: string, data?: unknown) {
super(
message ?? 'You has been banned',
code ?? 403,
type ?? 'banned',
data
);
}
}
export class ServiceUnavailableError extends TcError {
constructor(data?: unknown) {
super('Service unavailable', 503, 'SERVICE_NOT_AVAILABLE', data);
}
}
export class NotFoundError extends TcError {
constructor(data?: unknown) {
super('Not found', 404, 'NOT_FOUND', data);
}
}

View File

@@ -0,0 +1,24 @@
import { t } from '../index';
/**
* 休眠一定时间
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) =>
setTimeout(() => {
resolve();
}, ms)
);
}
describe('i18n', () => {
test('should be work', async () => {
await sleep(2000); // 等待异步加载完毕
expect(t('Token不合规')).toBe('Token不合规');
expect(
t('Token不合规', undefined, {
lng: 'en-US',
})
).toBe('Token Invalid');
});
});

View File

@@ -0,0 +1,23 @@
import { parseLanguageFromHead } from '../parser';
describe('parseLanguageFromHead', () => {
test.each([
// zh
['zh-CN,zh;q=0.9', 'zh-CN'],
['zh-TW,zh;q=0.9', 'zh-CN'],
['zh;q=0.9', 'zh-CN'],
['zh', 'zh-CN'],
// en
['en-US,en;q=0.8,sv', 'en-US'],
['en-GB,en;q=0.8,sv', 'en-US'],
['en;q=0.8,sv', 'en-US'],
['en', 'en-US'],
// other
['de-CH;q=0.8,sv', 'en-US'],
['jp', 'en-US'],
])('%s', (input, output) => {
expect(parseLanguageFromHead(input)).toBe(output);
});
});

View File

@@ -0,0 +1,41 @@
import i18next, { TFunction, TOptionsBase } from 'i18next';
import Backend from 'i18next-fs-backend';
import path from 'path';
import { crc32 } from 'crc';
i18next.use(Backend).init({
// initImmediate: false,
lng: 'en-US',
fallbackLng: 'en-US',
preload: ['zh-CN', 'en-US'],
ns: ['translation'],
defaultNS: 'translation',
backend: {
/**
* 加载启动目录下的
*/
loadPath: path.resolve(process.cwd(), './locales/{{lng}}/{{ns}}.json'),
},
});
/**
* 国际化翻译
*/
export const t: TFunction = (
key: string,
defaultValue?: string,
options?: TOptionsBase
) => {
try {
const hashKey = `k${crc32(key).toString(16)}`;
let words = i18next.t(hashKey, defaultValue, options);
if (words === hashKey) {
words = key;
console.info(`[i18n] 翻译缺失: [${hashKey}]${key}`);
}
return words;
} catch (err) {
console.error(err);
return key;
}
};

View File

@@ -0,0 +1,23 @@
import acceptLanguage from 'accept-language';
type AllowedLanguage = 'en-US' | 'zh-CN';
acceptLanguage.languages(['en', 'en-US', 'zh-CN', 'zh', 'zh-TW']);
/**
* 解析请求头的 Accept-Language
*/
export function parseLanguageFromHead(
headerLanguage = 'en-US'
): AllowedLanguage {
const language = acceptLanguage.get(headerLanguage);
if (language === 'zh' || language === 'zh-TW') {
return 'zh-CN';
}
if (language === 'en' || language === 'en-US') {
return 'en-US';
}
return language as AllowedLanguage;
}

View File

@@ -0,0 +1 @@
fork from `moleculer-db-adapter-mongoose`

View File

@@ -0,0 +1,450 @@
/*
* moleculer-db-adapter-mongoose
* Copyright (c) 2019 MoleculerJS (https://github.com/moleculerjs/moleculer-db)
* MIT Licensed
*/
'use strict';
import _ from 'lodash';
import { Errors, Service, ServiceBroker } from 'moleculer';
import type { DbAdapter } from 'moleculer-db';
import type { Db } from 'mongodb';
import mongoose, {
ConnectOptions,
Model,
Schema,
Document,
Connection,
} from 'mongoose';
const ServiceSchemaError = Errors.ServiceSchemaError;
export class MongooseDbAdapter<TDocument extends Document>
implements DbAdapter
{
uri: string;
opts?: ConnectOptions;
broker: ServiceBroker;
service: Service;
model: Model<TDocument>;
schema?: Schema;
modelName?: string;
db: Db;
conn: Connection;
/**
* Creates an instance of MongooseDbAdapter.
* @param {String} uri
* @param {Object?} opts
*
* @memberof MongooseDbAdapter
*/
constructor(uri: string, opts) {
(this.uri = uri), (this.opts = opts);
mongoose.Promise = Promise;
}
/**
* Initialize adapter
*
* @param {ServiceBroker} broker
* @param {Service} service
*
* @memberof MongooseDbAdapter
*/
init(broker, service) {
this.broker = broker;
this.service = service;
if (this.service.schema.model) {
this.model = this.service.schema.model;
} else if (this.service.schema.schema) {
if (!this.service.schema.modelName) {
throw new ServiceSchemaError(
'`modelName` is required when `schema` is given in schema of service!',
{}
);
}
this.schema = this.service.schema.schema;
this.modelName = this.service.schema.modelName;
}
if (!this.model && !this.schema) {
/* istanbul ignore next */
throw new ServiceSchemaError(
'Missing `model` or `schema` definition in schema of service!',
{}
);
}
}
/**
* Connect to database
*
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
connect() {
let conn: Promise<Connection>;
if (this.model) {
/* istanbul ignore next */
if (mongoose.connection.readyState == 1) {
this.conn = mongoose.connection;
this.db = this.conn.db;
return Promise.resolve();
} else if (mongoose.connection.readyState == 2) {
conn = mongoose.connection.asPromise();
} else {
conn = mongoose.connect(this.uri, this.opts).then((m) => m.connection);
}
} else if (this.schema) {
conn = mongoose
.createConnection(this.uri, this.opts)
.asPromise()
.then((conn) => {
this.model = conn.model(this.modelName, this.schema);
return conn;
});
}
return conn.then((_conn: Connection) => {
this.conn = _conn;
this.db = _conn.db;
this.service.logger.info('MongoDB adapter has connected successfully.');
/* istanbul ignore next */
this.conn.on('disconnected', () =>
this.service.logger.warn('Mongoose adapter has disconnected.')
);
this.conn.on('error', (err) =>
this.service.logger.error('MongoDB error.', err)
);
this.conn.on('reconnect', () =>
this.service.logger.info('Mongoose adapter has reconnected.')
);
});
}
/**
* Disconnect from database
*
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
disconnect() {
return new Promise<void>((resolve) => {
if (this.conn && this.conn.close) {
this.conn.close(() => {
resolve();
});
} else {
mongoose.connection.close(() => {
resolve();
});
}
});
}
/**
* Find all entities by filters.
*
* Available filter props:
* - limit
* - offset
* - sort
* - search
* - searchFields
* - query
*
* @param {any} filters
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
find(filters) {
return this.createCursor(filters).exec();
}
/**
* Find an entity by query
*
* @param {Object} query
* @returns {Promise}
* @memberof MemoryDbAdapter
*/
findOne(query) {
return this.model.findOne(query).exec();
}
/**
* Find an entities by ID
*
* @param {any} _id
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
findById(_id) {
return this.model.findById(_id).exec();
}
/**
* Find any entities by IDs
*
* @param {Array} idList
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
findByIds(idList) {
return this.model
.find({
_id: {
$in: idList,
},
})
.exec();
}
/**
* Get count of filtered entites
*
* Available filter props:
* - search
* - searchFields
* - query
*
* @param {Object} [filters={}]
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
count(filters = {}) {
return this.createCursor(filters).countDocuments().exec();
}
/**
* Insert an entity
*
* @param {Object} entity
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
insert(entity): any {
const item = new this.model(entity);
return item.save();
}
/**
* Insert many entities
*
* @param {Array} entities
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
insertMany(entities): any {
return this.model.create(entities);
}
/**
* Update many entities by `query` and `update`
*
* @param {Object} query
* @param {Object} update
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
updateMany(query, update) {
return this.model
.updateMany(query, update, { multi: true, new: true })
.then((res) => res.matchedCount);
}
/**
* Update an entity by ID and `update`
*
* @param {any} _id
* @param {Object} update
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
updateById(_id, update): any {
return this.model.findByIdAndUpdate(_id, update, { new: true });
}
/**
* Remove entities which are matched by `query`
*
* @param {Object} query
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
removeMany(query) {
return this.model.deleteMany(query).then((res) => res.deletedCount);
}
/**
* Remove an entity by ID
*
* @param {any} _id
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
removeById(_id): any {
return this.model.findByIdAndRemove(_id);
}
/**
* Clear all entities from collection
*
* @returns {Promise}
*
* @memberof MongooseDbAdapter
*/
clear(): any {
return this.model.deleteMany({}).then((res) => res.deletedCount);
}
/**
* Convert DB entity to JSON object
*
* @param {any} entity
* @returns {Object}
* @memberof MongooseDbAdapter
*/
entityToObject(entity) {
const json = entity.toJSON();
if (entity._id && entity._id.toHexString) {
json._id = entity._id.toHexString();
} else if (entity._id && entity._id.toString) {
json._id = entity._id.toString();
}
return json;
}
/**
* Create a filtered query
* Available filters in `params`:
* - search
* - sort
* - limit
* - offset
* - query
*
* @param {Object} params
* @returns {MongoQuery}
*/
createCursor(params) {
if (params) {
const q = this.model.find(params.query);
// Search
if (_.isString(params.search) && params.search !== '') {
if (params.searchFields && params.searchFields.length > 0) {
q.find({
$or: params.searchFields.map((f) => ({
[f]: new RegExp(params.search, 'i'),
})),
});
} else {
// Full-text search
// More info: https://docs.mongodb.com/manual/reference/operator/query/text/
(q as any).find({
$text: {
$search: String(params.search),
},
});
(q as any)._fields = {
_score: {
$meta: 'textScore',
},
};
q.sort({
_score: {
$meta: 'textScore',
},
});
}
}
// Sort
if (_.isString(params.sort)) q.sort(params.sort.replace(/,/, ' '));
else if (Array.isArray(params.sort)) q.sort(params.sort.join(' '));
// Offset
if (_.isNumber(params.offset) && params.offset > 0) q.skip(params.offset);
// Limit
if (_.isNumber(params.limit) && params.limit > 0) q.limit(params.limit);
return q;
}
return this.model.find();
}
/**
* Transforms 'idField' into MongoDB's '_id'
* @param {Object} entity
* @param {String} idField
* @memberof MongoDbAdapter
* @returns {Object} Modified entity
*/
beforeSaveTransformID(entity, idField) {
const newEntity = _.cloneDeep(entity);
if (idField !== '_id' && entity[idField] !== undefined) {
newEntity._id = this.stringToObjectID(newEntity[idField]);
delete newEntity[idField];
}
return newEntity;
}
/**
* Transforms MongoDB's '_id' into user defined 'idField'
* @param {Object} entity
* @param {String} idField
* @memberof MongoDbAdapter
* @returns {Object} Modified entity
*/
afterRetrieveTransformID(entity, idField) {
if (idField !== '_id') {
entity[idField] = this.objectIDToString(entity['_id']);
delete entity._id;
}
return entity;
}
/**
* Convert hex string to ObjectID
* @param {String} id
* @returns ObjectID}
* @memberof MongooseDbAdapter
*/
stringToObjectID(id) {
if (typeof id == 'string' && mongoose.Types.ObjectId.isValid(id))
return new mongoose.Schema.Types.ObjectId(id);
return id;
}
/**
* Convert ObjectID to hex string
* @param {ObjectID} id
* @returns {String}
* @memberof MongooseDbAdapter
*/
objectIDToString(id) {
if (id && id.toString) return id.toString();
return id;
}
}

View File

@@ -0,0 +1,3 @@
Fork from https://github.com/moleculerjs/moleculer-web
Hash: f375dbb4f8bff8aa16e95024e5c65463b626fa45

View File

@@ -0,0 +1,312 @@
/*
* moleculer
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
* MIT Licensed
*/
import { pathToRegexp } from 'path-to-regexp';
import Busboy from '@fastify/busboy';
import kleur from 'kleur';
import _ from 'lodash';
import { PayloadTooLarge } from './errors';
import { Errors } from 'moleculer';
const { MoleculerClientError } = Errors;
import {
removeTrailingSlashes,
addSlashes,
decodeParam,
compose,
} from './utils';
export class Alias {
service;
route;
type = 'call';
method = '*';
path = null;
handler = null;
action = null;
fullPath;
keys;
re;
busboyConfig;
/**
* Constructor of Alias
*
* @param {Service} service
* @param {Object} route
* @param {Object} opts
* @param {any} action
*/
constructor(service, route, opts, action) {
this.service = service;
this.route = route;
if (_.isString(opts)) {
// Parse alias string
if (opts.indexOf(' ') !== -1) {
const p = opts.split(/\s+/);
this.method = p[0];
this.path = p[1];
} else {
this.path = opts;
}
} else if (_.isObject(opts)) {
Object.assign(this, _.cloneDeep(opts));
}
if (_.isString(action)) {
// Parse type from action name
if (action.indexOf(':') > 0) {
const p = action.split(':');
this.type = p[0];
this.action = p[1];
} else {
this.action = action;
}
} else if (_.isFunction(action)) {
this.handler = action;
this.action = null;
} else if (Array.isArray(action)) {
const mws = _.compact(
action.map((mw) => {
if (_.isString(mw)) this.action = mw;
else if (_.isFunction(mw)) return mw;
})
);
this.handler = compose.call(service, ...mws);
} else if (action != null) {
Object.assign(this, _.cloneDeep(action));
}
this.type = this.type || 'call';
this.path = removeTrailingSlashes(this.path);
this.fullPath = this.fullPath || addSlashes(this.route.path) + this.path;
if (this.fullPath !== '/' && this.fullPath.endsWith('/')) {
this.fullPath = this.fullPath.slice(0, -1);
}
this.keys = [];
this.re = pathToRegexp(
this.fullPath,
this.keys,
route.opts.pathToRegexpOptions || {}
); // Options: https://github.com/pillarjs/path-to-regexp#usage
if (this.type == 'multipart') {
// Handle file upload in multipart form
this.handler = this.multipartHandler.bind(this);
}
}
/**
*
* @param {*} url
*/
match(url) {
const m = this.re.exec(url);
if (!m) return false;
const params = {};
let key, param;
for (let i = 0; i < this.keys.length; i++) {
key = this.keys[i];
param = m[i + 1];
if (!param) continue;
params[key.name] = decodeParam(param);
if (key.repeat) params[key.name] = params[key.name].split(key.delimiter);
}
return params;
}
/**
*
* @param {*} method
*/
isMethod(method) {
return this.method === '*' || this.method === method;
}
/**
*
*/
printPath() {
/* istanbul ignore next */
return `${this.method} ${this.fullPath}`;
}
/**
*
*/
toString() {
return (
kleur.magenta(_.padStart(this.method, 6)) +
' ' +
kleur.cyan(this.fullPath) +
kleur.grey(' => ') +
(this.handler != null && this.type !== 'multipart'
? '<Function>'
: this.action)
);
}
/**
*
* @param {*} req
* @param {*} res
*/
multipartHandler(req, res) {
const ctx = req.$ctx;
ctx.meta.$multipart = {};
const promises = [];
let numOfFiles = 0;
let hasField = false;
const busboyOptions = _.defaultsDeep(
{ headers: req.headers },
this.busboyConfig,
this.route.opts.busboyConfig
);
const busboy = new Busboy(busboyOptions);
busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
file.on('limit', () => {
// This file reached the file size limit.
if (_.isFunction(busboyOptions.onFileSizeLimit)) {
busboyOptions.onFileSizeLimit.call(this.service, file, busboy);
}
file.destroy(
new PayloadTooLarge({ fieldname, filename, encoding, mimetype })
);
});
numOfFiles++;
promises.push(
ctx
.call(
this.action,
file,
_.defaultsDeep({}, this.route.opts.callOptions, {
meta: {
fieldname: fieldname,
filename: filename,
encoding: encoding,
mimetype: mimetype,
$params: req.$params,
},
})
)
.catch((err) => {
file.resume(); // Drain file stream to continue processing form
busboy.emit('error', err);
return err;
})
);
});
busboy.on('field', (field, value) => {
hasField = true;
ctx.meta.$multipart[field] = value;
});
busboy.on('finish', async () => {
/* istanbul ignore next */
if (!busboyOptions.empty && numOfFiles == 0)
return this.service.sendError(
req,
res,
new MoleculerClientError('File missing in the request')
);
// Call the action if no files but multipart fields
if (numOfFiles == 0 && hasField) {
promises.push(
ctx.call(
this.action,
{},
_.defaultsDeep({}, this.route.opts.callOptions, {
meta: {
$params: req.$params,
},
})
)
);
}
try {
let data = await this.service.Promise.all(promises);
const fileLimit =
busboyOptions.limits && busboyOptions.limits.files != null
? busboyOptions.limits.files
: null;
if (numOfFiles == 1 && fileLimit == 1) {
// Remove the array wrapping
data = data[0];
}
if (this.route.onAfterCall)
data = await this.route.onAfterCall.call(
this,
ctx,
this.route,
req,
res,
data
);
this.service.sendResponse(req, res, data, {});
} catch (err) {
/* istanbul ignore next */
this.service.sendError(req, res, err);
}
});
/* istanbul ignore next */
busboy.on('error', (err) => {
req.unpipe(req.busboy);
req.resume();
this.service.sendError(req, res, err);
});
// Add limit event handlers
if (_.isFunction(busboyOptions.onPartsLimit)) {
busboy.on('partsLimit', () =>
busboyOptions.onPartsLimit.call(
this.service,
busboy,
this,
this.service
)
);
}
if (_.isFunction(busboyOptions.onFilesLimit)) {
busboy.on('filesLimit', () =>
busboyOptions.onFilesLimit.call(
this.service,
busboy,
this,
this.service
)
);
}
if (_.isFunction(busboyOptions.onFieldsLimit)) {
busboy.on('fieldsLimit', () =>
busboyOptions.onFieldsLimit.call(
this.service,
busboy,
this,
this.service
)
);
}
req.pipe(busboy);
}
}

View File

@@ -0,0 +1,218 @@
/*
* moleculer
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
* MIT Licensed
*/
import { Errors } from 'moleculer';
const { MoleculerError, MoleculerClientError } = Errors;
export { MoleculerError, MoleculerClientError };
const ERR_NO_TOKEN = 'NO_TOKEN';
const ERR_INVALID_TOKEN = 'INVALID_TOKEN';
const ERR_UNABLE_DECODE_PARAM = 'UNABLE_DECODE_PARAM';
const ERR_ORIGIN_NOT_FOUND = 'ORIGIN_NOT_FOUND';
const ERR_ORIGIN_NOT_ALLOWED = 'ORIGIN_NOT_ALLOWED';
/**
* Invalid request body
*
* @class InvalidRequestBodyError
* @extends {Error}
*/
class InvalidRequestBodyError extends MoleculerError {
/**
* Creates an instance of InvalidRequestBodyError.
*
* @param {any} body
* @param {any} error
*
* @memberOf InvalidRequestBodyError
*/
constructor(body, error) {
super('Invalid request body', 400, 'INVALID_REQUEST_BODY', {
body,
error,
});
}
}
/**
* Invalid response type
*
* @class InvalidResponseTypeError
* @extends {Error}
*/
class InvalidResponseTypeError extends MoleculerError {
/**
* Creates an instance of InvalidResponseTypeError.
*
* @param {String} dataType
*
* @memberOf InvalidResponseTypeError
*/
constructor(dataType) {
super(`Invalid response type '${dataType}'`, 500, 'INVALID_RESPONSE_TYPE', {
dataType,
});
}
}
/**
* Unauthorized HTTP error
*
* @class UnAuthorizedError
* @extends {Error}
*/
class UnAuthorizedError extends MoleculerError {
/**
* Creates an instance of UnAuthorizedError.
*
* @param {String} type
* @param {any} data
*
* @memberOf UnAuthorizedError
*/
constructor(type, data) {
super('Unauthorized', 401, type || ERR_INVALID_TOKEN, data);
}
}
/**
* Forbidden HTTP error
*
* @class ForbiddenError
* @extends {Error}
*/
class ForbiddenError extends MoleculerError {
/**
* Creates an instance of ForbiddenError.
*
* @param {String} type
* @param {any} data
*
* @memberOf ForbiddenError
*/
constructor(type, data?) {
super('Forbidden', 403, type, data);
}
}
/**
* Bad request HTTP error
*
* @class BadRequestError
* @extends {Error}
*/
class BadRequestError extends MoleculerError {
/**
* Creates an instance of BadRequestError.
*
* @param {String} type
* @param {any} data
*
* @memberOf BadRequestError
*/
constructor(type, data) {
super('Bad request', 400, type, data);
}
}
/**
* Not found HTTP error
*
* @class NotFoundError
* @extends {Error}
*/
class NotFoundError extends MoleculerError {
/**
* Creates an instance of NotFoundError.
*
* @param {String} type
* @param {any} data
*
* @memberOf NotFoundError
*/
constructor(type?, data?) {
super('Not found', 404, type || 'NOT_FOUND', data);
}
}
/**
* Payload is too large HTTP error
*
* @class PayloadTooLarge
* @extends {Error}
*/
class PayloadTooLarge extends MoleculerClientError {
/**
* Creates an instance of PayloadTooLarge.
*
* @param {String} type
* @param {any} data
*
* @memberOf PayloadTooLarge
*/
constructor(data) {
super('Payload too large', 413, 'PAYLOAD_TOO_LARGE', data);
}
}
/**
* Rate limit exceeded HTTP error
*
* @class RateLimitExceeded
* @extends {Error}
*/
class RateLimitExceeded extends MoleculerClientError {
/**
* Creates an instance of RateLimitExceeded.
*
* @param {String} type
* @param {any} data
*
* @memberOf RateLimitExceeded
*/
constructor(type?, data?) {
super('Rate limit exceeded', 429, type, data);
}
}
/**
* Service unavailable HTTP error
*
* @class ForbiddenError
* @extends {Error}
*/
class ServiceUnavailableError extends MoleculerError {
/**
* Creates an instance of ForbiddenError.
*
* @param {String} type
* @param {any} data
*
* @memberOf ForbiddenError
*/
constructor(type?, data?) {
super('Service unavailable', 503, type, data);
}
}
export {
InvalidRequestBodyError,
InvalidResponseTypeError,
UnAuthorizedError,
ForbiddenError,
BadRequestError,
NotFoundError,
PayloadTooLarge,
RateLimitExceeded,
ServiceUnavailableError,
ERR_NO_TOKEN,
ERR_INVALID_TOKEN,
ERR_UNABLE_DECODE_PARAM,
ERR_ORIGIN_NOT_FOUND,
ERR_ORIGIN_NOT_ALLOWED,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
/**
* Memory store for Rate limiter
*
* Inspired by https://github.com/dotcypress/micro-ratelimit/
*
* @class MemoryStore
*/
export class MemoryStore {
hits;
resetTime;
timer;
/**
* Creates an instance of MemoryStore.
*
* @param {Number} clearPeriod
* @memberof MemoryStore
*/
constructor(clearPeriod) {
this.hits = new Map();
this.resetTime = Date.now() + clearPeriod;
this.timer = setInterval(() => {
this.resetTime = Date.now() + clearPeriod;
this.reset();
}, clearPeriod);
this.timer.unref();
}
/**
* Increment the counter by key
*
* @param {String} key
* @returns {Number}
* @memberof MemoryStore
*/
inc(key) {
let counter = this.hits.get(key) || 0;
counter++;
this.hits.set(key, counter);
return counter;
}
/**
* Reset all counters
*
* @memberof MemoryStore
*/
reset() {
this.hits.clear();
}
}

View File

@@ -0,0 +1,156 @@
/*
* moleculer
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
* MIT Licensed
*/
import _ from 'lodash';
import fresh from 'fresh';
import etag from 'etag';
import {
BadRequestError,
ERR_UNABLE_DECODE_PARAM,
MoleculerError,
} from './errors';
/**
* Decode URI encoded param
* @param {String} param
*/
function decodeParam(param) {
try {
return decodeURIComponent(param);
} catch (_) {
/* istanbul ignore next */
throw new BadRequestError(ERR_UNABLE_DECODE_PARAM, { param });
}
}
// Remove slashes "/" from the left & right sides and remove double "//" slashes
function removeTrailingSlashes(s) {
if (s.startsWith('/')) s = s.slice(1);
if (s.endsWith('/')) s = s.slice(0, -1);
return s; //.replace(/\/\//g, "/");
}
// Add slashes "/" to the left & right sides
function addSlashes(s) {
return (s.startsWith('/') ? '' : '/') + s + (s.endsWith('/') ? '' : '/');
}
// Normalize URL path (remove multiple slashes //)
function normalizePath(s) {
return s.replace(/\/{2,}/g, '/');
}
/**
* Compose middlewares
*
* @param {...Function} mws
*/
function compose(...mws) {
const self = this as any;
return (req, res, done) => {
const next = (i, err?) => {
if (i >= mws.length) {
if (_.isFunction(done)) return done.call(self, err);
/* istanbul ignore next */
return;
}
if (err) {
// Call only error middlewares (err, req, res, next)
if (mws[i].length == 4)
mws[i].call(self, err, req, res, (err) => next(i + 1, err));
else next(i + 1, err);
} else {
if (mws[i].length < 4)
mws[i].call(self, req, res, (err) => next(i + 1, err));
else next(i + 1);
}
};
return next(0);
};
}
/**
* Compose middlewares and return Promise
* @param {...Function} mws
* @returns {Promise}
*/
function composeThen(req, res, ...mws) {
return new Promise<void>((resolve, reject) => {
compose.call(this, ...mws)(req, res, (err) => {
if (err) {
/* istanbul ignore next */
if (err instanceof MoleculerError) return reject(err);
/* istanbul ignore next */
if (err instanceof Error)
return reject(
new MoleculerError(
err.message,
(err as any).code || (err as any).status,
(err as any).type
)
); // TODO err.stack
/* istanbul ignore next */
return reject(new MoleculerError(err));
}
resolve();
});
});
}
/**
* Generate ETag from content.
*
* @param {any} body
* @param {Boolean|String|Function?} opt
*
* @returns {String}
*/
function generateETag(body, opt) {
if (_.isFunction(opt)) return opt.call(this, body);
const buf = !Buffer.isBuffer(body) ? Buffer.from(body) : body;
return etag(buf, opt === true || opt === 'weak' ? { weak: true } : null);
}
/**
* Check the data freshness.
*
* @param {*} req
* @param {*} res
*
* @returns {Boolean}
*/
function isFresh(req, res) {
if (
(res.statusCode >= 200 && res.statusCode < 300) ||
304 === res.statusCode
) {
return fresh(req.headers, {
etag: res.getHeader('ETag'),
'last-modified': res.getHeader('Last-Modified'),
});
}
return false;
}
export {
removeTrailingSlashes,
addSlashes,
normalizePath,
decodeParam,
compose,
composeThen,
generateETag,
isFresh,
};

View File

@@ -0,0 +1,22 @@
export const PERMISSION = {
/**
* 非插件的权限点都叫core
*/
core: {
owner: '__group_owner__', // 保留字段, 用于标识群组所有者
message: 'core.message',
invite: 'core.invite',
unlimitedInvite: 'core.unlimitedInvite',
editInvite: 'core.editInvite', // 编辑邀请码权限,需要有创建无限制邀请码的权限
groupDetail: 'core.groupDetail',
groupBaseInfo: 'core.groupBaseInfo',
groupConfig: 'core.groupConfig',
manageUser: 'core.manageUser',
managePanel: 'core.managePanel',
manageInvite: 'core.manageInvite',
manageRoles: 'core.manageRoles',
deleteMessage: 'core.deleteMessage',
},
};
export const allPermission = [...Object.values(PERMISSION.core)];

View File

@@ -0,0 +1,106 @@
import dotenv from 'dotenv';
import _ from 'lodash';
dotenv.config();
/**
* 配置信息
*/
const port = process.env.PORT ? Number(process.env.PORT) : 11000;
const apiUrl = process.env.API_URL || `http://127.0.0.1:${port}`;
const staticHost = process.env.STATIC_HOST || '{BACKEND}';
const staticUrl = process.env.STATIC_URL || `${staticHost}/static/`;
const requestTimeout = process.env.REQUEST_TIMEOUT
? Number(process.env.REQUEST_TIMEOUT)
: 10 * 1000; // default 0 (unit: milliseconds)
export const config = {
port,
secret: process.env.SECRET || 'tailchat',
env: process.env.NODE_ENV || 'development',
/**
* 是否打开socket admin ui
*/
enableSocketAdmin: !!process.env.ADMIN,
redisUrl: process.env.REDIS_URL,
mongoUrl: process.env.MONGO_URL,
storage: {
type: 'minio', // 可选: minio
minioUrl: process.env.MINIO_URL,
ssl: checkEnvTrusty(process.env.MINIO_SSL) ?? false,
user: process.env.MINIO_USER,
pass: process.env.MINIO_PASS,
bucketName: process.env.MINIO_BUCKET_NAME || 'tailchat',
pathStyle: process.env.MINIO_PATH_STYLE === 'VirtualHosted' ? false : true,
/**
* 文件上传限制
* 单位byte
* 默认 1m
*/
limit: process.env.FILE_LIMIT
? Number(process.env.FILE_LIMIT)
: 1 * 1024 * 1024,
},
apiUrl,
staticUrl,
enableOpenapi: true, // 是否开始openapi
emailVerification: checkEnvTrusty(process.env.EMAIL_VERIFY) || false, // 是否在注册后验证邮箱可用性
smtp: {
senderName: process.env.SMTP_SENDER, // 发邮件者显示名称
connectionUrl: process.env.SMTP_URI || '',
},
enablePrometheus: checkEnvTrusty(process.env.PROMETHEUS),
runner: {
requestTimeout,
},
/**
* 使用Tianji对网站进行监控
*/
tianji: {
scriptUrl: process.env.TIANJI_SCRIPT_URL,
websiteId: process.env.TIANJI_WEBSITE_ID,
},
feature: {
disableMsgpack: checkEnvTrusty(process.env.DISABLE_MESSAGEPACK), // 是否禁用socketio的 messgpack parser
disableFileCheck: checkEnvTrusty(process.env.DISABLE_FILE_CHECK),
disableLogger: checkEnvTrusty(process.env.DISABLE_LOGGER), // 是否关闭日志
disableInfoLog: checkEnvTrusty(process.env.DISABLE_INFO_LOG), // 是否关闭常规日志(仅保留错误日志)
disableTracing: checkEnvTrusty(process.env.DISABLE_TRACING), // 是否关闭链路追踪
disableUserRegister: checkEnvTrusty(process.env.DISABLE_USER_REGISTER), // 是否关闭用户注册功能
disableGuestLogin: checkEnvTrusty(process.env.DISABLE_GUEST_LOGIN), // 是否关闭用户游客登录功能
disableCreateGroup: checkEnvTrusty(process.env.DISABLE_CREATE_GROUP), // 是否禁用用户创建群组功能
disablePluginStore: checkEnvTrusty(process.env.DISABLE_PLUGIN_STORE), // 是否禁用用户插件中心功能
disableAddFriend: checkEnvTrusty(process.env.DISABLE_ADD_FRIEND), // 是否禁用用户添加好友功能
disableTelemetry: checkEnvTrusty(process.env.DISABLE_TELEMETRY), // 是否禁用遥测
},
};
export const builtinAuthWhitelist = [
'/gateway/health',
'/debug/hello',
'/user/login',
'/user/register',
'/user/createTemporaryUser',
'/user/resolveToken',
'/user/getUserInfo',
'/user/getUserInfoList',
'/user/checkTokenValid',
'/group/getGroupBasicInfo',
'/group/invite/findInviteByCode',
];
/**
* 构建上传地址
*/
export function buildUploadUrl(objectName: string) {
return config.staticUrl + objectName;
}
/**
* 判断环境变量是否为true
*/
export function checkEnvTrusty(env: string): boolean {
return env === '1' || env === 'true';
}

View File

@@ -0,0 +1,99 @@
import { Context, Errors, ServiceSchema } from 'moleculer';
import BaseDBService, { MoleculerDB } from 'moleculer-db';
import { MongooseDbAdapter } from '../lib/moleculer-db-adapter-mongoose';
import type { Document, FilterQuery, Model } from 'mongoose';
import { config } from '../lib/settings';
import type { ReturnModelType } from '@typegoose/typegoose';
import type {
AnyParamConstructor,
BeAnObject,
} from '@typegoose/typegoose/lib/types';
type EntityChangedType = 'created' | 'updated';
// type MoleculerDBMethods = MoleculerDB<MongooseDbAdapter>['methods'];
type MoleculerDBMethods = MoleculerDB<any>['methods'];
// fork from moleculer-db-adapter-mongoose/index.d.ts
interface FindFilters<T extends Document> {
query?: FilterQuery<T>;
search?: string;
searchFields?: string[]; // never used???
sort?: string | string[];
offset?: number;
limit?: number;
}
// 复写部分 adapter 的方法类型
interface TcDbAdapterOverwrite<T extends Document, M extends Model<T>> {
model: M;
insert(entity: Partial<T>): Promise<T>;
find(filters: FindFilters<T>): Promise<T>;
findOne(query: FilterQuery<T>): Promise<T | null>;
}
export interface TcDbService<
T extends Document = Document,
M extends Model<T> = Model<T>
> extends MoleculerDBMethods {
entityChanged(type: EntityChangedType, json: {}, ctx: Context): Promise<void>;
adapter: Omit<MongooseDbAdapter<T>, keyof TcDbAdapterOverwrite<T, M>> &
TcDbAdapterOverwrite<T, M>;
/**
* 转换fetch出来的文档, 变成一个json
*/
transformDocuments: MoleculerDB<
// @ts-ignore
MongooseDbAdapter<T>
>['methods']['transformDocuments'];
}
export type TcDbModel = ReturnModelType<AnyParamConstructor<any>, BeAnObject>;
/**
* Tc 数据库mixin
* @param model 数据模型
*/
export function TcDbService(model: TcDbModel): Partial<ServiceSchema> {
const actions = {
/**
* 自动操作全关
*/
find: false,
count: false,
list: false,
create: false,
insert: false,
get: false,
update: false,
remove: false,
};
const methods = {
/**
* 实体变更时触发事件
*/
async entityChanged(type, json, ctx) {
await this.clearCache();
const eventName = `${this.name}.entity.${type}`;
this.broker.emit(eventName, { meta: ctx.meta, entity: json });
},
};
if (!config.mongoUrl) {
throw new Errors.MoleculerClientError('需要环境变量 MONGO_URL');
}
return {
mixins: [BaseDBService],
adapter: new MongooseDbAdapter(config.mongoUrl, {
useNewUrlParser: true,
useUnifiedTopology: true,
}),
model,
actions,
methods,
};
}

View File

@@ -0,0 +1,822 @@
import { Client as MinioClient, CopyConditions } from 'minio';
import { isString, isUndefined } from 'ramda-adjunct';
import { Errors } from 'moleculer';
class MinioInitializationError extends Errors.MoleculerError {
/**
* Creates an instance of MinioInitializationError.
*
* @param {String?} message
* @param {Number?} code
* @param {String?} type
* @param {any} data
*
* @memberof MinioInitializationError
*/
constructor(
message = 'Minio can not be initialized',
code = 500,
type = 'MINIO_INITIALIZATION_ERROR',
data = {}
) {
super(message);
this.code = code;
this.type = type;
this.data = data;
}
}
class MinioPingError extends Errors.MoleculerRetryableError {
/**
* Creates an instance of MinioPingError.
*
* @param {String?} message
* @param {Number?} code
* @param {String?} type
* @param {any} data
*
* @memberof MinioPingError
*/
constructor(
message = 'Minio Backend not reachable',
code = 502,
type = 'MINIO_PING_ERROR',
data = {}
) {
super(message);
this.code = code;
this.type = type;
this.data = data;
}
}
/**
* Service mixin for managing files in a Minio S3 backend
*
* @name moleculer-minio
* @module Service
*/
export const TcMinioService = {
// Service name
name: 'minio',
// Default settings
settings: {
/** @type {String} The Hostname minio is running on and available at. Hostname or IP-Address */
endPoint: undefined,
/** @type {Number} TCP/IP port number minio is listening on. Default value set to 80 for HTTP and 443 for HTTPs.*/
port: undefined,
/** @type {Boolean?} If set to true, https is used instead of http. Default is true.*/
useSSL: true,
/** @type {String} The AccessKey to use when connecting to minio */
accessKey: undefined,
/** @type {String} The SecretKey to use when connecting to minio */
secretKey: undefined,
/** @type {String?} Set this value to override region cache*/
region: undefined,
/** @type {String?} Set this value to pass in a custom transport. (Optional)*/
transport: undefined,
/** @type {String?} Set this value to provide x-amz-security-token (AWS S3 specific). (Optional)*/
sessionToken: undefined,
/** @type {Number?} This service will perform a periodic healthcheck of Minio. Use this setting to configure the inverval in which the healthcheck is performed. Set to `0` to turn healthcheks of */
minioHealthCheckInterval: 5000,
/**
* Path Style: <Schema>://<S3 Endpoint>/<Bucket>/<Object>
* Virtual hosted style: <Schema>://<Bucket>.<S3 Endpoint>/<Object>
*/
pathStyle: true,
},
methods: {
/**
* Creates and returns a new Minio client
*
* @methods
*
* @returns {Client}
*/
createMinioClient() {
return new MinioClient({
endPoint: this.settings.endPoint,
port: this.settings.port,
useSSL: this.settings.useSSL,
accessKey: this.settings.accessKey,
secretKey: this.settings.secretKey,
region: this.settings.region,
transport: this.settings.transport,
sessionToken: this.settings.sessionToken,
pathStyle: this.settings.pathStyle,
});
},
/**
* Pings the configured minio backend
*
* @param {number} timeout - Amount of miliseconds to wait for a ping response
* @returns {PromiseLike<boolean|MinioPingError>}
*/
ping({ timeout = 5000 } = {}) {
return this.Promise.race([
this.client.listBuckets().then(() => true),
this.Promise.delay(timeout).then(() => {
throw new MinioPingError();
}),
]);
},
},
actions: {
/**
* Creates a new Bucket
*
* @actions
*
* @param {string} bucketName - The name of the bucket
* @param {string} region - The region to create the bucket in. Defaults to "us-east-1"
*
* @returns {PromiseLike<undefined|Error>}
*/
makeBucket: {
params: {
bucketName: { type: 'string' },
region: { type: 'string', optional: true },
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({ bucketName, region = '' }) =>
this.client.makeBucket(bucketName, region)
);
},
},
/**
* Lists all buckets.
*
* @actions
*
* @returns {PromiseLike<Bucket[]|Error>}
*/
listBuckets: {
handler() {
return this.client
.listBuckets()
.then((buckets) => (isUndefined(buckets) ? [] : buckets));
},
},
/**
* Checks if a bucket exists.
*
* @actions
* @param {string} bucketName - Name of the bucket
*
* @returns {PromiseLike<boolean|Error>}
*/
bucketExists: {
params: {
bucketName: { type: 'string' },
},
handler(ctx) {
return this.client.bucketExists(ctx.params.bucketName);
},
},
/**
* Removes a bucket.
*
* @actions
* @param {string} bucketName - Name of the bucket
*
* @returns {PromiseLike<boolean|Error>}
*/
removeBucket: {
params: {
bucketName: { type: 'string' },
},
handler(ctx) {
return this.client.removeBucket(ctx.params.bucketName);
},
},
/**
* Lists all objects in a bucket.
*
* @actions
* @param {string} bucketName - Name of the bucket
* @param {string} prefix - The prefix of the objects that should be listed (optional, default '').
* @param {boolean} recursive - `true` indicates recursive style listing and false indicates directory style listing delimited by '/'. (optional, default `false`).
*
* @returns {PromiseLike<Object[]|Error>}
*/
listObjects: {
params: {
bucketName: { type: 'string' },
prefix: { type: 'string', optional: true },
recursive: { type: 'boolean', optional: true },
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({ bucketName, prefix = '', recursive = false }) => {
return new this.Promise((resolve, reject) => {
try {
const stream = this.client.listObjects(
bucketName,
prefix,
recursive
);
const objects = [];
stream.on('data', (el) => objects.push(el));
stream.on('end', () => resolve(objects));
stream.on('error', reject);
} catch (e) {
reject(e);
}
});
}
);
},
},
/**
* Lists all objects in a bucket using S3 listing objects V2 API
*
* @actions
* @param {string} bucketName - Name of the bucket
* @param {string} prefix - The prefix of the objects that should be listed (optional, default '').
* @param {boolean} recursive - `true` indicates recursive style listing and false indicates directory style listing delimited by '/'. (optional, default `false`).
* @param {string} startAfter - Specifies the object name to start after when listing objects in a bucket. (optional, default '').
*
* @returns {PromiseLike<Object[]|Error>}
*/
listObjectsV2: {
params: {
bucketName: { type: 'string' },
prefix: { type: 'string', optional: true },
recursive: { type: 'boolean', optional: true },
startAfter: { type: 'string', optional: true },
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({ bucketName, prefix = '', recursive = false, startAfter = '' }) => {
return new this.Promise((resolve, reject) => {
try {
const stream = this.client.listObjectsV2(
bucketName,
prefix,
recursive,
startAfter
);
const objects = [];
stream.on('data', (el) => objects.push(el));
stream.on('end', () => resolve(objects));
stream.on('error', reject);
} catch (e) {
reject(e);
}
});
}
);
},
},
/**
* Lists partially uploaded objects in a bucket.
*
* @actions
* @param {string} bucketName - Name of the bucket
* @param {string} prefix - The prefix of the objects that should be listed (optional, default '').
* @param {boolean} recursive - `true` indicates recursive style listing and false indicates directory style listing delimited by '/'. (optional, default `false`).
*
* @returns {PromiseLike<Object[]|Error>}
*/
listIncompleteUploads: {
params: {
bucketName: { type: 'string' },
prefix: { type: 'string', optional: true },
recursive: { type: 'boolean', optional: true },
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({ bucketName, prefix = '', recursive = false }) => {
return new this.Promise((resolve, reject) => {
try {
const stream = this.client.listIncompleteUploads(
bucketName,
prefix,
recursive
);
const objects = [];
stream.on('data', (el) => objects.push(el));
stream.on('end', () => resolve(objects));
stream.on('error', reject);
} catch (e) {
reject(e);
}
});
}
);
},
},
/**
* Downloads an object as a stream.
*
* @actions
* @param {string} bucketName - Name of the bucket
* @param {string} objectName - Name of the object.
*
* @returns {PromiseLike<ReadableStream|Error>}
*/
getObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
},
handler(ctx) {
return this.client.getObject(
ctx.params.bucketName,
ctx.params.objectName
);
},
},
/**
* Downloads the specified range bytes of an object as a stream.
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
* @param {number} offset - `offset` of the object from where the stream will start.
* @param {number} length - `length` of the object that will be read in the stream (optional, if not specified we read the rest of the file from the offset).
*
* @returns {PromiseLike<ReadableStream|Error>}
*/
getPartialObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
offset: { type: 'number' },
length: { type: 'number', optional: true },
},
handler(ctx) {
return this.client.getPartialObject(
ctx.params.bucketName,
ctx.params.objectName,
ctx.params.offset,
ctx.params.length
);
},
},
/**
* Downloads and saves the object as a file in the local filesystem.
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
* @param {string} filePath - Path on the local filesystem to which the object data will be written.
*
* @returns {PromiseLike<undefined|Error>}
*/
fGetObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
filePath: { type: 'string' },
},
handler(ctx) {
return this.client.fGetObject(
ctx.params.bucketName,
ctx.params.objectName,
ctx.params.filePath
);
},
},
/**
* Uploads an object from a stream/Buffer.
*
* @actions
* @param {ReadableStream} params - Readable stream.
*
* @meta
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
* @param {number} size - Size of the object (optional).
* @param {object} metaData - metaData of the object (optional).
*
* @returns {PromiseLike<undefined|Error>}
*/
putObject: {
handler(ctx) {
return this.Promise.resolve({
stream: ctx.params,
meta: ctx.meta,
}).then(({ stream, meta }) =>
this.client.putObject(
meta.bucketName,
meta.objectName,
stream,
meta.size,
meta.metaData
)
);
},
},
/**
* Uploads contents from a file to objectName.
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
* @param {string} filePath - Path of the file to be uploaded.
* @param {object} metaData - metaData of the object (optional).
*
* @returns {PromiseLike<undefined|Error>}
*/
fPutObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
filePath: { type: 'string' },
metaData: { type: 'object', optional: true },
},
handler(ctx) {
return this.client.fPutObject(
ctx.params.bucketName,
ctx.params.objectName,
ctx.params.filePath,
ctx.params.metaData
);
},
},
/**
* Copy a source object into a new object in the specified bucket.
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
* @param {string} sourceObject - Path of the file to be copied.
* @param {object} conditions - Conditions to be satisfied before allowing object copy.
* @param {object} metaData - metaData of the object (optional).
*
* @returns {PromiseLike<{etag: {string}, lastModified: {string}}|Error>}
*/
copyObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
sourceObject: { type: 'string' },
conditions: {
type: 'object',
properties: {
modified: { type: 'string', optional: true },
unmodified: { type: 'string', optional: true },
matchETag: { type: 'string', optional: true },
matchETagExcept: { type: 'string', optional: true },
},
},
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({ bucketName, objectName, sourceObject, conditions }) => {
const _conditions = new CopyConditions();
if (conditions.modified) {
_conditions.setModified(new Date(conditions.modified));
}
if (conditions.unmodified) {
_conditions.setUnmodified(new Date(conditions.unmodified));
}
if (conditions.matchETag) {
_conditions.setMatchETag(conditions.matchETag);
}
if (conditions.matchETagExcept) {
_conditions.setMatchETagExcept(conditions.matchETagExcept);
}
conditions = _conditions;
return this.client.copyObject(
bucketName,
objectName,
sourceObject,
conditions
);
}
);
},
},
/**
* Gets metadata of an object.
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
*
* @returns {PromiseLike<{size: {number}, metaData: {object}, lastModified: {string}, etag: {string}}|Error>}
*/
statObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
},
handler(ctx) {
return this.client.statObject(
ctx.params.bucketName,
ctx.params.objectName
);
},
},
/**
* Removes an Object
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
*
* @returns {PromiseLike<undefined|Error>}
*/
removeObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
},
handler(ctx) {
return this.client.removeObject(
ctx.params.bucketName,
ctx.params.objectName
);
},
},
/**
* Removes a list of Objects
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string[]} objectNames - Names of the objects.
*
* @returns {PromiseLike<undefined|Error>}
*/
removeObjects: {
params: {
bucketName: { type: 'string' },
objectNames: { type: 'array', items: 'string' },
},
handler(ctx) {
return this.client.removeObjects(
ctx.params.bucketName,
ctx.params.objectNames
);
},
},
/**
* Removes a partially uploaded object.
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
*
* @returns {PromiseLike<undefined|Error>}
*/
removeIncompleteUpload: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({ bucketName, objectName }) =>
this.client.removeIncompleteUpload(bucketName, objectName)
);
},
},
/**
* Generates a presigned URL for the provided HTTP method, 'httpMethod'. Browsers/Mobile clients may point to this URL to directly download objects even if the bucket is private. This
* presigned URL can have an associated expiration time in seconds after which the URL is no longer valid. The default value is 7 days.
*
* @actions
* @param {string} httpMethod - The HTTP-Method (eg. `GET`).
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
* @param {number} expires - Expiry time in seconds. Default value is 7 days. (optional)
* @param {object} reqParams - request parameters. (optional)
* @param {string} requestDate - An ISO date string, the url will be issued at. Default value is now. (optional)
* @returns {PromiseLike<String|Error>}
*/
presignedUrl: {
params: {
httpMethod: { type: 'string' },
bucketName: { type: 'string' },
objectName: { type: 'string' },
expires: { type: 'number', integer: true, optional: true },
reqParams: { type: 'object', optional: true },
requestDate: { type: 'string', optional: true },
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({
httpMethod,
bucketName,
objectName,
expires,
reqParams,
requestDate,
}) => {
if (isString(requestDate)) {
requestDate = new Date(requestDate);
}
return new this.Promise((resolve, reject) => {
this.client.presignedUrl(
httpMethod,
bucketName,
objectName,
expires,
reqParams,
requestDate,
(error, url) => {
if (error) {
reject(error);
} else {
resolve(url);
}
}
);
});
}
);
},
},
/**
* Generates a presigned URL for HTTP GET operations. Browsers/Mobile clients may point to this URL to directly download objects even if the bucket is private. This presigned URL can have an
* associated expiration time in seconds after which the URL is no longer valid. The default value is 7 days.
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
* @param {number} expires - Expiry time in seconds. Default value is 7 days. (optional)
* @param {object} reqParams - request parameters. (optional)
* @param {string} requestDate - An ISO date string, the url will be issued at. Default value is now. (optional)
* @returns {PromiseLike<String|Error>}
*/
presignedGetObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
expires: { type: 'number', integer: true, optional: true },
reqParams: { type: 'object', optional: true },
requestDate: { type: 'string', optional: true },
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({ bucketName, objectName, expires, reqParams, requestDate }) => {
if (isString(requestDate)) {
requestDate = new Date(requestDate);
}
return new this.Promise((resolve, reject) => {
this.client.presignedGetObject(
bucketName,
objectName,
expires,
reqParams,
requestDate,
(error, url) => {
if (error) {
reject(error);
} else {
resolve(url);
}
}
);
});
}
);
},
},
/**
* Generates a presigned URL for HTTP PUT operations. Browsers/Mobile clients may point to this URL to upload objects directly to a bucket even if it is private. This presigned URL can have
* an associated expiration time in seconds after which the URL is no longer valid. The default value is 7 days.
*
* @actions
* @param {string} bucketName - Name of the bucket.
* @param {string} objectName - Name of the object.
* @param {number} expires - Expiry time in seconds. Default value is 7 days. (optional)
* @returns {PromiseLike<String|Error>}
*/
presignedPutObject: {
params: {
bucketName: { type: 'string' },
objectName: { type: 'string' },
expires: { type: 'number', integer: true, optional: true },
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(
({ bucketName, objectName, expires }) => {
return new this.Promise((resolve, reject) => {
this.client.presignedPutObject(
bucketName,
objectName,
expires,
(error, url) => {
if (error) {
reject(error);
} else {
resolve(url);
}
}
);
});
}
);
},
},
/**
* Allows setting policy conditions to a presigned URL for POST operations. Policies such as bucket name to receive object uploads, key name prefixes, expiry policy may be set.
*
* @actions
* @param {object} policy - Policy object created by minioClient.newPostPolicy()
* @returns {PromiseLike<{postURL: {string}, formData: {object}}|Error>}
*/
presignedPostPolicy: {
params: {
policy: {
type: 'object',
properties: {
expires: { type: 'string', optional: true },
key: { type: 'string', optional: true },
keyStartsWith: { type: 'string', optional: true },
bucket: { type: 'string', optional: true },
contentType: { type: 'string', optional: true },
contentLengthRangeMin: {
type: 'number',
integer: true,
optional: true,
},
contentLengthRangeMax: {
type: 'number',
integer: true,
optional: true,
},
},
},
},
handler(ctx) {
return this.Promise.resolve(ctx.params).then(({ policy }) => {
const _policy = this.client.newPostPolicy();
if (policy.expires) {
_policy.setExpires(new Date(policy.expires));
}
if (policy.key) {
_policy.setKey(policy.key);
}
if (policy.keyStartsWith) {
_policy.setKeyStartsWith(policy.keyStartsWith);
}
if (policy.bucket) {
_policy.setBucket(policy.bucket);
}
if (policy.contentType) {
_policy.setContentType(policy.contentType);
}
if (policy.contentLengthRangeMin && policy.contentLengthRangeMax) {
_policy.setContentLengthRange(
policy.contentLengthRangeMin,
policy.contentLengthRangeMax
);
}
return this.client.presignedPostPolicy(_policy);
});
},
},
},
/**
* Service created lifecycle event handler.
* Constructs a new minio client entity
*/
created() {
this.client = this.createMinioClient();
},
/**
* Service started lifecycle event handler. Resolves when:
* * ping of S3 backend has been successful
* * a healthCheck has been registered, given minioHealthCheckInterval > 0
* @returns {PromiseLike<undefined|MinioInitializationError>}
*/
started() {
/* istanbul ignore next */
return this.Promise.resolve()
.then(() => this.ping())
.then(() => {
this.settings.minioHealthCheckInterval
? (this.healthCheckInterval = setInterval(
() =>
this.ping().catch((e) =>
this.logger.error('Minio backend can not be reached', e)
),
this.settings.minioHealthCheckInterval
))
: undefined;
return undefined;
})
.catch((e) => {
throw new MinioInitializationError(e.message);
});
},
/**
* Service stopped lifecycle event handler.
* Removes the healthCheckInterval
*/
stopped() {
this.healthCheckInterval && clearInterval(this.healthCheckInterval);
},
};

View File

@@ -0,0 +1,68 @@
import type { Context } from 'moleculer';
import type { TFunction } from 'i18next';
import type { GroupStruct } from '../structs/group';
import type { BuiltinEventMap } from '../structs/events';
export type {
ServiceSchema as PureServiceSchema,
Service as PureService,
} from 'moleculer';
export interface UserJWTPayload {
_id: string;
nickname: string;
email: string;
avatar: string;
}
interface TranslationMeta {
t: TFunction;
language: string;
}
export type PureContext<P = {}, M extends object = {}> = Context<P, M>;
export interface TcPureContext<P = {}, M = {}>
extends Omit<Context<P>, 'emit'> {
meta: TranslationMeta & M;
// 事件类型重写
emit<K extends string>(
eventName: K,
data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
groups?: string | string[]
): Promise<void>;
emit(eventName: string): Promise<void>;
}
export type TcContext<P = {}, M = {}> = TcPureContext<
P,
{
user: UserJWTPayload;
token: string;
userId: string;
/**
* 仅在 socket.io 的请求中会出现
*/
socketId?: string;
/**
* 仅在 afterActionHook 请求中会出现
*/
actionResult?: any;
} & M
>;
export type GroupBaseInfo = Pick<
GroupStruct,
'name' | 'avatar' | 'owner' | 'description'
> & {
memberCount: number;
backgroundImage?: string;
};
/**
* 面板能力
*/
export type PanelFeature = 'subscribe'; // 订阅变更,即用户登录时自动加入面板的房间

View File

@@ -0,0 +1,71 @@
import type { InboxItem } from 'tailchat-types';
export interface MessageReactionStruct {
name: string;
author?: string;
}
export interface MessageStruct {
_id: string;
content: string;
author: string;
groupId?: string;
converseId: string;
hasRecall: boolean;
reactions: MessageReactionStruct[];
}
export interface MessageMetaStruct {
mentions?: string[];
reply?: {
_id: string;
author: string;
content: string;
};
}
interface InboxMessageStruct {
/**
* 消息所在群组Id
*/
groupId?: string;
/**
* 消息所在会话Id
*/
converseId: string;
/**
* 消息ID
*/
messageId: string;
/**
* 消息片段,用于消息的预览/发送通知
*/
messageSnippet: string;
}
/**
* 收件箱记录项类型
*/
export interface BasicInboxItem {
_id: string;
userId: string;
type: string;
message?: InboxMessageStruct;
/**
* 信息体,没有类型
*/
payload?: object;
/**
* 是否已读
*/
readed: boolean;
}
export type InboxStruct = InboxItem;
export type { ChatConverse as ChatConverseStruct } from 'tailchat-types';

View File

@@ -0,0 +1,28 @@
import type { InboxStruct, MessageMetaStruct } from './chat';
/**
* 默认服务的事件映射
*/
export interface BuiltinEventMap {
'gateway.auth.addWhitelists': { urls: string[] };
'chat.message.updateMessage':
| {
type: 'add';
groupId?: string;
converseId: string;
messageId: string;
author: string;
content: string;
plain?: string;
meta: MessageMetaStruct;
}
| {
type: 'recall' | 'delete';
groupId?: string;
converseId: string;
messageId: string;
meta: MessageMetaStruct;
};
'config.updated': { config: Record<string, any> };
'chat.inbox.append': InboxStruct;
}

View File

@@ -0,0 +1,63 @@
export enum GroupPanelType {
TEXT = 0,
GROUP = 1,
PLUGIN = 2,
}
// TODO
export type GroupPanelMeta = {};
interface GroupMemberStruct {
roles?: string[]; // 角色
userId: string;
muteUntil?: string;
}
export interface GroupPanelStruct {
id: string; // 在群组中唯一, 可以用任意方式进行生成。这里使用ObjectId, 但不是ObjectId类型
name: string; // 用于显示的名称
parentId?: string; // 父节点id
type: GroupPanelType; // 面板类型: Reference: https://discord.com/developers/docs/resources/channel#channel-object-channel-types
provider?: string; // 面板提供者,为插件的标识,仅面板类型为插件时有效
pluginPanelName?: string; // 插件面板名, 如 com.msgbyte.webview/grouppanel
/**
* 面板的其他数据
*/
meta?: GroupPanelMeta;
}
/**
* 群组权限组
*/
export interface GroupRoleStruct {
name: string; // 权限组名
permissions: string[]; // 拥有的权限, 是一段字符串
}
export interface GroupStruct {
_id: string;
name: string;
avatar?: string;
owner: string;
description?: string;
members: GroupMemberStruct[];
panels: GroupPanelStruct[];
roles?: GroupRoleStruct[];
config: Record<string, any>;
}

View File

@@ -0,0 +1,9 @@
import type { UserBaseInfo, UserInfoWithToken, UserType } from 'tailchat-types';
import { userType } from 'tailchat-types';
export {
userType,
UserType,
UserBaseInfo as UserStruct,
UserInfoWithToken as UserStructWithToken,
};

View File

@@ -0,0 +1,16 @@
const noConflictKey = '__';
/**
* 因为微服务的名称中经常会有 `.` , 而 `.` 在一些场景(如lodash.set) 有特殊含义,因此增加一个工具用于解决这个问题
*/
export function encodeNoConflictServiceNameKey(key: string): string {
return key.replaceAll('.', noConflictKey);
}
export function decodeNoConflictServiceNameKey(key: string): string {
if (typeof key !== 'string') {
return '';
}
return key.replaceAll(noConflictKey, '.');
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"skipLibCheck": true,
"typeRoots": ["./node_modules/@types"],
"rootDir": "./src",
"outDir": "dist"
},
"include": ["./src/**/*"],
"exclude": ["node_modules/**/*"]
}