diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 961b8ff6..1eb99872 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1542,25 +1542,64 @@ importers:
server/admin-next:
dependencies:
+ '@fastify/busboy':
+ specifier: ^1.1.0
+ version: 1.1.0
+ body-parser:
+ specifier: ^1.20.1
+ version: 1.20.1
+ compression:
+ specifier: ^1.7.4
+ version: 1.7.4
express:
specifier: ^4.18.2
version: 4.18.2
+ express-mongoose-ra-json-server:
+ specifier: ^0.1.0
+ version: 0.1.0(express@4.18.2)(mongoose@6.1.1)
+ jsonwebtoken:
+ specifier: ^8.5.1
+ version: 8.5.1
+ lodash:
+ specifier: ^4.17.21
+ version: 4.17.21
+ md5:
+ specifier: ^2.3.0
+ version: 2.3.0
+ morgan:
+ specifier: ^1.10.0
+ version: 1.10.0
react:
specifier: ^18.2.0
version: 18.2.0
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
+ tailchat-server-sdk:
+ specifier: workspace:^
+ version: link:../packages/sdk
tushan:
- specifier: ^0.2.1
- version: 0.2.1(react-is@18.2.0)(ts-node@10.9.1)
+ specifier: ^0.2.2
+ version: 0.2.2(ts-node@10.9.1)
vite-express:
specifier: ^0.5.4
version: 0.5.4(express@4.18.2)(vite@4.2.0)
devDependencies:
+ '@types/body-parser':
+ specifier: ^1.19.2
+ version: 1.19.2
+ '@types/compression':
+ specifier: ^1.7.2
+ version: 1.7.2
'@types/express':
specifier: ^4.17.15
version: 4.17.15
+ '@types/md5':
+ specifier: ^2.3.2
+ version: 2.3.2
+ '@types/morgan':
+ specifier: ^1.9.4
+ version: 1.9.4
'@types/react':
specifier: 18.0.20
version: 18.0.20
@@ -7062,7 +7101,7 @@ packages:
'@jest/test-result': 27.5.1
'@jest/transform': 27.5.1
'@jest/types': 27.5.1
- '@types/node': 18.15.11
+ '@types/node': 18.15.12
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.8.1
@@ -12108,7 +12147,7 @@ packages:
mongoose: 6.0.15
reflect-metadata: 0.1.13
semver: 7.3.8
- tslib: 2.4.1
+ tslib: 2.5.0
dev: false
/@typegoose/typegoose@9.3.1(mongoose@6.1.1):
@@ -12641,7 +12680,6 @@ packages:
/@types/md5@2.3.2:
resolution: {integrity: sha512-v+JFDu96+UYJ3/UWzB0mEglIS//MZXgRaJ4ubUPwOM0gvLc/kcQ3TWNYwENEK7/EcXGQVrW8h/XqednSjBd/Og==}
- dev: false
/@types/mdast@3.0.10:
resolution: {integrity: sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==}
@@ -12812,7 +12850,7 @@ packages:
/@types/pino@6.3.12:
resolution: {integrity: sha512-dsLRTq8/4UtVSpJgl9aeqHvbh6pzdmjYD3C092SYgLD2TyoCqHpTJk6vp8DvCTGGc7iowZ2MoiYiVUUCcu7muw==}
dependencies:
- '@types/node': 18.15.11
+ '@types/node': 18.15.12
'@types/pino-pretty': 4.7.5
'@types/pino-std-serializers': 2.4.1
sonic-boom: 2.8.0
@@ -19286,6 +19324,17 @@ packages:
mongoose: 6.0.15
dev: false
+ /express-mongoose-ra-json-server@0.1.0(express@4.18.2)(mongoose@6.1.1):
+ resolution: {integrity: sha512-y/Z5zb3RWVsCLgof8MMWMmpyAe2ZUEy6sYqczELOqaCr0cChbI9Y1VNr5DcbwEjqWmEIoU9aqcsURoUse8r/gw==}
+ peerDependencies:
+ express: ^4.0.0 || ^3.0.0
+ mongoose: ^6.0.0 || ^5.0.0
+ dependencies:
+ escape-string-regexp: 4.0.0
+ express: 4.18.2
+ mongoose: 6.1.1
+ dev: false
+
/express@4.18.2:
resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
engines: {node: '>= 0.10.0'}
@@ -23298,7 +23347,7 @@ packages:
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
dependencies:
'@jest/types': 27.5.1
- '@types/node': 18.15.11
+ '@types/node': 18.15.12
chalk: 4.1.2
ci-info: 3.8.0
graceful-fs: 4.2.10
@@ -34261,10 +34310,6 @@ packages:
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
dev: false
- /tslib@2.4.1:
- resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==}
- dev: false
-
/tslib@2.5.0:
resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==}
@@ -34302,8 +34347,8 @@ packages:
domino: 2.1.6
dev: false
- /tushan@0.2.1(react-is@18.2.0)(ts-node@10.9.1):
- resolution: {integrity: sha512-ZLbG1yu6q/Dv7hXoSCjaK/SspsNxew2wx9Y9WUM5uPQ5odL+uVr9N7N4pZ536Sejat2dvm19/QPTSIXn8RHT0g==}
+ /tushan@0.2.2(ts-node@10.9.1):
+ resolution: {integrity: sha512-7Nxw5Ru9kQ8XyPZDgeS0CLRd0cbMs4INDgDt9kSUvqphnFdp7bMrG9FQGh+2RNYG65JIDZGhsR83Uv9wg2Esrg==}
dependencies:
'@arco-design/web-react': 2.47.1(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query': 4.29.3(react-dom@18.2.0)(react@18.2.0)
@@ -34320,7 +34365,7 @@ packages:
qs: 6.11.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
- react-fastify-form: 1.0.12(react@18.2.0)
+ react-is: 18.2.0
react-router: 6.8.1(react@18.2.0)
react-router-dom: 6.8.1(react-dom@18.2.0)(react@18.2.0)
styled-components: 5.3.10(react-dom@18.2.0)(react-is@18.2.0)(react@18.2.0)
@@ -34329,7 +34374,6 @@ packages:
zustand: 4.3.6(immer@9.0.21)(react@18.2.0)
transitivePeerDependencies:
- debug
- - react-is
- react-native
- ts-node
dev: false
diff --git a/server/admin-next/index.html b/server/admin-next/index.html
index 651f8124..6689d9cc 100644
--- a/server/admin-next/index.html
+++ b/server/admin-next/index.html
@@ -4,7 +4,7 @@
-
Tushan
+ Tailchat Admin
diff --git a/server/admin-next/package.json b/server/admin-next/package.json
index 175c5747..1168cc54 100644
--- a/server/admin-next/package.json
+++ b/server/admin-next/package.json
@@ -9,14 +9,27 @@
"build": "vite build"
},
"dependencies": {
+ "@fastify/busboy": "^1.1.0",
+ "body-parser": "^1.20.1",
+ "compression": "^1.7.4",
"express": "^4.18.2",
+ "express-mongoose-ra-json-server": "^0.1.0",
+ "jsonwebtoken": "^8.5.1",
+ "lodash": "^4.17.21",
+ "md5": "^2.3.0",
+ "morgan": "^1.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "tushan": "^0.2.1",
+ "tailchat-server-sdk": "workspace:^",
+ "tushan": "^0.2.2",
"vite-express": "^0.5.4"
},
"devDependencies": {
+ "@types/body-parser": "^1.19.2",
+ "@types/compression": "^1.7.2",
"@types/express": "^4.17.15",
+ "@types/md5": "^2.3.2",
+ "@types/morgan": "^1.9.4",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^3.1.0",
diff --git a/server/admin-next/src/client/auth.ts b/server/admin-next/src/client/auth.ts
index 92658cfe..d79c9b61 100644
--- a/server/admin-next/src/client/auth.ts
+++ b/server/admin-next/src/client/auth.ts
@@ -1,33 +1,66 @@
-import { AuthProvider } from 'tushan';
+import type { AuthProvider } from 'tushan';
+
+export const authStorageKey = 'tailchat:admin:auth';
export const authProvider: AuthProvider = {
login: ({ username, password }) => {
- if (username !== 'tushan' || password !== 'tushan') {
- return Promise.reject();
- }
+ const request = new Request('/admin/api/login', {
+ method: 'POST',
+ body: JSON.stringify({ username, password }),
+ headers: new Headers({ 'Content-Type': 'application/json' }),
+ });
- localStorage.setItem('username', username);
- return Promise.resolve();
+ return fetch(request)
+ .then((response) => {
+ return response.json();
+ })
+ .then((auth) => {
+ console.log(auth);
+ localStorage.setItem(authStorageKey, JSON.stringify(auth));
+ })
+ .catch(() => {
+ throw new Error('Login Failed');
+ });
},
logout: () => {
- localStorage.removeItem('username');
+ localStorage.removeItem(authStorageKey);
return Promise.resolve();
},
- checkAuth: () =>
- localStorage.getItem('username') ? Promise.resolve() : Promise.reject(),
+ checkAuth: () => {
+ const auth = localStorage.getItem(authStorageKey);
+ if (auth) {
+ try {
+ const obj = JSON.parse(auth);
+ if (obj.expiredAt && Date.now() < obj.expiredAt) {
+ return Promise.resolve();
+ }
+ } catch (err) {}
+ }
+
+ return Promise.reject();
+ },
checkError: (error) => {
const status = error.status;
if (status === 401 || status === 403) {
- localStorage.removeItem('username');
+ localStorage.removeItem(authStorageKey);
return Promise.reject();
}
+ // other error code (404, 500, etc): no need to log out
return Promise.resolve();
},
- getIdentity: () =>
- Promise.resolve({
- id: '0',
- fullName: 'Admin',
- }),
+ getIdentity: () => {
+ const { username } = JSON.parse(
+ localStorage.getItem(authStorageKey) ?? '{}'
+ );
+ if (!username) {
+ return Promise.reject();
+ }
+
+ return Promise.resolve({
+ id: username,
+ fullName: username,
+ });
+ },
getPermissions: () => Promise.resolve(''),
};
diff --git a/server/admin-next/src/server/broker.ts b/server/admin-next/src/server/broker.ts
new file mode 100644
index 00000000..961e56d3
--- /dev/null
+++ b/server/admin-next/src/server/broker.ts
@@ -0,0 +1,28 @@
+import { TcBroker, SYSTEM_USERID } from 'tailchat-server-sdk';
+import brokerConfig from '../../../moleculer.config';
+
+const transporter = process.env.TRANSPORTER;
+export const broker = new TcBroker({
+ ...brokerConfig,
+ metrics: false,
+ logger: false,
+ transporter,
+});
+
+broker.start().then(() => {
+ console.log('Linked to Tailchat network, TRANSPORTER: ', transporter);
+});
+
+export function callBrokerAction(
+ actionName: string,
+ params: any,
+ opts?: Record
+): Promise {
+ return broker.call(actionName, params, {
+ ...opts,
+ meta: {
+ ...opts?.meta,
+ userId: SYSTEM_USERID,
+ },
+ });
+}
diff --git a/server/admin-next/src/server/index.ts b/server/admin-next/src/server/index.ts
index 5004960b..4068f843 100644
--- a/server/admin-next/src/server/index.ts
+++ b/server/admin-next/src/server/index.ts
@@ -1,16 +1,58 @@
import express from 'express';
import ViteExpress from 'vite-express';
+import mongoose from 'mongoose';
+import compression from 'compression';
+import morgan from 'morgan';
+import bodyParser from 'body-parser';
+import path from 'path';
+import dotenv from 'dotenv';
+dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
+import { apiRouter } from './router/api';
const app = express();
-const port = Number(process.env.PORT || 13000);
+const port = Number(process.env.ADMIN_PORT || 13000);
-app.get('/hello', (_, res) => {
- res.send('Hello Vite + React + TypeScript!');
+if (!process.env.MONGO_URL) {
+ console.error('Require env: MONGO_URL');
+ process.exit(1);
+}
+
+// 链接数据库
+mongoose.connect(process.env.MONGO_URL, (error: any) => {
+ if (!error) {
+ return console.info('Datebase connected');
+ }
+ console.error('Datebase connect error', error);
+});
+
+app.use(compression());
+app.use(bodyParser());
+
+// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
+app.disable('x-powered-by');
+
+// Remix fingerprints its assets so we can cache forever.
+app.use(
+ '/build',
+ express.static('public/build', { immutable: true, maxAge: '1y' })
+);
+
+// Everything else (like favicon.ico) is cached for an hour. You may want to be
+// more aggressive with this caching.
+app.use(express.static('public', { maxAge: '1h' }));
+
+app.use(morgan('tiny'));
+
+app.use('/admin/api', apiRouter);
+
+app.use((err: any, req: any, res: any, next: any) => {
+ res.status(500);
+ res.json({ error: err.message });
});
ViteExpress.listen(app, port, () =>
console.log(
- `Server is listening on port ${port}, visit with: http://localhost:${port}`
+ `Server is listening on port ${port}, visit with: http://localhost:${port}/admin/`
)
);
diff --git a/server/admin-next/src/server/middleware/auth.ts b/server/admin-next/src/server/middleware/auth.ts
new file mode 100644
index 00000000..ee157f70
--- /dev/null
+++ b/server/admin-next/src/server/middleware/auth.ts
@@ -0,0 +1,39 @@
+import type { NextFunction, Request, Response } from 'express';
+import jwt from 'jsonwebtoken';
+import md5 from 'md5';
+
+export const adminAuth = {
+ username: process.env.ADMIN_USER,
+ password: process.env.ADMIN_PASS,
+};
+
+export const authSecret =
+ (process.env.SECRET || 'tailchat') + md5(JSON.stringify(adminAuth)); // 增加一个md5的盐值确保SECRET没有设置的情况下只修改了用户名密码也不会被人伪造token秘钥
+
+export function auth() {
+ return (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const authorization = req.headers.authorization;
+ if (!authorization) {
+ res.status(401).end('not found authorization in headers');
+ return;
+ }
+
+ const token = authorization.slice('Bearer '.length);
+
+ const payload = jwt.verify(token, authSecret);
+ if (typeof payload === 'string') {
+ res.status(401).end('payload type error');
+ return;
+ }
+ if (payload.platform !== 'admin') {
+ res.status(401).end('Payload invalid');
+ return;
+ }
+
+ next();
+ } catch (err) {
+ res.status(401).end(String(err));
+ }
+ };
+}
diff --git a/server/admin-next/src/server/router/api.ts b/server/admin-next/src/server/router/api.ts
new file mode 100644
index 00000000..898f85bf
--- /dev/null
+++ b/server/admin-next/src/server/router/api.ts
@@ -0,0 +1,90 @@
+import { Router } from 'express';
+import raExpressMongoose from 'express-mongoose-ra-json-server';
+import jwt from 'jsonwebtoken';
+import { callBrokerAction } from '../broker';
+import { adminAuth, auth, authSecret } from '../middleware/auth';
+import { configRouter } from './config';
+import { networkRouter } from './network';
+import { fileRouter } from './file';
+
+const router = Router();
+
+router.post('/login', (req, res) => {
+ if (!adminAuth.username || !adminAuth.password) {
+ res.status(401).end('Server not set env: ADMIN_USER, ADMIN_PASS');
+ return;
+ }
+
+ const { username, password } = req.body;
+
+ if (username === adminAuth.username && password === adminAuth.password) {
+ // 用户名和密码都正确,返回token
+ const token = jwt.sign(
+ {
+ username,
+ platform: 'admin',
+ },
+ authSecret,
+ {
+ expiresIn: '2h',
+ }
+ );
+
+ res.json({
+ username,
+ token: token,
+ expiredAt: new Date().valueOf() + 2 * 60 * 60 * 1000,
+ });
+ } else {
+ res.status(401).end('username or password incorrect');
+ }
+});
+
+router.use('/network', networkRouter);
+router.use('/config', configRouter);
+router.use('/file', fileRouter);
+
+router.use(
+ '/users',
+ auth(),
+ raExpressMongoose(require('../../../../models/user/user').default, {
+ q: ['nickname', 'email'],
+ })
+);
+router.delete('/messages/:id', auth(), async (req, res) => {
+ try {
+ const messageId = req.params.id;
+ await callBrokerAction('chat.message.deleteMessage', {
+ messageId,
+ });
+
+ res.json({ id: messageId });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ message: (err as any).message });
+ }
+});
+router.use(
+ '/messages',
+ auth(),
+ raExpressMongoose(require('../../../../models/chat/message').default, {
+ q: ['content'],
+ allowedRegexFields: ['content'],
+ })
+);
+router.use(
+ '/groups',
+ auth(),
+ raExpressMongoose(require('../../../../models/group/group').default, {
+ q: ['name'],
+ })
+);
+router.use(
+ '/file',
+ auth(),
+ raExpressMongoose(require('../../../../models/file').default, {
+ q: ['objectName'],
+ })
+);
+
+export { router as apiRouter };
diff --git a/server/admin-next/src/server/router/config.ts b/server/admin-next/src/server/router/config.ts
new file mode 100644
index 00000000..134bc0a7
--- /dev/null
+++ b/server/admin-next/src/server/router/config.ts
@@ -0,0 +1,38 @@
+/**
+ * Network 相关接口
+ */
+
+import { Router } from 'express';
+import { broker } from '../broker';
+import { auth } from '../middleware/auth';
+
+const router = Router();
+
+router.get('/client', auth(), async (req, res, next) => {
+ try {
+ const config = await broker.call('config.client');
+
+ res.json({
+ config,
+ });
+ } catch (err) {
+ next(err);
+ }
+});
+
+router.patch('/client', auth(), async (req, res, next) => {
+ try {
+ await broker.call('config.setClientConfig', {
+ key: req.body.key,
+ value: req.body.value,
+ });
+
+ res.json({
+ success: true,
+ });
+ } catch (err) {
+ next(err);
+ }
+});
+
+export { router as configRouter };
diff --git a/server/admin-next/src/server/router/file.ts b/server/admin-next/src/server/router/file.ts
new file mode 100644
index 00000000..114a0748
--- /dev/null
+++ b/server/admin-next/src/server/router/file.ts
@@ -0,0 +1,60 @@
+/**
+ * Network 相关接口
+ */
+
+import { Router } from 'express';
+import { callBrokerAction } from '../broker';
+import { auth } from '../middleware/auth';
+import Busboy from '@fastify/busboy';
+
+const router = Router();
+
+router.put('/upload', auth(), async (req, res) => {
+ const busboy = new Busboy({ headers: req.headers as any });
+
+ const promises: Promise[] = [];
+ busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
+ promises.push(
+ callBrokerAction('file.save', file, {
+ filename: filename,
+ })
+ .then((data) => {
+ console.log(data);
+ return data;
+ })
+ .catch((err) => {
+ file.resume(); // Drain file stream to continue processing form
+ busboy.emit('error', err);
+ return err;
+ })
+ );
+ });
+
+ busboy.on('finish', async () => {
+ /* istanbul ignore next */
+ if (promises.length == 0) {
+ res.status(500).json('File missing in the request');
+ return;
+ }
+
+ try {
+ const files = await Promise.all(promises);
+
+ res.json({ files });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json(String(err));
+ }
+ });
+
+ busboy.on('error', (err) => {
+ console.error(err);
+ req.unpipe(busboy);
+ req.resume();
+ res.status(500).json({ err });
+ });
+
+ req.pipe(busboy);
+});
+
+export { router as fileRouter };
diff --git a/server/admin-next/src/server/router/network.ts b/server/admin-next/src/server/router/network.ts
new file mode 100644
index 00000000..1e56b3f9
--- /dev/null
+++ b/server/admin-next/src/server/router/network.ts
@@ -0,0 +1,37 @@
+/**
+ * Network 相关接口
+ */
+
+import { Router } from 'express';
+import { broker } from '../broker';
+import { auth } from '../middleware/auth';
+import _ from 'lodash';
+
+const router = Router();
+
+router.get('/all', auth(), async (req, res) => {
+ res.json({
+ nodes: Array.from(new Map(broker.registry.nodes.nodes).values()).map(
+ (item) =>
+ _.pick(item, [
+ 'id',
+ 'available',
+ 'local',
+ 'ipList',
+ 'hostname',
+ 'cpu',
+ 'client',
+ ])
+ ),
+ events: broker.registry.events.events.map((item: any) => item.name),
+ services: broker.registry.services.services.map((item: any) => item.name),
+ actions: Array.from(new Map(broker.registry.actions.actions).keys()),
+ });
+});
+
+router.get('/ping', auth(), async (req, res) => {
+ const pong = await broker.ping();
+ res.json(pong);
+});
+
+export { router as networkRouter };
diff --git a/server/admin-next/tsconfig.json b/server/admin-next/tsconfig.json
index 9f29391a..ce2791c2 100644
--- a/server/admin-next/tsconfig.json
+++ b/server/admin-next/tsconfig.json
@@ -7,7 +7,9 @@
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
- "strict": true,
+ "strict": false,
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
"jsx": "react-jsx",
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",