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 <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2021-12-03 07:31:22 +08:00 committed by GitHub
parent 12f58effad
commit 60b966f59e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 312 additions and 284 deletions

View File

@ -4,8 +4,8 @@ ADMINER_PORT=8080
DB_MYSQL_PORT=3306 DB_MYSQL_PORT=3306
DB_POSTGRES_PORT=5432 DB_POSTGRES_PORT=5432
VERDACCIO_PORT=4873 VERDACCIO_PORT=4873
APP_PORT=13001 APP_PORT=13001
API_PORT=13002
########## NOCOBASE ENV ########## ########## NOCOBASE ENV ##########
@ -27,7 +27,19 @@ DB_LOG_SQL=
# API & APP # API & APP
NOCOBASE_ENV= 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 APP_DIST=packages/app/dist
# ADMIN USER (Initialization only) # ADMIN USER (Initialization only)
@ -42,7 +54,8 @@ STORAGE_TYPE=local
# LOCAL STORAGE # LOCAL STORAGE
LOCAL_STORAGE_USE_STATIC_SERVER=true 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
ALI_OSS_STORAGE_BASE_URL= ALI_OSS_STORAGE_BASE_URL=

View File

@ -24,10 +24,13 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
- run: yarn install - name: Build
- run: yarn bootstrap env:
- run: echo "API_URL=${{ secrets.API_URL }}" >> .env API_BASE_URL: ${{ secrets.API_ORIGIN }}${{ secrets.API_BASE_PATH }}
- run: yarn build-docs run: |
yarn install
yarn bootstrap
yarn build-docs
- name: Deploy - name: Deploy
uses: amondnet/vercel-action@v19 uses: amondnet/vercel-action@v19

View File

@ -1,23 +1,20 @@
import { defineConfig } from 'dumi'; import { defineConfig } from 'dumi';
import { getUmiConfig } from '@nocobase/utils';
const baseUrl = `http://localhost:${process.env.API_PORT || '13001'}/`;
console.log('baseUrl', baseUrl);
process.env.MFSU_AD = 'none'; process.env.MFSU_AD = 'none';
const umiConfig = getUmiConfig();
export default defineConfig({ export default defineConfig({
title: 'NocoBase', title: 'NocoBase',
hash: true, hash: true,
define: { define: {
'process.env.API_URL': process.env.API_URL || `${baseUrl}api/`, ...umiConfig.define,
'process.env.API_HOSTNAME': process.env.API_HOSTNAME,
}, },
// only proxy when using `umi dev`
// if the assets are built, will not proxy
proxy: { proxy: {
'/api/': { ...umiConfig.proxy,
target: baseUrl,
changeOrigin: true,
pathRewrite: { '^/api/': '/api/' },
},
}, },
resolve: { resolve: {
includes: ['docs', 'packages/client/src/schemas'], includes: ['docs', 'packages/client/src/schemas'],

View File

@ -15,7 +15,7 @@ import { UseRequestProvider } from 'ahooks';
import { extend } from 'umi-request'; import { extend } from 'umi-request';
const request = extend({ const request = extend({
prefix: process.env.API_URL, prefix: process.env.API_BASE_URL,
timeout: 30000, timeout: 30000,
}); });
@ -33,10 +33,6 @@ const client = new ClientSDK({
request, request,
}); });
console.log('process.env.API_URL', process.env.API_URL);
// console.log = () => {}
const RouteSwitch = createRouteSwitch({ const RouteSwitch = createRouteSwitch({
components: { components: {
AdminLayout, AdminLayout,

View File

@ -1,25 +1,27 @@
import path from 'path';
import { defineConfig } from 'umi'; import { defineConfig } from 'umi';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import { getUmiConfig } from '@nocobase/utils';
dotenv.config({ dotenv.config({
path: path.resolve(__dirname, '../../.env'), path: path.resolve(__dirname, '../../.env'),
}); });
process.env.MFSU_AD = 'none';
const umiConfig = getUmiConfig();
export default defineConfig({ export default defineConfig({
nodeModulesTransform: { nodeModulesTransform: {
type: 'none', type: 'none',
}, },
define: { define: {
'process.env.API_URL': process.env.API_URL, ...umiConfig.define,
'process.env.API_PORT': process.env.API_PORT,
}, },
// only proxy when using `umi dev`
// if the assets are built, will not proxy
proxy: { proxy: {
'/api': { ...umiConfig.proxy,
'target': `http://localhost:${process.env.API_PORT}/`,
'changeOrigin': true,
'pathRewrite': { '^/api': '/api' },
},
}, },
routes: [ routes: [
{ path: '/', exact: false, component: '@/pages/index' }, { path: '/', exact: false, component: '@/pages/index' },

View File

@ -42,7 +42,7 @@ const api = new Server({
}, },
}, },
resourcer: { resourcer: {
prefix: '/api', prefix: process.env.API_BASE_PATH || '/api/',
}, },
}); });
@ -70,7 +70,7 @@ for (const plugin of plugins) {
api.plugin( api.plugin(
require(`@nocobase/plugin-client/${libDir}/server`).default, { 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, lang: process.env.APP_LANG,
// importDemo: true, // importDemo: true,
}); });

View File

@ -7,7 +7,7 @@ import { ClientSDK } from '../ClientSDK';
import { extend } from 'umi-request'; import { extend } from 'umi-request';
const request = extend({ const request = extend({
prefix: process.env.API_URL, prefix: process.env.API_BASE_URL,
timeout: 30000, timeout: 30000,
}); });

View File

@ -17,7 +17,7 @@ import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n'; import i18n from '../i18n';
const request = extend({ const request = extend({
prefix: process.env.API_URL, prefix: process.env.API_BASE_URL,
timeout: 30000, timeout: 30000,
}); });
@ -26,7 +26,7 @@ request.use(async (ctx, next) => {
const token = localStorage.getItem('NOCOBASE_TOKEN'); const token = localStorage.getItem('NOCOBASE_TOKEN');
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
headers['X-Hostname'] = process.env.API_HOSTNAME; headers['X-Hostname'] = window.location.hostname;
} }
await next(); await next();
}); });
@ -35,8 +35,6 @@ const client = new ClientSDK({
request, request,
}); });
console.log('process.env.API_URL', process.env.API_URL);
// console.log = () => {} // console.log = () => {}
const RouteSwitch = createRouteSwitch({ const RouteSwitch = createRouteSwitch({

View File

@ -27,7 +27,7 @@ export function useDefaultAction() {
} }
export const request = extend({ export const request = extend({
prefix: process.env.API_URL, prefix: process.env.API_BASE_URL,
timeout: 30000, timeout: 30000,
}); });
@ -40,8 +40,6 @@ request.use(async (ctx, next) => {
await next(); await next();
}); });
console.log('process.env.API_URL', process.env.API_URL);
export async function createOrUpdateCollection(data: any) { export async function createOrUpdateCollection(data: any) {
return await request('collections:createOrUpdate', { return await request('collections:createOrUpdate', {
method: 'post', method: 'post',

View File

@ -407,7 +407,7 @@ Upload.Attachment = connect(
multiple={multiple} multiple={multiple}
listType={'picture-card'} listType={'picture-card'}
fileList={fileList} fileList={fileList}
action={`${process.env.API_URL}attachments:upload`} action={`${process.env.API_BASE_URL}attachments:upload`}
onChange={(info) => { onChange={(info) => {
setSync(false); setSync(false);
if (multiple) { if (multiple) {

View File

@ -22,6 +22,18 @@ DB_STORAGE=db.sqlite
# set to 'on' to enable log # set to 'on' to enable log
DB_LOG_SQL= 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 USER (Initialization only)
ADMIN_EMAIL=admin@nocobase.com ADMIN_EMAIL=admin@nocobase.com

View File

@ -1,11 +1,14 @@
import { defineConfig } from 'umi'; import { defineConfig } from 'umi';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getUmiConfig } from '@nocobase/utils';
dotenv.config({ dotenv.config({
path: path.resolve(__dirname, './.env'), path: path.resolve(__dirname, './.env'),
}); });
const umiConfig = getUmiConfig();
process.env.MFSU_AD = 'none'; process.env.MFSU_AD = 'none';
export default defineConfig({ export default defineConfig({
@ -14,15 +17,12 @@ export default defineConfig({
type: 'none', type: 'none',
}, },
define: { define: {
'process.env.API_URL': process.env.API_URL || `/api/`, ...umiConfig.define,
'process.env.API_PORT': process.env.API_PORT || '13001',
}, },
// only proxy when using `umi dev`
// if the assets are built, will not proxy
proxy: { proxy: {
'/api': { ...umiConfig.proxy,
target: `http://localhost:${process.env.API_PORT || '13001'}/`,
changeOrigin: true,
pathRewrite: { '^/api': '/api' },
},
}, },
routes: [{ path: '/', exact: false, component: '@/pages/index' }], routes: [{ path: '/', exact: false, component: '@/pages/index' }],
fastRefresh: {}, fastRefresh: {},

View File

@ -7,7 +7,7 @@ const start = Date.now();
const api = new Application({ const api = new Application({
database: dbConfig[process.env.DB_DIALECT || 'sqlite'], database: dbConfig[process.env.DB_DIALECT || 'sqlite'],
resourcer: { resourcer: {
prefix: '/api', prefix: process.env.API_BASE_PATH || '/api/',
}, },
}); });

View File

@ -18,6 +18,7 @@ export default {
this.app.on('beforeStart', async () => { this.app.on('beforeStart', async () => {
await database.getModel('collections').load({ await database.getModel('collections').load({
// TODO(bug): quick fix just comment out
skipExisting: true, skipExisting: true,
}); });
}); });

View File

@ -7,13 +7,11 @@
"@koa/multer": "^3.0.0", "@koa/multer": "^3.0.0",
"@nocobase/server": "^0.5.0-alpha.34", "@nocobase/server": "^0.5.0-alpha.34",
"ali-oss": "^6.12.0", "ali-oss": "^6.12.0",
"koa-mount": "^4.0.0",
"koa-static": "^5.0.0", "koa-static": "^5.0.0",
"mime-match": "^1.0.2", "mime-match": "^1.0.2",
"multer": "^1.4.2" "multer": "^1.4.2"
}, },
"devDependencies": { "devDependencies": {
"@nocobase/test": "^0.5.0-alpha.34",
"@types/multer": "^1.4.5" "@types/multer": "^1.4.5"
}, },
"gitHead": "e7df1f93c4e23b9a666d99ee7372c02bdaec97c4" "gitHead": "e7df1f93c4e23b9a666d99ee7372c02bdaec97c4"

View File

@ -1,39 +1,52 @@
import { promisify } from 'util';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; 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', () => { describe('action', () => {
let app; let app;
let agent; let agent;
let api;
let db; let db;
let http;
beforeEach(async () => { beforeEach(async () => {
app = await getApp(); app = await getApp();
agent = getAgent(app); agent = app.agent();
api = getAPI(app); http = app.listen(API_PORT);
db = app.db; db = app.db;
const Storage = app.db.getModel('storages'); const Storage = db.getModel('storages');
await Storage.create({ await Storage.create({
name: `local_${Date.now().toString(36)}`, name: `local1_${generatePrefixByPath()}`,
type: STORAGE_TYPE_LOCAL, type: STORAGE_TYPE_LOCAL,
baseUrl: DEFAULT_LOCAL_BASE_URL, baseUrl: DEFAULT_LOCAL_BASE_URL,
default: true default: true
}); });
}); });
afterEach(() => db.close()); afterEach(async () => {
await promisify(cb => http.close(cb))();
await db.close();
});
describe('direct attachment', () => { describe('direct attachment', () => {
it('upload file should be ok', async () => { it('upload file should be ok', async () => {
const response = await agent const { body } = await agent
.post('/api/attachments:upload') .resource('attachments')
.attach(FILE_FIELD_NAME, path.resolve(__dirname, './files/text.txt')); .upload({
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt')
});
const matcher = { const matcher = {
title: 'text', title: 'text',
@ -44,14 +57,15 @@ describe('action', () => {
meta: {}, meta: {},
storage_id: 1, storage_id: 1,
}; };
// 文件上传和解析是否正常 // 文件上传和解析是否正常
expect(response.body).toMatchObject(matcher); expect(body.data).toMatchObject(matcher);
// 文件的 url 是否正常生成 // 文件的 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 = db.getModel('attachments');
const attachment = await Attachment.findOne({ const attachment = await Attachment.findOne({
where: { id: response.body.id }, where: { id: body.data.id },
include: ['storage'] include: ['storage']
}); });
const storage = attachment.get('storage'); const storage = attachment.get('storage');
@ -73,10 +87,9 @@ describe('action', () => {
// 文件是否保存到指定路径 // 文件是否保存到指定路径
expect(file.toString()).toBe('Hello world!\n'); expect(file.toString()).toBe('Hello world!\n');
const content = await agent.get(`/uploads${attachment.path}/${attachment.filename}`);
// 通过 url 是否能正确访问 // 通过 url 是否能正确访问
// TODO(bug) const content = await requestFile(attachment.url, agent);
// expect(content.text).toBe('Hello world!\n'); 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 () => { it('upload with associatedKey, fail as 400 because file mimetype does not match', async () => {
const User = db.getModel('users'); const User = db.getModel('users');
const user = await User.create(); const user = await User.create();
const response = await api.resource('users.avatar').upload({ const response = await agent.resource('users.avatar').upload({
associatedKey: user.id, associatedKey: user.id,
filePath: './files/text.txt' file: path.resolve(__dirname, './files/text.txt')
}); });
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -94,9 +107,9 @@ describe('action', () => {
it('upload with associatedKey', async () => { it('upload with associatedKey', async () => {
const User = db.getModel('users'); const User = db.getModel('users');
const user = await User.create(); const user = await User.create();
const response = await api.resource('users.avatar').upload({ const { body } = await agent.resource('users.avatar').upload({
associatedKey: user.id, associatedKey: user.id,
filePath: './files/image.png', file: path.resolve(__dirname, './files/image.png'),
values: { width: 100, height: 100 } values: { width: 100, height: 100 }
}); });
const matcher = { const matcher = {
@ -112,7 +125,7 @@ describe('action', () => {
storage_id: 1, storage_id: 1,
}; };
// 上传正常返回 // 上传正常返回
expect(response.body).toMatchObject(matcher); expect(body.data).toMatchObject(matcher);
// 由于初始没有外键,无法获取 // 由于初始没有外键,无法获取
// await user.getAvatar() // await user.getAvatar()
@ -120,13 +133,44 @@ describe('action', () => {
include: ['avatar'] 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 // TODO(bug): 没有 associatedKey 时路径解析资源名称不对,无法进入 action
it.skip('upload without associatedKey', async () => { it.skip('upload without associatedKey', async () => {
const response = await api.resource('users.avatar').upload({ const { body } = await agent.resource('users.avatar').upload({
filePath: './files/image.png', file: path.resolve(__dirname, './files/image.png'),
values: { width: 100, height: 100 } values: { width: 100, height: 100 }
}); });
const matcher = { const matcher = {
@ -136,13 +180,13 @@ describe('action', () => {
size: 255, size: 255,
mimetype: 'image/png', mimetype: 'image/png',
// TODO(optimize): 可以考虑使用 qs 的 decoder 来进行类型解析 // TODO(optimize): 可以考虑使用 qs 的 decoder 来进行类型解析
// see: https://github.com/ljharb/qs/issues/91 // @see: https://github.com/ljharb/qs/issues/91
// 或考虑使用 query-string 库的 parseNumbers 等配置项 // 或考虑使用 query-string 库的 parseNumbers 等配置项
meta: { width: '100', height: '100' }, meta: { width: '100', height: '100' },
storage_id: 1, storage_id: 1,
}; };
// 上传返回正常 // 上传返回正常
expect(response.body).toMatchObject(matcher); expect(body.data).toMatchObject(matcher);
}); });
}); });
}); });

View File

@ -1,72 +1,19 @@
import path from 'path'; import path from 'path';
import qs from 'qs';
import supertest from 'supertest'; import supertest from 'supertest';
import bodyParser from 'koa-bodyparser'; import { MockServer, mockServer } from '@nocobase/test';
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 plugin from '../server'; import plugin from '../server';
import { FILE_FIELD_NAME } from '../constants';
function getTestKey() { export async function getApp(options = {}): Promise<MockServer> {
const { id } = require.main; const app = mockServer({
const key = id ...options,
.replace(`${process.env.PWD}/packages`, '') cors: {
.replace(/src\/__tests__/g, '') origin: '*'
.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 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.plugin(require('@nocobase/plugin-collections/src/server').default);
app.resourcer.registerActionHandlers({ ...actions.associate, ...actions.common }); app.plugin(plugin);
app.registerPlugin({ await app.load();
'collections': [path.resolve(__dirname, '../../../plugin-collections')],
'file-manager': [plugin]
});
await app.loadPlugins();
app.db.import({ app.db.import({
directory: path.resolve(__dirname, './tables') directory: path.resolve(__dirname, './tables')
}); });
@ -75,80 +22,13 @@ export async function getApp() {
} catch (error) { } catch (error) {
console.error(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; return app;
} }
interface ActionParams { // because the app in supertest is using a random port
resourceKey?: string | number; export function requestFile(url, agent) {
// resourceName?: string; return path.isAbsolute(url)
// associatedName?: string; ? agent.get(url)
associatedKey?: string | number; : supertest.agent(url).get('');
fields?: any;
filter?: any;
values?: any;
[key: string]: any;
}
interface Handler {
get: (params?: ActionParams) => Promise<supertest.Response>;
list: (params?: ActionParams) => Promise<supertest.Response>;
create: (params?: ActionParams) => Promise<supertest.Response>;
update: (params?: ActionParams) => Promise<supertest.Response>;
destroy: (params?: ActionParams) => Promise<supertest.Response>;
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
}
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);
}
}
}
});
}
};
} }

View File

@ -19,6 +19,17 @@ export default {
} }
} }
}, },
{
type: 'belongsToMany',
name: 'pubkeys',
target: 'attachments',
attachment: {
storage: 'local_private',
rules: {
mimetype: ['text/*']
}
}
},
{ {
type: 'belongsToMany', type: 'belongsToMany',
name: 'photos', name: 'photos',

View File

@ -46,10 +46,10 @@ export async function middleware(ctx: Context, next: Next) {
// 如果没有包含关联,则直接按默认文件上传至默认存储引擎 // 如果没有包含关联,则直接按默认文件上传至默认存储引擎
storage = await StorageModel.findOne({ where: { default: true } }); storage = await StorageModel.findOne({ where: { default: true } });
} else { } else {
const fieldOptions = resourceField.getOptions(); const { attachment = {} } = resourceField.getOptions();
storage = await StorageModel.findOne({ storage = await StorageModel.findOne({
where: fieldOptions.defaultValue where: attachment.storage
? { [StorageModel.primaryKeyAttribute]: fieldOptions.defaultValue } ? { name: attachment.storage }
: { default: true } : { default: true }
}); });
} }
@ -75,8 +75,8 @@ export async function middleware(ctx: Context, next: Next) {
}, },
storage: makeStorage(storage), storage: makeStorage(storage),
}; };
const upload = multer(multerOptions); const uploader = multer(multerOptions);
return upload.single(FILE_FIELD_NAME)(ctx, next); return uploader.single(FILE_FIELD_NAME)(ctx, next);
}; };
export async function action(ctx: Context, next: 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 { associatedName, associatedKey, resourceField } = ctx.action.params;
const extname = path.extname(file.filename); const extname = path.extname(file.filename);
const urlPath = storage.path
? (storage.path.startsWith('/')
? storage.path
: `/${storage.path}`)
: '';
const data = { const data = {
title: file.originalname.replace(extname, ''), title: file.originalname.replace(extname, ''),
filename: file.filename, filename: file.filename,
@ -94,7 +99,7 @@ export async function action(ctx: Context, next: Next) {
path: storage.path, path: storage.path,
size: file.size, size: file.size,
// 直接缓存起来 // 直接缓存起来
url: `${storage.baseUrl}${storage.path}/${file.filename}`, url: `${storage.baseUrl}${urlPath}/${file.filename}`,
mimetype: file.mimetype, mimetype: file.mimetype,
// @ts-ignore // @ts-ignore
meta: ctx.request.body meta: ctx.request.body

View File

@ -8,8 +8,9 @@ import {
middleware as uploadMiddleware, middleware as uploadMiddleware,
} from './actions/upload'; } from './actions/upload';
import { import {
middleware as localMiddleware, middleware as localMiddleware
} from './storages/local'; } from './storages/local';
import { STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_LOCAL } from './constants';
export default { export default {
name: 'file-manager', name: 'file-manager',
@ -24,21 +25,25 @@ export default {
// 暂时中间件只能通过 use 加进来 // 暂时中间件只能通过 use 加进来
resourcer.use(uploadMiddleware); resourcer.use(uploadMiddleware);
resourcer.registerActionHandler('upload', uploadAction); 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'); const Storage = database.getModel('storages');
this.app.on('db.init', async () => { this.app.on('db.init', async () => {
await Storage.create({ await Storage.create({
title: '本地存储', title: '本地存储',
name: `local`, name: `local`,
type: 'local', type: STORAGE_TYPE_LOCAL,
baseUrl: process.env.LOCAL_STORAGE_BASE_URL, baseUrl: process.env.LOCAL_STORAGE_BASE_URL || `http://localhost:${process.env.API_PORT}/uploads`,
default: process.env.STORAGE_TYPE === 'local', default: process.env.STORAGE_TYPE === STORAGE_TYPE_LOCAL,
}); });
await Storage.create({ await Storage.create({
name: `ali-oss`, name: `ali-oss`,
type: 'ali-oss', type: STORAGE_TYPE_ALI_OSS,
baseUrl: process.env.ALI_OSS_STORAGE_BASE_URL, baseUrl: process.env.ALI_OSS_STORAGE_BASE_URL,
options: { options: {
region: process.env.ALI_OSS_REGION, region: process.env.ALI_OSS_REGION,

View File

@ -3,43 +3,71 @@ import { URL } from 'url';
import mkdirp from 'mkdirp'; import mkdirp from 'mkdirp';
import multer from 'multer'; import multer from 'multer';
import serve from 'koa-static'; import serve from 'koa-static';
import mount from 'koa-mount';
import { STORAGE_TYPE_LOCAL } from '../constants'; import { STORAGE_TYPE_LOCAL } from '../constants';
import { getFilename } from '../utils'; 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 { export function getDocumentRoot(storage): string {
const { documentRoot = 'uploads' } = storage.options || {}; const { documentRoot = 'uploads' } = storage.options || {};
// TODO(feature): 后面考虑以字符串模板的方式使用,可注入 req/action 相关变量,以便于区分文件夹 // TODO(feature): 后面考虑以字符串模板的方式使用,可注入 req/action 相关变量,以便于区分文件夹
return path.resolve(path.isAbsolute(documentRoot) return path.resolve(path.isAbsolute(documentRoot)
? documentRoot ? documentRoot
: path.join(process.cwd(), documentRoot), storage.path); : path.join(process.cwd(), documentRoot));
} }
// TODO(optimize): 初始化的时机不应该放在中间件里 export async function middleware(app, options?) {
export function middleware(app) { const LOCALHOST = `http://localhost:${process.env.API_PORT}`;
if (process.env.NOCOBASE_ENV === 'production') {
return;
}
const storages = new Map<string, any>();
const StorageModel = app.db.getModel('storages'); const StorageModel = app.db.getModel('storages');
const storages = new Map<string, any>();
return app.use(async function (ctx, next) { const localServerUpdateHook = createLocalServerUpdateHook(app, storages);
const items = await StorageModel.findAll({ StorageModel.addHook('afterCreate', localServerUpdateHook);
where: { StorageModel.addHook('afterUpdate', localServerUpdateHook);
type: STORAGE_TYPE_LOCAL, StorageModel.addHook('afterDestroy', localServerUpdateHook);
}
});
const primaryKey = StorageModel.primaryKeyAttribute; await update(app, storages);
for (const storage of items) {
// TODO未解决 storage 更新问题
if (storages.has(storage[primaryKey])) {
continue;
}
app.use(async function (ctx, next) {
for (const storage of storages.values()) {
const baseUrl = storage.get('baseUrl'); const baseUrl = storage.get('baseUrl');
let url; let url;
@ -47,33 +75,40 @@ export function middleware(app) {
url = new URL(baseUrl); url = new URL(baseUrl);
} catch (e) { } catch (e) {
url = { url = {
protocol: 'http:',
hostname: 'localhost',
port: process.env.API_PORT,
pathname: baseUrl pathname: baseUrl
}; };
} }
// 以下情况才认为当前进程所应该提供静态服务 // 以下情况才认为当前进程所应该提供静态服务
// 否则都忽略,交给其他 server 来提供(如 nginx/cdn 等) // 否则都忽略,交给其他 server 来提供(如 nginx/cdn 等)
// TODO(bug): https、端口 80 默认值和其他本地 ip/hostname 的情况未考虑 if (url.origin && url.origin !== LOCALHOST) {
// TODO 实际应该用 NOCOBASE_ENV 来判断,或者抛给 env 处理 continue;
if (process.env.LOCAL_STORAGE_USE_STATIC_SERVER) {
const basePath = url.pathname.startsWith('/') ? url.pathname : `/${url.pathname}`;
app.use(mount(basePath, serve(getDocumentRoot(storage))));
} }
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(); await next();
}); });
} }
export default (storage) => multer.diskStorage({ export default (storage) => multer.diskStorage({
destination: function (req, file, cb) { destination: function (req, file, cb) {
const destPath = getDocumentRoot(storage); const destPath = path.join(getDocumentRoot(storage), storage.path);
mkdirp(destPath).then(() => { mkdirp(destPath, (err: Error | null) => cb(err, destPath));
cb(null, destPath);
}).catch(cb);
}, },
filename: getFilename filename: getFilename
}); });

View File

@ -144,7 +144,7 @@ export function parseRequest(request: ParseRequest, options: ParseOptions = {}):
const params: ParsedParams = {}; const params: ParsedParams = {};
let prefix = (options.prefix || '').trim(); let prefix = (options.prefix || '').trim().replace(/\/$/, '');
if (prefix && !prefix.startsWith('/')) { if (prefix && !prefix.startsWith('/')) {
prefix = `/${prefix}`; prefix = `/${prefix}`;

View File

@ -49,7 +49,7 @@ export class MockServer extends Application {
agent(): SuperAgentTest & { resource: (name: string) => Resource } { agent(): SuperAgentTest & { resource: (name: string) => Resource } {
const agent = supertest.agent(this.callback()); const agent = supertest.agent(this.callback());
const prefix = this.resourcer.options.prefix; const prefix = this.resourcer.options.prefix;
const proxy = new Proxy({}, { const proxy = new Proxy(agent, {
get(target, method: string, receiver) { get(target, method: string, receiver) {
if (method === 'resource') { if (method === 'resource') {
return (name: string) => { return (name: string) => {
@ -74,7 +74,7 @@ export class MockServer extends Application {
if (resourceKey) { if (resourceKey) {
url += `/${resourceKey}`; url += `/${resourceKey}`;
} }
console.log('request url: ' + url);
switch (method) { switch (method) {
case 'upload': case 'upload':
return agent return agent

View File

@ -1,9 +1,2 @@
import deepmerge from 'deepmerge'; export * from './merge';
export * from './umiConfig';
const overwriteMerge = (destinationArray, sourceArray, options) => sourceArray
export function merge(obj1: any, obj2: any) {
return deepmerge(obj1, obj2, {
arrayMerge: overwriteMerge,
});
}

View File

@ -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,
});
}

View File

@ -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(),
},
};
}

View File

@ -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" resolved "https://registry.npm.taobao.org/koa-is-json/download/koa-is-json-1.0.0.tgz#273c07edcdcb8df6a2c1ab7d59ee76491451ec14"
integrity sha1-JzwH7c3Ljfaiwat9We52SRRR7BQ= 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: koa-send@^5.0.0:
version "5.0.1" version "5.0.1"
resolved "https://registry.npm.taobao.org/koa-send/download/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79" resolved "https://registry.npm.taobao.org/koa-send/download/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79"