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",