diff --git a/packages/nc-gui/composables/useApi/index.ts b/packages/nc-gui/composables/useApi/index.ts index 0d196de4aa..44cab9d2ee 100644 --- a/packages/nc-gui/composables/useApi/index.ts +++ b/packages/nc-gui/composables/useApi/index.ts @@ -58,6 +58,8 @@ export function useApi({ const nuxtApp = useNuxtApp() + useSocketApi() + /** api instance - with interceptors for token refresh already bound */ const api = useGlobalInstance && !!nuxtApp.$api ? nuxtApp.$api : createApiInstance(apiOptions) @@ -138,6 +140,19 @@ export function useApi({ }, ) + api.instance.interceptors.response.use( + (response) => { + return response + }, + (rs) => { + if (rs?.socket) { + isLoading.value = false + return Promise.resolve(rs) + } + return rs + }, + ) + return { api, isLoading, diff --git a/packages/nc-gui/composables/useApi/interceptors.ts b/packages/nc-gui/composables/useApi/interceptors.ts index a2f4cc5daa..d58ecb43cc 100644 --- a/packages/nc-gui/composables/useApi/interceptors.ts +++ b/packages/nc-gui/composables/useApi/interceptors.ts @@ -7,6 +7,35 @@ export function addAxiosInterceptors(api: Api) { const state = useGlobal() const router = useRouter() const route = $(router.currentRoute) + const { socket, request } = useSocketApi() + + // first interceptor is executed last + api.instance.interceptors.request.use( + async (config) => { + if (socket.value?.connected) { + await request({ + ...config, + headers: { + ...config.headers, + host: window.location.host, + }, + protocol: window.location.protocol, + }) + .then((t: any) => { + throw t + }) + .catch((e: any) => { + if (e?.socket) throw e + }) + } + + return config + }, + (rs) => { + if (rs?.socket) return Promise.resolve(rs) + return rs + }, + ) api.instance.interceptors.request.use((config) => { config.headers['xc-gui'] = 'true' diff --git a/packages/nc-gui/composables/useSocketApi.ts b/packages/nc-gui/composables/useSocketApi.ts new file mode 100644 index 0000000000..35d2aea243 --- /dev/null +++ b/packages/nc-gui/composables/useSocketApi.ts @@ -0,0 +1,78 @@ +import type { Socket } from 'socket.io-client' +import io from 'socket.io-client' +import type { Ref } from 'vue' +import { v4 as uuidv4 } from 'uuid' +import { useGlobal } from '#imports' + +export const useSocketApi = createSharedComposable(() => { + const state = useGlobal() + + const requestQuery: Record = {} + + const socket: Ref = ref() + + const init = async (token: string) => { + try { + if (socket.value) socket.value.disconnect() + + const url = new URL(state.appInfo.value.ncSiteUrl, window.location.href.split(/[?#]/)[0]).href + + socket.value = io(url, { + extraHeaders: { 'xc-auth': token }, + }) + + socket.value.on('connect_error', () => { + socket.value?.disconnect() + }) + + socket.value.on('api', (data) => { + if (data.id in requestQuery) { + const tq = requestQuery[data.id] + if (data?.setCookie) { + for (const ck of data.setCookie) { + // TODO fix event + setCookie({} as any, ck.name, ck.value, ck.options) + } + } + tq.promise.resolve(data) + + delete requestQuery[data.id] + } else { + console.log('Unknown query', data) + } + }) + } catch {} + } + + watch( + state.token, + (newToken, oldToken) => { + if (newToken && newToken !== oldToken) init(newToken) + else if (!newToken) socket.value?.disconnect() + }, + { immediate: true }, + ) + + return { + init, + socket, + request: (data: any): Promise => { + return new Promise((resolve, reject) => { + if (!socket.value) return reject(new Error('Socket not initialized')) + const tq = { + id: uuidv4(), + promise: { + resolve, + reject, + }, + data, + dt: Date.now(), + } + + requestQuery[tq.id] = tq + + socket.value?.emit('api', { id: tq.id, ...tq.data }) + }) + }, + } +}) diff --git a/packages/nc-gui/package-lock.json b/packages/nc-gui/package-lock.json index 45c3f04caf..972d472730 100644 --- a/packages/nc-gui/package-lock.json +++ b/packages/nc-gui/package-lock.json @@ -36,6 +36,7 @@ "sortablejs": "^1.15.0", "tinycolor2": "^1.4.2", "unique-names-generator": "^4.7.1", + "uuid": "^9.0.0", "v3-infinite-loading": "^1.2.2", "validator": "^13.7.0", "vue-dompurify-html": "^3.0.0", @@ -71,6 +72,7 @@ "@types/papaparse": "^5.3.2", "@types/sortablejs": "^1.13.0", "@types/tinycolor2": "^1.4.3", + "@types/uuid": "^9.0.0", "@types/validator": "^13.7.10", "@vitest/ui": "^0.18.0", "@vue/compiler-sfc": "^3.2.37", @@ -95,28 +97,6 @@ "windicss": "^3.5.6" } }, - "../nocodb-sdk": { - "version": "0.104.3", - "license": "AGPL-3.0-or-later", - "dependencies": { - "axios": "^0.21.1", - "jsep": "^1.3.6" - }, - "devDependencies": { - "@typescript-eslint/eslint-plugin": "^4.0.1", - "@typescript-eslint/parser": "^4.0.1", - "cspell": "^4.1.0", - "eslint": "^7.8.0", - "eslint-config-prettier": "^6.11.0", - "eslint-plugin-eslint-comments": "^3.2.0", - "eslint-plugin-functional": "^3.0.2", - "eslint-plugin-import": "^2.22.0", - "eslint-plugin-prettier": "^4.0.0", - "npm-run-all": "^4.1.5", - "prettier": "^2.1.1", - "typescript": "^4.0.2" - } - }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -3204,6 +3184,12 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.7.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.10.tgz", @@ -8544,7 +8530,6 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -11960,8 +11945,21 @@ } }, "node_modules/nocodb-sdk": { - "resolved": "../nocodb-sdk", - "link": true + "version": "0.104.3", + "resolved": "file:../nocodb-sdk", + "license": "AGPL-3.0-or-later", + "dependencies": { + "axios": "^0.21.1", + "jsep": "^1.3.6" + } + }, + "node_modules/nocodb-sdk/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dependencies": { + "follow-redirects": "^1.14.0" + } }, "node_modules/node-abi": { "version": "3.23.0", @@ -16258,6 +16256,14 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v3-infinite-loading": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/v3-infinite-loading/-/v3-infinite-loading-1.2.2.tgz", @@ -20125,6 +20131,12 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "@types/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==", + "dev": true + }, "@types/validator": { "version": "13.7.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.10.tgz", @@ -23943,8 +23955,7 @@ "follow-redirects": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", - "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", - "devOptional": true + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" }, "form-data": { "version": "4.0.0", @@ -26417,22 +26428,20 @@ } }, "nocodb-sdk": { - "version": "file:../nocodb-sdk", + "version": "0.104.3", "requires": { - "@typescript-eslint/eslint-plugin": "^4.0.1", - "@typescript-eslint/parser": "^4.0.1", "axios": "^0.21.1", - "cspell": "^4.1.0", - "eslint": "^7.8.0", - "eslint-config-prettier": "^6.11.0", - "eslint-plugin-eslint-comments": "^3.2.0", - "eslint-plugin-functional": "^3.0.2", - "eslint-plugin-import": "^2.22.0", - "eslint-plugin-prettier": "^4.0.0", - "jsep": "^1.3.6", - "npm-run-all": "^4.1.5", - "prettier": "^2.1.1", - "typescript": "^4.0.2" + "jsep": "^1.3.6" + }, + "dependencies": { + "axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "requires": { + "follow-redirects": "^1.14.0" + } + } } }, "node-abi": { @@ -29634,6 +29643,11 @@ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "dev": true }, + "uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" + }, "v3-infinite-loading": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/v3-infinite-loading/-/v3-infinite-loading-1.2.2.tgz", diff --git a/packages/nc-gui/package.json b/packages/nc-gui/package.json index b7a3e9e7db..0cdc12542d 100644 --- a/packages/nc-gui/package.json +++ b/packages/nc-gui/package.json @@ -59,6 +59,7 @@ "sortablejs": "^1.15.0", "tinycolor2": "^1.4.2", "unique-names-generator": "^4.7.1", + "uuid": "^9.0.0", "v3-infinite-loading": "^1.2.2", "validator": "^13.7.0", "vue-dompurify-html": "^3.0.0", @@ -94,6 +95,7 @@ "@types/papaparse": "^5.3.2", "@types/sortablejs": "^1.13.0", "@types/tinycolor2": "^1.4.3", + "@types/uuid": "^9.0.0", "@types/validator": "^13.7.10", "@vitest/ui": "^0.18.0", "@vue/compiler-sfc": "^3.2.37", diff --git a/packages/nc-gui/pages/signin.vue b/packages/nc-gui/pages/signin.vue index 6912b6eb10..6e1e7b3711 100644 --- a/packages/nc-gui/pages/signin.vue +++ b/packages/nc-gui/pages/signin.vue @@ -13,6 +13,8 @@ const { api, isLoading, error } = useApi({ useGlobalInstance: true }) const { t } = useI18n() +const { init } = useSocketApi() + useSidebar('nc-left-sidebar', { hasSidebar: false }) const formValidator = ref() @@ -52,6 +54,8 @@ async function signIn() { api.auth.signin(form).then(async ({ token }) => { _signIn(token!) + await init(token!) + await navigateTo('/') }) } diff --git a/packages/nocodb/src/lib/meta/api/index.ts b/packages/nocodb/src/lib/meta/api/index.ts index ba154978d8..7392e17a94 100644 --- a/packages/nocodb/src/lib/meta/api/index.ts +++ b/packages/nocodb/src/lib/meta/api/index.ts @@ -58,6 +58,9 @@ import syncSourceApis from './sync/syncSourceApis'; const clients: { [id: string]: Socket } = {}; const jobs: { [id: string]: { last_message: any } } = {}; +const middlewares = []; +const socketApis = []; + export default function (router: Router, server) { initStrategies(router); projectApis(router); @@ -148,9 +151,216 @@ export default function (router: Router, server) { socket.emit('progress', jobs[room].last_message); } }); + socket.on('api', (data) => { + const startAt = process.hrtime(); + + const params = {}; + + const hf = socketApis.find((hf) => { + if (hf.method !== data.method) return false; + const hfpath = hf.path.replace(/\/$/, '').split('/') + const dtpath = data.url.replace(/\?.+/, '').replace(/\/$/, '').split('/') + if (hfpath.length !== dtpath.length) return false; + for (let i = 0; i < hfpath.length; i++) { + if (/^:/.test(hfpath[i])) { + if (!dtpath[i]) return false; + params[hfpath[i].substr(1)] = dtpath[i]; + } else { + if (hfpath[i] !== dtpath[i]) return false; + } + } + return true; + }); + if (hf) { + const headers = Object.entries(data.headers) + .reduce((obj, [key, val]) => { + if (typeof val === 'string') { + obj[key] = val; + } else if (typeof val === 'object') { + if (key === 'common' || key === data.method) { + obj = { ...obj, ...val }; + } + } + return obj; + }, { 'xc-socket': 'true'}); + + data.path = hf.path; + data.query = data.params || {}; + data.params = params; + data.headers = headers; + data.body = data.data; + data.originalUrl = data.url; + + const req = createReq(data); + + const res = createRes((_cd, dt, _hd) => { + const rt: any = { id: data.id, socket: true, data: dt } + if (dt?.setCookie) { + rt.setCookie = dt.setCookie; + delete dt.setCookie; + } + + const ms = process.hrtime(startAt)[0] * 1e3 + process.hrtime(startAt)[1] * 1e-6; + + // :method :url :status - :response-time ms + process.stdout.write(`${req.method } ${req.url} ${res.statusCode} - ${ms.toFixed(3)} ms\n`) + socket.emit('api', rt); + }) + + for (const middleware of middlewares) { + middleware.handle(req, res, () => {}); + } + + hf.handle(req, res, () => {}) + } + }); }); importApis(router, io, jobs); + + socketApis.push(...handleRouter(router)); +} + +function createReq(data: any) { + const req: any = { ...data }; + + req.header = req.get = (p) => req.headers[p]; + + req.login = + req.logIn = function(user, options, done) { + if (typeof options == 'function') { + done = options; + options = {}; + } + options = options || {}; + + var property = 'user'; + if (this._passport && this._passport.instance) { + property = this._passport.instance._userProperty || 'user'; + } + var session = (options.session === undefined) ? true : options.session; + + this[property] = user; + if (session) { + if (!this._passport) { throw new Error('passport.initialize() middleware not in use'); } + if (typeof done != 'function') { throw new Error('req#login requires a callback function'); } + + var self = this; + this._passport.instance._sm.logIn(this, user, function(err) { + if (err) { self[property] = null; return done(err); } + done(); + }); + } else { + done && done(); + } + }; + + req.logout = + req.logOut = function() { + var property = 'user'; + if (this._passport && this._passport.instance) { + property = this._passport.instance._userProperty || 'user'; + } + + this[property] = null; + if (this._passport) { + this._passport.instance._sm.logOut(this); + } + }; + + req.isAuthenticated = function() { + var property = 'user'; + if (this._passport && this._passport.instance) { + property = this._passport.instance._userProperty || 'user'; + } + + return (this[property]) ? true : false; + }; + + req.isUnauthenticated = function() { + return !this.isAuthenticated(); + }; + + return req; +} + +function createRes(callback) { + var res: any = { + _removedHeader: {}, + headersSent: true, + statusMessage: 'OK', + statusCode: 200, + locals: {}, + }; + + var headers = {}; + res.set = res.header = (x, y) => { + if (arguments.length === 2) { + res.setHeader(x, y); + } else { + for (var key in x) { + res.setHeader(key, x[key]); + } + } + return res; + } + res.setHeader = (x, y) => { + headers[x] = y; + headers[x.toLowerCase()] = y; + return res; + }; + res.getHeader = (x) => headers[x]; + + res.redirect = function(_code, url) { + if (Number.isNaN(_code)) { + res.statusCode = 301; + url = _code; + } else { + res.statusCode = _code; + } + res.setHeader("Location", url); + res.end(); + }; + res.status = res.sendStatus = function(number) { + res.statusCode = number; + return res; + }; + + res.cookie = function(name, value, options) { + if (callback) callback(res.statusCode, { setCookie: [ { name, value, options} ] }, {}) + } + + res.end = res.send = res.write = res.json = function(data) { + if (callback) callback(res.statusCode, data, headers); + }; + + return res; +} + +function handleRouter(router: any) { + if (!router.stack) return []; + const tempRoutes = []; + for (const layer of router.stack) { + if (layer.name !== 'router' && layer.name !== 'logger' && layer.route === undefined && typeof layer.handle === 'function') { + const rt = { + method: 'MIDDLEWARE', + handle: layer.handle, + } + middlewares.push(rt); + } + if (layer.name === 'bound dispatch' && typeof layer.route.path === 'string') { + const rt = { + method: Object.keys(layer.route.methods)[0], + path: layer.route.path, + stack: layer.route.stack, + handle: layer.handle, + } + tempRoutes.push(rt); + } else if (layer.name === 'router') { + tempRoutes.push(...handleRouter(layer.handle)); + } + } + return tempRoutes; } function getHash(str) {