From 60b966f59e291a39e48c28508b6d3b87a15d242e Mon Sep 17 00:00:00 2001 From: Junyi Date: Fri, 3 Dec 2021 07:31:22 +0800 Subject: [PATCH] Fix: plugin-file-manager (#111) * fix: test cases * fix env used by file manager * default value of process.env.LOCAL_STORAGE_BASE_URL * use workspace dependencies * refactor some env to adapt more scenario * fix reviewing issues * fix env default value * update umi config * bugfix * fix shared cache Co-authored-by: chenos --- .env.example | 19 ++- .github/workflows/vercel.yml | 11 +- .umirc.ts | 17 +- docs/examples/demos/demo1.tsx | 6 +- packages/app/.umirc.ts | 18 ++- packages/app/src/apis/index.ts | 4 +- packages/client/src/constate/Client.tsx | 2 +- packages/client/src/demos/demo1.tsx | 6 +- packages/client/src/schemas/index.tsx | 4 +- packages/client/src/schemas/upload/index.tsx | 2 +- .../templates/AppGenerator/.env.tpl | 12 ++ .../templates/AppGenerator/.umirc.ts | 14 +- .../templates/AppGenerator/src/apis/index.ts | 2 +- packages/plugin-collections/src/server.ts | 1 + packages/plugin-file-manager/package.json | 2 - .../src/__tests__/action.test.ts | 102 ++++++++---- .../src/__tests__/index.ts | 152 ++---------------- .../src/__tests__/tables/users.ts | 11 ++ .../plugin-file-manager/src/actions/upload.ts | 17 +- packages/plugin-file-manager/src/server.ts | 19 ++- .../plugin-file-manager/src/storages/local.ts | 105 ++++++++---- packages/resourcer/src/utils.ts | 2 +- packages/test/src/mockServer.ts | 4 +- packages/utils/src/index.ts | 11 +- packages/utils/src/merge.ts | 9 ++ packages/utils/src/umiConfig.ts | 36 +++++ yarn.lock | 8 - 27 files changed, 312 insertions(+), 284 deletions(-) create mode 100644 packages/utils/src/merge.ts create mode 100644 packages/utils/src/umiConfig.ts diff --git a/.env.example b/.env.example index 133be3fb7..c2b214327 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ ADMINER_PORT=8080 DB_MYSQL_PORT=3306 DB_POSTGRES_PORT=5432 VERDACCIO_PORT=4873 + APP_PORT=13001 -API_PORT=13002 ########## NOCOBASE ENV ########## @@ -27,7 +27,19 @@ DB_LOG_SQL= # API & APP NOCOBASE_ENV= -APP_USE_STATIC_SERVER=true + +API_PORT=13002 + +# api base path endpoint for app(web) +API_BASE_PATH=/api/ + +# api server access point for app(web when build) +API_BASE_URL= + +# api server access point will be proxied from app(umi dev) +PROXY_TARGET_URL= + +# app(web) assets APP_DIST=packages/app/dist # ADMIN USER (Initialization only) @@ -42,7 +54,8 @@ STORAGE_TYPE=local # LOCAL STORAGE LOCAL_STORAGE_USE_STATIC_SERVER=true -LOCAL_STORAGE_BASE_URL=http://localhost:13002 +LOCAL_STORAGE_BASE_URL=/uploads # same origin +# LOCAL_STORAGE_BASE_URL=http://your.cdn.domain/pathname # cdn or other origin driven # ALI OSS STORAGE ALI_OSS_STORAGE_BASE_URL= diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml index 1f14796f2..379f82e47 100644 --- a/.github/workflows/vercel.yml +++ b/.github/workflows/vercel.yml @@ -24,10 +24,13 @@ jobs: restore-keys: | ${{ runner.os }}-node- - - run: yarn install - - run: yarn bootstrap - - run: echo "API_URL=${{ secrets.API_URL }}" >> .env - - run: yarn build-docs + - name: Build + env: + API_BASE_URL: ${{ secrets.API_ORIGIN }}${{ secrets.API_BASE_PATH }} + run: | + yarn install + yarn bootstrap + yarn build-docs - name: Deploy uses: amondnet/vercel-action@v19 diff --git a/.umirc.ts b/.umirc.ts index 9dd66e4f8..28924fe9e 100644 --- a/.umirc.ts +++ b/.umirc.ts @@ -1,23 +1,20 @@ import { defineConfig } from 'dumi'; - -const baseUrl = `http://localhost:${process.env.API_PORT || '13001'}/`; -console.log('baseUrl', baseUrl); +import { getUmiConfig } from '@nocobase/utils'; process.env.MFSU_AD = 'none'; +const umiConfig = getUmiConfig(); + export default defineConfig({ title: 'NocoBase', hash: true, define: { - 'process.env.API_URL': process.env.API_URL || `${baseUrl}api/`, - 'process.env.API_HOSTNAME': process.env.API_HOSTNAME, + ...umiConfig.define, }, + // only proxy when using `umi dev` + // if the assets are built, will not proxy proxy: { - '/api/': { - target: baseUrl, - changeOrigin: true, - pathRewrite: { '^/api/': '/api/' }, - }, + ...umiConfig.proxy, }, resolve: { includes: ['docs', 'packages/client/src/schemas'], diff --git a/docs/examples/demos/demo1.tsx b/docs/examples/demos/demo1.tsx index 7e354c8b9..023ee915e 100644 --- a/docs/examples/demos/demo1.tsx +++ b/docs/examples/demos/demo1.tsx @@ -15,7 +15,7 @@ import { UseRequestProvider } from 'ahooks'; import { extend } from 'umi-request'; const request = extend({ - prefix: process.env.API_URL, + prefix: process.env.API_BASE_URL, timeout: 30000, }); @@ -33,10 +33,6 @@ const client = new ClientSDK({ request, }); -console.log('process.env.API_URL', process.env.API_URL); - -// console.log = () => {} - const RouteSwitch = createRouteSwitch({ components: { AdminLayout, diff --git a/packages/app/.umirc.ts b/packages/app/.umirc.ts index e8c93e559..b7b135c2c 100644 --- a/packages/app/.umirc.ts +++ b/packages/app/.umirc.ts @@ -1,25 +1,27 @@ +import path from 'path'; import { defineConfig } from 'umi'; import dotenv from 'dotenv'; -import path from 'path'; +import { getUmiConfig } from '@nocobase/utils'; dotenv.config({ path: path.resolve(__dirname, '../../.env'), }); +process.env.MFSU_AD = 'none'; + +const umiConfig = getUmiConfig(); + export default defineConfig({ nodeModulesTransform: { type: 'none', }, define: { - 'process.env.API_URL': process.env.API_URL, - 'process.env.API_PORT': process.env.API_PORT, + ...umiConfig.define, }, + // only proxy when using `umi dev` + // if the assets are built, will not proxy proxy: { - '/api': { - 'target': `http://localhost:${process.env.API_PORT}/`, - 'changeOrigin': true, - 'pathRewrite': { '^/api': '/api' }, - }, + ...umiConfig.proxy, }, routes: [ { path: '/', exact: false, component: '@/pages/index' }, diff --git a/packages/app/src/apis/index.ts b/packages/app/src/apis/index.ts index 2ac788d90..db9054b42 100644 --- a/packages/app/src/apis/index.ts +++ b/packages/app/src/apis/index.ts @@ -42,7 +42,7 @@ const api = new Server({ }, }, resourcer: { - prefix: '/api', + prefix: process.env.API_BASE_PATH || '/api/', }, }); @@ -70,7 +70,7 @@ for (const plugin of plugins) { api.plugin( require(`@nocobase/plugin-client/${libDir}/server`).default, { - dist: path.resolve(process.cwd(), './dist'), + dist: process.env.APP_DIST || path.resolve(process.cwd(), './dist'), lang: process.env.APP_LANG, // importDemo: true, }); diff --git a/packages/client/src/constate/Client.tsx b/packages/client/src/constate/Client.tsx index 26f1c6b71..29cc1e8e8 100644 --- a/packages/client/src/constate/Client.tsx +++ b/packages/client/src/constate/Client.tsx @@ -7,7 +7,7 @@ import { ClientSDK } from '../ClientSDK'; import { extend } from 'umi-request'; const request = extend({ - prefix: process.env.API_URL, + prefix: process.env.API_BASE_URL, timeout: 30000, }); diff --git a/packages/client/src/demos/demo1.tsx b/packages/client/src/demos/demo1.tsx index d9b3a9792..b16290018 100644 --- a/packages/client/src/demos/demo1.tsx +++ b/packages/client/src/demos/demo1.tsx @@ -17,7 +17,7 @@ import { I18nextProvider } from 'react-i18next'; import i18n from '../i18n'; const request = extend({ - prefix: process.env.API_URL, + prefix: process.env.API_BASE_URL, timeout: 30000, }); @@ -26,7 +26,7 @@ request.use(async (ctx, next) => { const token = localStorage.getItem('NOCOBASE_TOKEN'); if (token) { headers['Authorization'] = `Bearer ${token}`; - headers['X-Hostname'] = process.env.API_HOSTNAME; + headers['X-Hostname'] = window.location.hostname; } await next(); }); @@ -35,8 +35,6 @@ const client = new ClientSDK({ request, }); -console.log('process.env.API_URL', process.env.API_URL); - // console.log = () => {} const RouteSwitch = createRouteSwitch({ diff --git a/packages/client/src/schemas/index.tsx b/packages/client/src/schemas/index.tsx index 4fe42846f..f5b8163a5 100644 --- a/packages/client/src/schemas/index.tsx +++ b/packages/client/src/schemas/index.tsx @@ -27,7 +27,7 @@ export function useDefaultAction() { } export const request = extend({ - prefix: process.env.API_URL, + prefix: process.env.API_BASE_URL, timeout: 30000, }); @@ -40,8 +40,6 @@ request.use(async (ctx, next) => { await next(); }); -console.log('process.env.API_URL', process.env.API_URL); - export async function createOrUpdateCollection(data: any) { return await request('collections:createOrUpdate', { method: 'post', diff --git a/packages/client/src/schemas/upload/index.tsx b/packages/client/src/schemas/upload/index.tsx index f72a5d659..b44841b9f 100644 --- a/packages/client/src/schemas/upload/index.tsx +++ b/packages/client/src/schemas/upload/index.tsx @@ -407,7 +407,7 @@ Upload.Attachment = connect( multiple={multiple} listType={'picture-card'} fileList={fileList} - action={`${process.env.API_URL}attachments:upload`} + action={`${process.env.API_BASE_URL}attachments:upload`} onChange={(info) => { setSync(false); if (multiple) { diff --git a/packages/create-nocobase-app/templates/AppGenerator/.env.tpl b/packages/create-nocobase-app/templates/AppGenerator/.env.tpl index f8ae03ddd..cca017953 100644 --- a/packages/create-nocobase-app/templates/AppGenerator/.env.tpl +++ b/packages/create-nocobase-app/templates/AppGenerator/.env.tpl @@ -22,6 +22,18 @@ DB_STORAGE=db.sqlite # set to 'on' to enable log DB_LOG_SQL= +# API & APP + +NOCOBASE_ENV= + +# api server access point will be proxied from app(web when `umi dev`) +API_ORIGIN_DEV=http://localhost:13002 +# api server access point for app(web when build) +API_ORIGIN= + +# api base path endpoint for app(web) +API_BASE_PATH=/api/ + # ADMIN USER (Initialization only) ADMIN_EMAIL=admin@nocobase.com diff --git a/packages/create-nocobase-app/templates/AppGenerator/.umirc.ts b/packages/create-nocobase-app/templates/AppGenerator/.umirc.ts index ed96bdd56..f9947d0a0 100644 --- a/packages/create-nocobase-app/templates/AppGenerator/.umirc.ts +++ b/packages/create-nocobase-app/templates/AppGenerator/.umirc.ts @@ -1,11 +1,14 @@ import { defineConfig } from 'umi'; import dotenv from 'dotenv'; import path from 'path'; +import { getUmiConfig } from '@nocobase/utils'; dotenv.config({ path: path.resolve(__dirname, './.env'), }); +const umiConfig = getUmiConfig(); + process.env.MFSU_AD = 'none'; export default defineConfig({ @@ -14,15 +17,12 @@ export default defineConfig({ type: 'none', }, define: { - 'process.env.API_URL': process.env.API_URL || `/api/`, - 'process.env.API_PORT': process.env.API_PORT || '13001', + ...umiConfig.define, }, + // only proxy when using `umi dev` + // if the assets are built, will not proxy proxy: { - '/api': { - target: `http://localhost:${process.env.API_PORT || '13001'}/`, - changeOrigin: true, - pathRewrite: { '^/api': '/api' }, - }, + ...umiConfig.proxy, }, routes: [{ path: '/', exact: false, component: '@/pages/index' }], fastRefresh: {}, diff --git a/packages/create-nocobase-app/templates/AppGenerator/src/apis/index.ts b/packages/create-nocobase-app/templates/AppGenerator/src/apis/index.ts index 6839c44cc..18901d80f 100644 --- a/packages/create-nocobase-app/templates/AppGenerator/src/apis/index.ts +++ b/packages/create-nocobase-app/templates/AppGenerator/src/apis/index.ts @@ -7,7 +7,7 @@ const start = Date.now(); const api = new Application({ database: dbConfig[process.env.DB_DIALECT || 'sqlite'], resourcer: { - prefix: '/api', + prefix: process.env.API_BASE_PATH || '/api/', }, }); diff --git a/packages/plugin-collections/src/server.ts b/packages/plugin-collections/src/server.ts index 6d17c671b..c3a17a8b4 100644 --- a/packages/plugin-collections/src/server.ts +++ b/packages/plugin-collections/src/server.ts @@ -18,6 +18,7 @@ export default { this.app.on('beforeStart', async () => { await database.getModel('collections').load({ + // TODO(bug): quick fix just comment out skipExisting: true, }); }); diff --git a/packages/plugin-file-manager/package.json b/packages/plugin-file-manager/package.json index 643e48885..a565e2b2c 100644 --- a/packages/plugin-file-manager/package.json +++ b/packages/plugin-file-manager/package.json @@ -7,13 +7,11 @@ "@koa/multer": "^3.0.0", "@nocobase/server": "^0.5.0-alpha.34", "ali-oss": "^6.12.0", - "koa-mount": "^4.0.0", "koa-static": "^5.0.0", "mime-match": "^1.0.2", "multer": "^1.4.2" }, "devDependencies": { - "@nocobase/test": "^0.5.0-alpha.34", "@types/multer": "^1.4.5" }, "gitHead": "e7df1f93c4e23b9a666d99ee7372c02bdaec97c4" diff --git a/packages/plugin-file-manager/src/__tests__/action.test.ts b/packages/plugin-file-manager/src/__tests__/action.test.ts index ec183e317..f280af173 100644 --- a/packages/plugin-file-manager/src/__tests__/action.test.ts +++ b/packages/plugin-file-manager/src/__tests__/action.test.ts @@ -1,39 +1,52 @@ +import { promisify } from 'util'; import { promises as fs } from 'fs'; import path from 'path'; -import qs from 'qs'; -import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants'; -import { getApp, getAgent, getAPI } from '.'; -const DEFAULT_LOCAL_BASE_URL = process.env.LOCAL_STORAGE_BASE_URL || `http://localhost:${process.env.API_PORT}/uploads`; +import { generatePrefixByPath } from '@nocobase/test'; + +import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants'; +import { getApp, requestFile } from '.'; + +const { + API_PORT, + LOCAL_STORAGE_BASE_URL +} = process.env; + +const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `http://localhost:${API_PORT}/uploads`; describe('action', () => { let app; let agent; - let api; let db; + let http; beforeEach(async () => { app = await getApp(); - agent = getAgent(app); - api = getAPI(app); + agent = app.agent(); + http = app.listen(API_PORT); db = app.db; - const Storage = app.db.getModel('storages'); + const Storage = db.getModel('storages'); await Storage.create({ - name: `local_${Date.now().toString(36)}`, + name: `local1_${generatePrefixByPath()}`, type: STORAGE_TYPE_LOCAL, baseUrl: DEFAULT_LOCAL_BASE_URL, default: true }); }); - afterEach(() => db.close()); + afterEach(async () => { + await promisify(cb => http.close(cb))(); + await db.close(); + }); describe('direct attachment', () => { it('upload file should be ok', async () => { - const response = await agent - .post('/api/attachments:upload') - .attach(FILE_FIELD_NAME, path.resolve(__dirname, './files/text.txt')); + const { body } = await agent + .resource('attachments') + .upload({ + [FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt') + }); const matcher = { title: 'text', @@ -44,14 +57,15 @@ describe('action', () => { meta: {}, storage_id: 1, }; + // 文件上传和解析是否正常 - expect(response.body).toMatchObject(matcher); + expect(body.data).toMatchObject(matcher); // 文件的 url 是否正常生成 - expect(response.body.url).toBe(`${DEFAULT_LOCAL_BASE_URL}${response.body.path}/${response.body.filename}`); + expect(body.data.url).toBe(`${DEFAULT_LOCAL_BASE_URL}${body.data.path}/${body.data.filename}`); const Attachment = db.getModel('attachments'); const attachment = await Attachment.findOne({ - where: { id: response.body.id }, + where: { id: body.data.id }, include: ['storage'] }); const storage = attachment.get('storage'); @@ -73,10 +87,9 @@ describe('action', () => { // 文件是否保存到指定路径 expect(file.toString()).toBe('Hello world!\n'); - const content = await agent.get(`/uploads${attachment.path}/${attachment.filename}`); // 通过 url 是否能正确访问 - // TODO(bug) - // expect(content.text).toBe('Hello world!\n'); + const content = await requestFile(attachment.url, agent); + expect(content.text).toBe('Hello world!\n'); }); }); @@ -84,9 +97,9 @@ describe('action', () => { it('upload with associatedKey, fail as 400 because file mimetype does not match', async () => { const User = db.getModel('users'); const user = await User.create(); - const response = await api.resource('users.avatar').upload({ + const response = await agent.resource('users.avatar').upload({ associatedKey: user.id, - filePath: './files/text.txt' + file: path.resolve(__dirname, './files/text.txt') }); expect(response.status).toBe(400); }); @@ -94,9 +107,9 @@ describe('action', () => { it('upload with associatedKey', async () => { const User = db.getModel('users'); const user = await User.create(); - const response = await api.resource('users.avatar').upload({ + const { body } = await agent.resource('users.avatar').upload({ associatedKey: user.id, - filePath: './files/image.png', + file: path.resolve(__dirname, './files/image.png'), values: { width: 100, height: 100 } }); const matcher = { @@ -112,7 +125,7 @@ describe('action', () => { storage_id: 1, }; // 上传正常返回 - expect(response.body).toMatchObject(matcher); + expect(body.data).toMatchObject(matcher); // 由于初始没有外键,无法获取 // await user.getAvatar() @@ -120,13 +133,44 @@ describe('action', () => { include: ['avatar'] }); // 外键更新正常 - expect(updatedUser.get('avatar').id).toBe(response.body.id); + expect(updatedUser.get('avatar').id).toBe(body.data.id); + }); + + it('upload to assoiciated field and storage with full base url should be ok', async () => { + const BASE_URL = `http://localhost:${process.env.API_PORT}/another-uploads`; + const storageName = 'local_private'; + const urlPath = 'test/path'; + const Storage = db.getModel('storages'); + // 动态添加 storage + await Storage.create({ + name: storageName, + type: STORAGE_TYPE_LOCAL, + path: urlPath, + baseUrl: BASE_URL, + options: { + documentRoot: 'uploads/another' + } + }); + + const User = db.getModel('users'); + const user = await User.create(); + const { body } = await agent.resource('users.pubkeys').upload({ + associatedKey: user.id, + file: path.resolve(__dirname, './files/text.txt'), + values: {} + }); + + // 文件的 url 是否正常生成 + expect(body.data.url).toBe(`${BASE_URL}/${urlPath}/${body.data.filename}`); + console.log(body.data.url); + const content = await requestFile(body.data.url, agent); + expect(content.text).toBe('Hello world!\n'); }); // TODO(bug): 没有 associatedKey 时路径解析资源名称不对,无法进入 action it.skip('upload without associatedKey', async () => { - const response = await api.resource('users.avatar').upload({ - filePath: './files/image.png', + const { body } = await agent.resource('users.avatar').upload({ + file: path.resolve(__dirname, './files/image.png'), values: { width: 100, height: 100 } }); const matcher = { @@ -136,13 +180,13 @@ describe('action', () => { size: 255, mimetype: 'image/png', // TODO(optimize): 可以考虑使用 qs 的 decoder 来进行类型解析 - // see: https://github.com/ljharb/qs/issues/91 + // @see: https://github.com/ljharb/qs/issues/91 // 或考虑使用 query-string 库的 parseNumbers 等配置项 meta: { width: '100', height: '100' }, storage_id: 1, }; // 上传返回正常 - expect(response.body).toMatchObject(matcher); + expect(body.data).toMatchObject(matcher); }); }); }); diff --git a/packages/plugin-file-manager/src/__tests__/index.ts b/packages/plugin-file-manager/src/__tests__/index.ts index b446f4f93..eba9b05e5 100644 --- a/packages/plugin-file-manager/src/__tests__/index.ts +++ b/packages/plugin-file-manager/src/__tests__/index.ts @@ -1,72 +1,19 @@ import path from 'path'; -import qs from 'qs'; import supertest from 'supertest'; -import bodyParser from 'koa-bodyparser'; -import { Dialect } from 'sequelize'; -import Database from '@nocobase/database'; -import { actions, middlewares } from '@nocobase/actions/src'; -import { Application } from '@nocobase/server/src'; -import middleware from '@nocobase/server/src/middleware' +import { MockServer, mockServer } from '@nocobase/test'; + import plugin from '../server'; -import { FILE_FIELD_NAME } from '../constants'; -function getTestKey() { - const { id } = require.main; - const key = id - .replace(`${process.env.PWD}/packages`, '') - .replace(/src\/__tests__/g, '') - .replace('.test.ts', '') - .replace(/[^\w]/g, '_') - .replace(/_+/g, '_'); - return key -} - -const config = { - username: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE, - host: process.env.DB_HOST, - port: Number.parseInt(process.env.DB_PORT, 10), - dialect: process.env.DB_DIALECT as Dialect, - define: { - hooks: { - beforeCreate(model, options) { - - }, - }, - }, - logging: process.env.DB_LOG_SQL === 'on', - sync: { - force: true, - alter: { - drop: true, - }, - }, - hooks: { - beforeDefine(columns, model) { - model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`; +export async function getApp(options = {}): Promise { + const app = mockServer({ + ...options, + cors: { + origin: '*' } - }, -}; - -export function getDatabase() { - return new Database(config); -}; - -export async function getApp() { - const app = new Application({ - database: config, - resourcer: { - prefix: '/api', - }, }); - app.resourcer.use(middlewares.associated); - app.resourcer.registerActionHandlers({ ...actions.associate, ...actions.common }); - app.registerPlugin({ - 'collections': [path.resolve(__dirname, '../../../plugin-collections')], - 'file-manager': [plugin] - }); - await app.loadPlugins(); + app.plugin(require('@nocobase/plugin-collections/src/server').default); + app.plugin(plugin); + await app.load(); app.db.import({ directory: path.resolve(__dirname, './tables') }); @@ -75,80 +22,13 @@ export async function getApp() { } catch (error) { console.error(error); } - app.use(async (ctx, next) => { - ctx.db = app.db; - await next(); - }); - app.use(bodyParser()); - app.use(middleware({ - prefix: '/api', - resourcer: app.resourcer, - database: app.db, - })); + return app; } -interface ActionParams { - resourceKey?: string | number; - // resourceName?: string; - // associatedName?: string; - associatedKey?: string | number; - fields?: any; - filter?: any; - values?: any; - [key: string]: any; -} - -interface Handler { - get: (params?: ActionParams) => Promise; - list: (params?: ActionParams) => Promise; - create: (params?: ActionParams) => Promise; - update: (params?: ActionParams) => Promise; - destroy: (params?: ActionParams) => Promise; - [name: string]: (params?: ActionParams) => Promise; -} - -export interface Agent { - resource: (name: string) => Handler; -} - -export function getAgent(app: Application) { - return supertest.agent(app.callback()); -} - -export function getAPI(app: Application) { - const agent = getAgent(app); - return { - resource(name: string): any { - return new Proxy({}, { - get(target, method: string, receiver) { - return (params: ActionParams = {}) => { - const { associatedKey, resourceKey, values = {}, filePath, ...restParams } = params; - let url = `/api/${name}`; - if (associatedKey) { - url = `/api/${name.split('.').join(`/${associatedKey}/`)}`; - } - url += `:${method as string}`; - if (resourceKey) { - url += `/${resourceKey}`; - } - - switch (method) { - case 'upload': - return agent.post(`${url}?${qs.stringify(restParams)}`) - .attach(FILE_FIELD_NAME, path.resolve(__dirname, filePath)) - .field(values); - - case 'list': - case 'get': - return agent.get(`${url}?${qs.stringify(restParams)}`); - - default: - return agent.post(`${url}?${qs.stringify(restParams)}`).send(values); - } - } - } - }); - } - }; +// because the app in supertest is using a random port +export function requestFile(url, agent) { + return path.isAbsolute(url) + ? agent.get(url) + : supertest.agent(url).get(''); } diff --git a/packages/plugin-file-manager/src/__tests__/tables/users.ts b/packages/plugin-file-manager/src/__tests__/tables/users.ts index 79af3dab7..7d33c857e 100644 --- a/packages/plugin-file-manager/src/__tests__/tables/users.ts +++ b/packages/plugin-file-manager/src/__tests__/tables/users.ts @@ -19,6 +19,17 @@ export default { } } }, + { + type: 'belongsToMany', + name: 'pubkeys', + target: 'attachments', + attachment: { + storage: 'local_private', + rules: { + mimetype: ['text/*'] + } + } + }, { type: 'belongsToMany', name: 'photos', diff --git a/packages/plugin-file-manager/src/actions/upload.ts b/packages/plugin-file-manager/src/actions/upload.ts index 6b29af05c..24e7341d8 100644 --- a/packages/plugin-file-manager/src/actions/upload.ts +++ b/packages/plugin-file-manager/src/actions/upload.ts @@ -46,10 +46,10 @@ export async function middleware(ctx: Context, next: Next) { // 如果没有包含关联,则直接按默认文件上传至默认存储引擎 storage = await StorageModel.findOne({ where: { default: true } }); } else { - const fieldOptions = resourceField.getOptions(); + const { attachment = {} } = resourceField.getOptions(); storage = await StorageModel.findOne({ - where: fieldOptions.defaultValue - ? { [StorageModel.primaryKeyAttribute]: fieldOptions.defaultValue } + where: attachment.storage + ? { name: attachment.storage } : { default: true } }); } @@ -75,8 +75,8 @@ export async function middleware(ctx: Context, next: Next) { }, storage: makeStorage(storage), }; - const upload = multer(multerOptions); - return upload.single(FILE_FIELD_NAME)(ctx, next); + const uploader = multer(multerOptions); + return uploader.single(FILE_FIELD_NAME)(ctx, next); }; export async function action(ctx: Context, next: Next) { @@ -86,6 +86,11 @@ export async function action(ctx: Context, next: Next) { } const { associatedName, associatedKey, resourceField } = ctx.action.params; const extname = path.extname(file.filename); + const urlPath = storage.path + ? (storage.path.startsWith('/') + ? storage.path + : `/${storage.path}`) + : ''; const data = { title: file.originalname.replace(extname, ''), filename: file.filename, @@ -94,7 +99,7 @@ export async function action(ctx: Context, next: Next) { path: storage.path, size: file.size, // 直接缓存起来 - url: `${storage.baseUrl}${storage.path}/${file.filename}`, + url: `${storage.baseUrl}${urlPath}/${file.filename}`, mimetype: file.mimetype, // @ts-ignore meta: ctx.request.body diff --git a/packages/plugin-file-manager/src/server.ts b/packages/plugin-file-manager/src/server.ts index f8fcf94ab..4691a0af7 100644 --- a/packages/plugin-file-manager/src/server.ts +++ b/packages/plugin-file-manager/src/server.ts @@ -8,8 +8,9 @@ import { middleware as uploadMiddleware, } from './actions/upload'; import { - middleware as localMiddleware, + middleware as localMiddleware } from './storages/local'; +import { STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_LOCAL } from './constants'; export default { name: 'file-manager', @@ -24,21 +25,25 @@ export default { // 暂时中间件只能通过 use 加进来 resourcer.use(uploadMiddleware); resourcer.registerActionHandler('upload', uploadAction); - localMiddleware(this.app); + + if (process.env.NOCOBASE_ENV !== 'production' + && process.env.LOCAL_STORAGE_USE_STATIC_SERVER) { + await localMiddleware(this.app); + } const Storage = database.getModel('storages'); - + this.app.on('db.init', async () => { await Storage.create({ title: '本地存储', name: `local`, - type: 'local', - baseUrl: process.env.LOCAL_STORAGE_BASE_URL, - default: process.env.STORAGE_TYPE === 'local', + type: STORAGE_TYPE_LOCAL, + baseUrl: process.env.LOCAL_STORAGE_BASE_URL || `http://localhost:${process.env.API_PORT}/uploads`, + default: process.env.STORAGE_TYPE === STORAGE_TYPE_LOCAL, }); await Storage.create({ name: `ali-oss`, - type: 'ali-oss', + type: STORAGE_TYPE_ALI_OSS, baseUrl: process.env.ALI_OSS_STORAGE_BASE_URL, options: { region: process.env.ALI_OSS_REGION, diff --git a/packages/plugin-file-manager/src/storages/local.ts b/packages/plugin-file-manager/src/storages/local.ts index 53ad57f8d..a277f33c2 100644 --- a/packages/plugin-file-manager/src/storages/local.ts +++ b/packages/plugin-file-manager/src/storages/local.ts @@ -3,43 +3,71 @@ import { URL } from 'url'; import mkdirp from 'mkdirp'; import multer from 'multer'; import serve from 'koa-static'; -import mount from 'koa-mount'; import { STORAGE_TYPE_LOCAL } from '../constants'; import { getFilename } from '../utils'; +// use koa-mount match logic +function match(basePath: string, pathname: string): boolean { + if (!pathname.startsWith(basePath)) { + return false; + } + + const newPath = pathname.replace(basePath, '') || '/'; + if (basePath.slice(-1) === '/') { + return true; + } + + return newPath[0] === '/'; +} + +async function update(app, storages) { + const StorageModel = app.db.getModel('storages'); + + const items = await StorageModel.findAll({ + where: { + type: STORAGE_TYPE_LOCAL, + } + }); + + const primaryKey = StorageModel.primaryKeyAttribute; + + storages.clear(); + for (const storage of items) { + storages.set(storage[primaryKey], storage); + } +} + +function createLocalServerUpdateHook(app, storages) { + return async function (row) { + if (row.get('type') === STORAGE_TYPE_LOCAL) { + await update(app, storages); + } + } +} + export function getDocumentRoot(storage): string { const { documentRoot = 'uploads' } = storage.options || {}; // TODO(feature): 后面考虑以字符串模板的方式使用,可注入 req/action 相关变量,以便于区分文件夹 return path.resolve(path.isAbsolute(documentRoot) ? documentRoot - : path.join(process.cwd(), documentRoot), storage.path); + : path.join(process.cwd(), documentRoot)); } -// TODO(optimize): 初始化的时机不应该放在中间件里 -export function middleware(app) { - if (process.env.NOCOBASE_ENV === 'production') { - return; - } +export async function middleware(app, options?) { + const LOCALHOST = `http://localhost:${process.env.API_PORT}`; - const storages = new Map(); const StorageModel = app.db.getModel('storages'); + const storages = new Map(); - return app.use(async function (ctx, next) { - const items = await StorageModel.findAll({ - where: { - type: STORAGE_TYPE_LOCAL, - } - }); + const localServerUpdateHook = createLocalServerUpdateHook(app, storages); + StorageModel.addHook('afterCreate', localServerUpdateHook); + StorageModel.addHook('afterUpdate', localServerUpdateHook); + StorageModel.addHook('afterDestroy', localServerUpdateHook); - const primaryKey = StorageModel.primaryKeyAttribute; - - for (const storage of items) { - - // TODO:未解决 storage 更新问题 - if (storages.has(storage[primaryKey])) { - continue; - } + await update(app, storages); + app.use(async function (ctx, next) { + for (const storage of storages.values()) { const baseUrl = storage.get('baseUrl'); let url; @@ -47,33 +75,40 @@ export function middleware(app) { url = new URL(baseUrl); } catch (e) { url = { - protocol: 'http:', - hostname: 'localhost', - port: process.env.API_PORT, pathname: baseUrl }; } // 以下情况才认为当前进程所应该提供静态服务 // 否则都忽略,交给其他 server 来提供(如 nginx/cdn 等) - // TODO(bug): https、端口 80 默认值和其他本地 ip/hostname 的情况未考虑 - // TODO 实际应该用 NOCOBASE_ENV 来判断,或者抛给 env 处理 - if (process.env.LOCAL_STORAGE_USE_STATIC_SERVER) { - const basePath = url.pathname.startsWith('/') ? url.pathname : `/${url.pathname}`; - app.use(mount(basePath, serve(getDocumentRoot(storage)))); + if (url.origin && url.origin !== LOCALHOST) { + continue; } - storages.set(storage.primaryKey, storage); + + const basePath = url.pathname.startsWith('/') ? url.pathname : `/${url.pathname}`; + if (!match(basePath, ctx.path)) { + continue; + } + + return serve(getDocumentRoot(storage), { + // for handle files after any api handlers + defer: true + })(ctx, async () => { + if (ctx.path.startsWith(basePath)) { + ctx.path = ctx.path.replace(basePath, ''); + } + await next(); + }); } + await next(); }); } export default (storage) => multer.diskStorage({ destination: function (req, file, cb) { - const destPath = getDocumentRoot(storage); - mkdirp(destPath).then(() => { - cb(null, destPath); - }).catch(cb); + const destPath = path.join(getDocumentRoot(storage), storage.path); + mkdirp(destPath, (err: Error | null) => cb(err, destPath)); }, filename: getFilename }); diff --git a/packages/resourcer/src/utils.ts b/packages/resourcer/src/utils.ts index 16f2679f5..47d15a346 100644 --- a/packages/resourcer/src/utils.ts +++ b/packages/resourcer/src/utils.ts @@ -144,7 +144,7 @@ export function parseRequest(request: ParseRequest, options: ParseOptions = {}): const params: ParsedParams = {}; - let prefix = (options.prefix || '').trim(); + let prefix = (options.prefix || '').trim().replace(/\/$/, ''); if (prefix && !prefix.startsWith('/')) { prefix = `/${prefix}`; diff --git a/packages/test/src/mockServer.ts b/packages/test/src/mockServer.ts index 330d1cc7b..206e7d88b 100644 --- a/packages/test/src/mockServer.ts +++ b/packages/test/src/mockServer.ts @@ -49,7 +49,7 @@ export class MockServer extends Application { agent(): SuperAgentTest & { resource: (name: string) => Resource } { const agent = supertest.agent(this.callback()); const prefix = this.resourcer.options.prefix; - const proxy = new Proxy({}, { + const proxy = new Proxy(agent, { get(target, method: string, receiver) { if (method === 'resource') { return (name: string) => { @@ -74,7 +74,7 @@ export class MockServer extends Application { if (resourceKey) { url += `/${resourceKey}`; } - console.log('request url: ' + url); + switch (method) { case 'upload': return agent diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e58cbe491..7ade55b32 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,9 +1,2 @@ -import deepmerge from 'deepmerge'; - -const overwriteMerge = (destinationArray, sourceArray, options) => sourceArray - -export function merge(obj1: any, obj2: any) { - return deepmerge(obj1, obj2, { - arrayMerge: overwriteMerge, - }); -} +export * from './merge'; +export * from './umiConfig'; diff --git a/packages/utils/src/merge.ts b/packages/utils/src/merge.ts new file mode 100644 index 000000000..e58cbe491 --- /dev/null +++ b/packages/utils/src/merge.ts @@ -0,0 +1,9 @@ +import deepmerge from 'deepmerge'; + +const overwriteMerge = (destinationArray, sourceArray, options) => sourceArray + +export function merge(obj1: any, obj2: any) { + return deepmerge(obj1, obj2, { + arrayMerge: overwriteMerge, + }); +} diff --git a/packages/utils/src/umiConfig.ts b/packages/utils/src/umiConfig.ts new file mode 100644 index 000000000..d66b957f6 --- /dev/null +++ b/packages/utils/src/umiConfig.ts @@ -0,0 +1,36 @@ +export function getUmiConfig() { + const { API_PORT, API_BASE_URL } = process.env; + const API_BASE_PATH = process.env.API_BASE_PATH || '/api/'; + const PROXY_TARGET_URL = process.env.PROXY_TARGET_URL || `http://127.0.0.1:${API_PORT}`; + const LOCAL_STORAGE_BASE_URL = process.env.LOCAL_STORAGE_BASE_URL || '/uploads'; + + function getLocalStorageProxy() { + if (LOCAL_STORAGE_BASE_URL.startsWith('http')) { + return {}; + } + + return { + [LOCAL_STORAGE_BASE_URL]: { + target: PROXY_TARGET_URL, + changeOrigin: true, + }, + }; + } + + return { + define: { + 'process.env.API_BASE_URL': API_BASE_URL || API_BASE_PATH, + }, + // only proxy when using `umi dev` + // if the assets are built, will not proxy + proxy: { + [API_BASE_PATH]: { + target: PROXY_TARGET_URL, + changeOrigin: true, + pathRewrite: { [`^${API_BASE_PATH}`]: API_BASE_PATH }, + }, + // for local storage + ...getLocalStorageProxy(), + }, + }; +} diff --git a/yarn.lock b/yarn.lock index 7dbc148f3..013232add 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12580,14 +12580,6 @@ koa-is-json@^1.0.0: resolved "https://registry.npm.taobao.org/koa-is-json/download/koa-is-json-1.0.0.tgz#273c07edcdcb8df6a2c1ab7d59ee76491451ec14" integrity sha1-JzwH7c3Ljfaiwat9We52SRRR7BQ= -koa-mount@^4.0.0: - version "4.0.0" - resolved "https://registry.npm.taobao.org/koa-mount/download/koa-mount-4.0.0.tgz#e0265e58198e1a14ef889514c607254ff386329c" - integrity sha1-4CZeWBmOGhTviJUUxgclT/OGMpw= - dependencies: - debug "^4.0.1" - koa-compose "^4.1.0" - koa-send@^5.0.0: version "5.0.1" resolved "https://registry.npm.taobao.org/koa-send/download/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79"