mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-04 21:19:27 +08:00
feat: supports configuring dynamic environment variables and secrets (#5966)
* feat: environments plugin * feat: improve code * fix: improve code * feat: improve code * refactor: package description * feat: bulk import * fix: remove * refactor: file manager support environment variables * refactor: file manager support environment variables * refactor: map manager support environment variables * refactor: support environment variables * refactor: support environment variables * refactor: support delete environment variables * fix: bug * refactor: workflow support environment variables * refactor: email environment variables * refactor: support bulk import * refactor: support bulk import * refactor: support bulk import * refactor: support bulk import * refactor: code improve * feat: env * chore: update * feat: environment * fix: bug * fix: acl snippet * fix: acl snippets * chore: map manager * refactor: support line break * refactor: support password * chore: environment variables * fix: bug * fix: bug * chore: enviroment variables * chore: system settings * fix: improve code * feat: verification * feat: map * feat: file-manager * feat: notification * fix: bug * feat: workflow * fix: improve code * fix: bug * feat: data-source * feat: auth * fix: error * fix: bug * refactor: description * refactor: locale * refactor: locale * refactor: locale * refactor: code improve * refactor: locale * refactor: locale * style: style improve * fix: error * fix: bug * fix: bug * refactor: environment * fix: ellipsis * refactor: password * fix: bug * fix: bug * fix: bug * fix: bug * fix: bug * chore: test * fix: cache * fix: mysql dialect options * refactor: email config form * fix: bug * fix: bug * fix: authenticator.dataValues parse * fix: include undefined * fix: json * fix: json parse * chore: enviromentProvider * fix: acl * fix: rowKey * fix: update ProviderOptions.tsx * feat: get app instance * fix: bug * fix: text * fix: build error * fix: error * chore: migration rules options * chore: migration rules * refactor: code improve * feat: env v2 * chore: environment varibales * chore: environment serve * fix: getVariables * feat: improve code * fix: bug * chore: collection options for migration * chore: tree collection options * chore: migration rules * chore: migration rules * chore: env api * chore: env api * fix: optionsKeysNotAllowedInEnv * fix: required true * fix: improve code * fix: app refresh * fix: remove db.import * fix: type error * fix: map * refactor: locale improve * refactor: tx-cos * fix: undefined * refactor: code improve * chore: use bookworm * fix: npm add user * fix: npm login * fix: npm adduser * fix: npm adduser * fix: expect * fix: expect * fix: environmentVariables * refactor: support bulk delete & filter * refactor: locale improve * feat: filter * refactor: useGlobalVariable * fix: scope * fix: bug * fix: optionsKeysNotAllowedInEnv * fix: test error * fix: test * fix: test * feat: improve code --------- Co-authored-by: chenos <chenlinxh@gmail.com> Co-authored-by: Chareice <chareice@live.com>
This commit is contained in:
parent
cbdd5ffe8c
commit
5d5f455b3c
14
.gitignore
vendored
14
.gitignore
vendored
@ -20,27 +20,13 @@ docs-dist/
|
||||
dist/
|
||||
docker/**/storage
|
||||
cache/diskstore-*
|
||||
*.nbdump
|
||||
storage/duplicator/*
|
||||
storage/backups/*
|
||||
**/.dumi/tmp
|
||||
**/.dumi/tmp-test
|
||||
**/.dumi/tmp-production
|
||||
packages/core/client/docs/contributing.md
|
||||
packages/core/app/client/src/.plugins
|
||||
storage/plugins
|
||||
storage/tar
|
||||
storage/tmp
|
||||
storage/print-templates
|
||||
storage/cache
|
||||
storage/app.watch.ts
|
||||
storage/.upgrading
|
||||
storage/logs-e2e
|
||||
storage/uploads-e2e
|
||||
storage/.pm2-*
|
||||
tsconfig.paths.json
|
||||
/playwright
|
||||
/storage/playwright
|
||||
.swc
|
||||
ncc-cache/
|
||||
yarn--**
|
||||
|
17
Dockerfile
17
Dockerfile
@ -1,4 +1,4 @@
|
||||
FROM node:20.13-bullseye as builder
|
||||
FROM node:20-bookworm as builder
|
||||
ARG VERDACCIO_URL=http://host.docker.internal:10104/
|
||||
ARG COMMIT_HASH
|
||||
ARG APPEND_PRESET_LOCAL_PLUGINS
|
||||
@ -7,10 +7,17 @@ ARG PLUGINS_DIRS
|
||||
|
||||
ENV PLUGINS_DIRS=${PLUGINS_DIRS}
|
||||
|
||||
RUN apt-get update && apt-get install -y jq expect
|
||||
|
||||
RUN npx npm-cli-adduser --username test --password test -e test@nocobase.com -r $VERDACCIO_URL
|
||||
RUN expect <<EOD
|
||||
spawn npm adduser --registry $VERDACCIO_URL
|
||||
expect {
|
||||
"Username:" {send "test\r"; exp_continue}
|
||||
"Password:" {send "test\r"; exp_continue}
|
||||
"Email: (this IS public)" {send "test@nocobase.com\r"; exp_continue}
|
||||
}
|
||||
EOD
|
||||
|
||||
RUN apt-get update && apt-get install -y jq
|
||||
WORKDIR /tmp
|
||||
COPY . /tmp
|
||||
RUN yarn install && yarn build --no-dts
|
||||
@ -47,12 +54,12 @@ RUN cd /app \
|
||||
&& tar -zcf ./nocobase.tar.gz -C /app/my-nocobase-app .
|
||||
|
||||
|
||||
FROM node:20.13-bullseye-slim
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN sh -c 'echo "deb http://mirrors.ustc.edu.cn/postgresql/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
RUN sh -c 'echo "deb http://mirrors.ustc.edu.cn/postgresql/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
RUN wget --quiet -O - http://mirrors.ustc.edu.cn/postgresql/repos/apt/ACCC4CF8.asc | apt-key add -
|
||||
|
||||
RUN apt-get update && apt-get -y --no-install-recommends install nginx libaio1 postgresql-client-16 postgresql-client-17 \
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM node:18-bullseye-slim as builder
|
||||
FROM node:20-bookworm-slim as builder
|
||||
|
||||
ARG CNA_VERSION
|
||||
|
||||
@ -14,7 +14,7 @@ RUN cd /app \
|
||||
&& rm -rf nocobase.tar.gz \
|
||||
&& tar -zcf ./nocobase.tar.gz -C /app/my-nocobase-app .
|
||||
|
||||
FROM node:18-bullseye-slim
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
# COPY ./sources.list /etc/apt/sources.list
|
||||
RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \
|
||||
|
@ -2,9 +2,7 @@
|
||||
"version": "1.6.0-alpha.9",
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"npmClientArgs": [
|
||||
"--ignore-engines"
|
||||
],
|
||||
"npmClientArgs": ["--ignore-engines"],
|
||||
"command": {
|
||||
"version": {
|
||||
"forcePublish": true,
|
||||
|
@ -31,6 +31,10 @@ interface IAuth {
|
||||
}
|
||||
|
||||
export abstract class Auth implements IAuth {
|
||||
/**
|
||||
* options keys that are not allowed to use environment variables
|
||||
*/
|
||||
public static optionsKeysNotAllowedInEnv: string[];
|
||||
abstract user: Model;
|
||||
protected authenticator: Authenticator;
|
||||
protected options: {
|
||||
|
@ -99,6 +99,7 @@ export class Application {
|
||||
public schemaSettingsManager: SchemaSettingsManager;
|
||||
public dataSourceManager: DataSourceManager;
|
||||
public name: string;
|
||||
public globalVars: Record<string, any> = {};
|
||||
|
||||
loading = true;
|
||||
maintained = false;
|
||||
@ -465,4 +466,12 @@ export class Application {
|
||||
componentOption,
|
||||
);
|
||||
}
|
||||
|
||||
addGlobalVar(key: string, value: any) {
|
||||
set(this.globalVars, key, value);
|
||||
}
|
||||
|
||||
getGlobalVar(key) {
|
||||
return get(this.globalVars, key);
|
||||
}
|
||||
}
|
||||
|
@ -11,3 +11,4 @@ export * from './useApp';
|
||||
export * from './useAppSpin';
|
||||
export * from './usePlugin';
|
||||
export * from './useRouter';
|
||||
export * from './useGlobalVariable';
|
||||
|
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { isFunction } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { useApp } from './';
|
||||
|
||||
export const useGlobalVariable = (key: string) => {
|
||||
const app = useApp();
|
||||
|
||||
const variable = useMemo(() => {
|
||||
return app.getGlobalVar(key);
|
||||
}, [app, key]);
|
||||
|
||||
if (isFunction(variable)) {
|
||||
try {
|
||||
return variable();
|
||||
} catch (error) {
|
||||
console.error(`Error calling global variable function for key: ${key}`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return variable;
|
||||
};
|
@ -72,7 +72,7 @@ export const enableLinkSettingsItem: SchemaSettingsItemType = {
|
||||
const { fieldSchema: columnSchema } = useColumnSchema();
|
||||
const schema = useFieldSchema();
|
||||
const fieldSchema = columnSchema || schema;
|
||||
const { name } = useBlockContext();
|
||||
const { name } = useBlockContext() || {};
|
||||
return name !== 'kanban' && (fieldSchema?.['x-read-pretty'] || field.readPretty);
|
||||
},
|
||||
useComponentProps() {
|
||||
|
@ -7,13 +7,13 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Field } from '@formily/core';
|
||||
import { useField } from '@formily/react';
|
||||
import { Input } from 'antd';
|
||||
import { TextAreaProps } from 'antd/es/input';
|
||||
import React, { useState, useEffect, Ref } from 'react';
|
||||
import { cx, css } from '@emotion/css';
|
||||
import JSON5 from 'json5';
|
||||
import React, { Ref, useEffect, useState } from 'react';
|
||||
|
||||
export type JSONTextAreaProps = TextAreaProps & { value?: string; space?: number; json5?: boolean };
|
||||
|
||||
@ -30,7 +30,16 @@ export const Json = React.forwardRef<typeof Input.TextArea, JSONTextAreaProps>(
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (value != null) {
|
||||
setText(_JSON.stringify(value, null, space));
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
_JSON.parse(value);
|
||||
setText(value);
|
||||
} catch (error) {
|
||||
setText(_JSON.stringify(value, null, space));
|
||||
}
|
||||
} else {
|
||||
setText(_JSON.stringify(value, null, space));
|
||||
}
|
||||
} else {
|
||||
setText(undefined);
|
||||
}
|
||||
|
@ -223,7 +223,7 @@ function useVariablesFromValue(value: string, delimiters: [string, string] = ['{
|
||||
|
||||
export function TextArea(props) {
|
||||
const { wrapSSR, hashId, componentCls } = useStyles();
|
||||
const { scope, onChange, changeOnSelect, style, fieldNames, delimiters = ['{{', '}}'] } = props;
|
||||
const { scope, onChange, changeOnSelect, style, fieldNames, delimiters = ['{{', '}}'], addonBefore } = props;
|
||||
const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : props.value.toString();
|
||||
const variables = useVariablesFromValue(value, delimiters);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
@ -397,7 +397,6 @@ export function TextArea(props) {
|
||||
},
|
||||
[onChange, delimitersString],
|
||||
);
|
||||
|
||||
const disabled = props.disabled || form.disabled;
|
||||
return wrapSSR(
|
||||
<Space.Compact
|
||||
@ -410,6 +409,8 @@ export function TextArea(props) {
|
||||
flex-grow: 1;
|
||||
min-width: 200px;
|
||||
word-break: break-all;
|
||||
border-top-left-radius: ${addonBefore ? '0px' : '6px'};
|
||||
border-bottom-left-radius: ${addonBefore ? '0px' : '6px'};
|
||||
}
|
||||
.ant-input-disabled {
|
||||
.ant-tag {
|
||||
@ -424,6 +425,19 @@ export function TextArea(props) {
|
||||
`,
|
||||
)}
|
||||
>
|
||||
{addonBefore && (
|
||||
<div
|
||||
className={css`
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgb(217, 217, 217);
|
||||
padding: 0px 11px;
|
||||
border-radius: 6px 0px 0px 6px;
|
||||
border-right: 0px;
|
||||
`}
|
||||
>
|
||||
{addonBefore}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
role="button"
|
||||
aria-label="textbox"
|
||||
|
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { connect, mapReadPretty } from '@formily/react';
|
||||
import { TextArea } from './TextArea';
|
||||
import { RawTextArea } from './RawTextArea';
|
||||
import { Password } from '../password';
|
||||
import { Variable } from './Variable';
|
||||
import { Input } from '../input';
|
||||
import { useGlobalVariable } from '../../../application/hooks/useGlobalVariable';
|
||||
|
||||
export const useEnvironmentVariableOptions = (scope) => {
|
||||
const environmentVariables = useGlobalVariable('$env');
|
||||
return useMemo(() => {
|
||||
if (environmentVariables) {
|
||||
return [environmentVariables].filter(Boolean);
|
||||
}
|
||||
return scope;
|
||||
}, [environmentVariables, scope]);
|
||||
};
|
||||
|
||||
const isVariable = (value) => {
|
||||
const regex = /{{.*?}}/;
|
||||
return regex.test(value);
|
||||
};
|
||||
interface TextAreaWithGlobalScopeProps {
|
||||
supportsLineBreak?: boolean;
|
||||
password?: boolean;
|
||||
number?: boolean;
|
||||
boolean?: boolean;
|
||||
value?: any;
|
||||
scope?: string | object;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const TextAreaWithGlobalScope = connect((props: TextAreaWithGlobalScopeProps) => {
|
||||
const { supportsLineBreak, password, number, boolean, ...others } = props;
|
||||
const scope = useEnvironmentVariableOptions(props.scope);
|
||||
const fieldNames = { value: 'name', label: 'title' };
|
||||
|
||||
if (supportsLineBreak) {
|
||||
return <RawTextArea {...others} scope={scope} fieldNames={fieldNames} rows={3} />;
|
||||
}
|
||||
if (number) {
|
||||
return <Variable.Input {...props} scope={scope} fieldNames={fieldNames} />;
|
||||
}
|
||||
if (password && props.value && !isVariable(props.value)) {
|
||||
return <Password {...others} autoFocus />;
|
||||
}
|
||||
if (boolean) {
|
||||
return <Variable.Input {...props} scope={scope} fieldNames={fieldNames} />;
|
||||
}
|
||||
return <TextArea {...others} scope={scope} fieldNames={fieldNames} />;
|
||||
}, mapReadPretty(Input.ReadPretty));
|
@ -8,3 +8,4 @@
|
||||
*/
|
||||
|
||||
export * from './Variable';
|
||||
export { TextAreaWithGlobalScope } from './TextAreaWithGlobalScope';
|
||||
|
@ -11,7 +11,7 @@ import { Result } from 'ahooks/es/useRequest/src/types';
|
||||
import React, { createContext, ReactNode, useContext } from 'react';
|
||||
import { useRequest } from '../api-client';
|
||||
|
||||
export const SystemSettingsContext = createContext<Result<any, any>>(null);
|
||||
export const SystemSettingsContext = createContext<Result<any, any> | any>(null);
|
||||
SystemSettingsContext.displayName = 'SystemSettingsContext';
|
||||
|
||||
export const useSystemSettings = () => {
|
||||
@ -20,8 +20,7 @@ export const useSystemSettings = () => {
|
||||
|
||||
export const SystemSettingsProvider: React.FC<{ children?: ReactNode }> = (props) => {
|
||||
const result = useRequest({
|
||||
url: 'systemSettings:get/1?appends=logo',
|
||||
url: 'systemSettings:get',
|
||||
});
|
||||
|
||||
return <SystemSettingsContext.Provider value={result}>{props.children}</SystemSettingsContext.Provider>;
|
||||
return <SystemSettingsContext.Provider value={{ ...result }}>{props.children}</SystemSettingsContext.Provider>;
|
||||
};
|
||||
|
@ -60,7 +60,7 @@ const useSaveSystemSettingsValues = () => {
|
||||
},
|
||||
});
|
||||
await api.request({
|
||||
url: 'systemSettings:update/1',
|
||||
url: 'systemSettings:put',
|
||||
method: 'post',
|
||||
data: values,
|
||||
});
|
||||
@ -88,11 +88,14 @@ const schema: ISchema = {
|
||||
type: 'void',
|
||||
title: '{{t("System settings")}}',
|
||||
properties: {
|
||||
title: {
|
||||
raw_title: {
|
||||
type: 'string',
|
||||
title: "{{t('System title')}}",
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.TextArea',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': {
|
||||
supportsLineBreak: true,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
logo: {
|
||||
|
@ -59,7 +59,6 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
|
||||
const { getCollectionJoinField, getCollection } = useCollectionManager_deprecated();
|
||||
const compile = useCompile();
|
||||
const { builtinVariables } = useBuiltInVariables();
|
||||
|
||||
const setCtx = useCallback((ctx: Record<string, any> | ((prev: Record<string, any>) => Record<string, any>)) => {
|
||||
if (_.isFunction(ctx)) {
|
||||
ctxRef.current = ctx(ctxRef.current);
|
||||
|
@ -8,6 +8,8 @@
|
||||
*/
|
||||
|
||||
import { Collection } from './collection';
|
||||
import { DataSource } from './data-source';
|
||||
import { Repository } from './repository';
|
||||
import {
|
||||
CollectionOptions,
|
||||
ICollection,
|
||||
@ -16,8 +18,6 @@ import {
|
||||
IRepository,
|
||||
MergeOptions,
|
||||
} from './types';
|
||||
import { DataSource } from './data-source';
|
||||
import { Repository } from './repository';
|
||||
|
||||
export class CollectionManager implements ICollectionManager {
|
||||
public dataSource: DataSource;
|
||||
@ -35,6 +35,10 @@ export class CollectionManager implements ICollectionManager {
|
||||
});
|
||||
}
|
||||
|
||||
setDataSource(dataSource: DataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
/* istanbul ignore next -- @preserve */
|
||||
getRegisteredFieldType(type) {}
|
||||
|
||||
|
@ -7,9 +7,9 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { CollectionOptions, ICollection, ICollectionManager, IField, IRepository } from './types';
|
||||
import { default as lodash } from 'lodash';
|
||||
import { CollectionField } from './collection-field';
|
||||
import { CollectionOptions, ICollection, ICollectionManager, IField, IRepository } from './types';
|
||||
|
||||
export class Collection implements ICollection {
|
||||
repository: IRepository;
|
||||
@ -17,7 +17,7 @@ export class Collection implements ICollection {
|
||||
|
||||
constructor(
|
||||
protected options: CollectionOptions,
|
||||
protected collectionManager: ICollectionManager,
|
||||
public collectionManager: ICollectionManager,
|
||||
) {
|
||||
this.setRepository(options.repository);
|
||||
|
||||
|
@ -8,10 +8,13 @@
|
||||
*/
|
||||
|
||||
import { DataSource } from './data-source';
|
||||
import { DataSourceManager } from './data-source-manager';
|
||||
|
||||
export class DataSourceFactory {
|
||||
public collectionTypes: Map<string, typeof DataSource> = new Map();
|
||||
|
||||
constructor(protected dataSourceManager: DataSourceManager) {}
|
||||
|
||||
register(type: string, dataSourceClass: typeof DataSource) {
|
||||
this.collectionTypes.set(type, dataSourceClass);
|
||||
}
|
||||
@ -25,8 +28,17 @@ export class DataSourceFactory {
|
||||
if (!klass) {
|
||||
throw new Error(`Data source type "${type}" not found`);
|
||||
}
|
||||
const environment = this.dataSourceManager.options.app?.environment;
|
||||
const { logger, sqlLogger, ...others } = options;
|
||||
|
||||
const opts = { logger, sqlLogger, ...others };
|
||||
|
||||
if (environment) {
|
||||
Object.assign(opts, environment.renderJsonTemplate(others));
|
||||
}
|
||||
// @ts-ignore
|
||||
return new klass(options);
|
||||
const dataSource = new klass(opts) as DataSource;
|
||||
dataSource.setDataSourceManager(this.dataSourceManager);
|
||||
return dataSource;
|
||||
}
|
||||
}
|
||||
|
@ -7,10 +7,10 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { createConsoleLogger, createLogger, Logger, LoggerOptions } from '@nocobase/logger';
|
||||
import { ToposortOptions } from '@nocobase/utils';
|
||||
import { DataSource } from './data-source';
|
||||
import { DataSourceFactory } from './data-source-factory';
|
||||
import { createConsoleLogger, createLogger, Logger, LoggerOptions } from '@nocobase/logger';
|
||||
|
||||
type DataSourceHook = (dataSource: DataSource) => void;
|
||||
|
||||
@ -24,13 +24,14 @@ export class DataSourceManager {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
factory: DataSourceFactory = new DataSourceFactory();
|
||||
factory: DataSourceFactory;
|
||||
protected middlewares = [];
|
||||
private onceHooks: Array<DataSourceHook> = [];
|
||||
private beforeAddHooks: Array<DataSourceHook> = [];
|
||||
|
||||
constructor(public options: DataSourceManagerOptions = {}) {
|
||||
this.dataSources = new Map();
|
||||
this.factory = new DataSourceFactory(this);
|
||||
this.middlewares = [];
|
||||
|
||||
if (options.app) {
|
||||
|
@ -8,13 +8,14 @@
|
||||
*/
|
||||
|
||||
import { ACL } from '@nocobase/acl';
|
||||
import { Logger } from '@nocobase/logger';
|
||||
import { getNameByParams, parseRequest, ResourceManager } from '@nocobase/resourcer';
|
||||
import { wrapMiddlewareWithLogging } from '@nocobase/utils';
|
||||
import EventEmitter from 'events';
|
||||
import compose from 'koa-compose';
|
||||
import { DataSourceManager } from './data-source-manager';
|
||||
import { loadDefaultActions } from './load-default-actions';
|
||||
import { ICollectionManager } from './types';
|
||||
import { Logger } from '@nocobase/logger';
|
||||
import { wrapMiddlewareWithLogging } from '@nocobase/utils';
|
||||
|
||||
export type DataSourceOptions = any;
|
||||
|
||||
@ -27,6 +28,7 @@ export abstract class DataSource extends EventEmitter {
|
||||
public collectionManager: ICollectionManager;
|
||||
public resourceManager: ResourceManager;
|
||||
public acl: ACL;
|
||||
public dataSourceManager: DataSourceManager;
|
||||
|
||||
logger: Logger;
|
||||
|
||||
@ -49,6 +51,10 @@ export abstract class DataSource extends EventEmitter {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
setDataSourceManager(dataSourceManager: DataSourceManager) {
|
||||
this.dataSourceManager = dataSourceManager;
|
||||
}
|
||||
|
||||
setLogger(logger: Logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
@ -66,6 +72,9 @@ export abstract class DataSource extends EventEmitter {
|
||||
});
|
||||
|
||||
this.collectionManager = this.createCollectionManager(options);
|
||||
if (this.collectionManager) {
|
||||
this.collectionManager.setDataSource(this);
|
||||
}
|
||||
this.resourceManager.registerActionHandlers(loadDefaultActions());
|
||||
|
||||
if (options.acl !== false) {
|
||||
|
@ -10,6 +10,7 @@
|
||||
/* istanbul ignore file -- @preserve */
|
||||
|
||||
import { Database } from '@nocobase/database';
|
||||
import { DataSource } from './data-source';
|
||||
import {
|
||||
CollectionOptions,
|
||||
ICollection,
|
||||
@ -22,12 +23,17 @@ import {
|
||||
export class SequelizeCollectionManager implements ICollectionManager {
|
||||
db: Database;
|
||||
options: any;
|
||||
dataSource: DataSource;
|
||||
|
||||
constructor(options) {
|
||||
this.db = this.createDB(options);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
setDataSource(dataSource: DataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
collectionsFilter() {
|
||||
if (this.options.collectionsFilter) {
|
||||
return this.options.collectionsFilter;
|
||||
|
@ -7,6 +7,8 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DataSource } from './data-source';
|
||||
|
||||
export type CollectionOptions = {
|
||||
name: string;
|
||||
repository?: string;
|
||||
@ -102,6 +104,10 @@ export type MergeOptions = {
|
||||
};
|
||||
|
||||
export interface ICollectionManager {
|
||||
dataSource: DataSource;
|
||||
|
||||
setDataSource(dataSource: DataSource): void;
|
||||
|
||||
registerFieldTypes(types: Record<string, any>): void;
|
||||
|
||||
registerFieldInterfaces(interfaces: Record<string, new (options: any) => IFieldInterface>): void;
|
||||
|
@ -147,7 +147,7 @@ describe('underscored options', () => {
|
||||
type: 'belongsToMany',
|
||||
name: 'tags',
|
||||
through: 'collectionCategory',
|
||||
target: 'posts',
|
||||
target: 'tags',
|
||||
sourceKey: 'name',
|
||||
foreignKey: 'postsName',
|
||||
targetKey: 'name',
|
||||
|
@ -88,16 +88,19 @@ export type DumpRules =
|
||||
| ({ skipped: true } & BaseDumpRules)
|
||||
| ({ group: BuiltInGroup | string } & BaseDumpRules);
|
||||
|
||||
export type MigrationRule = 'overwrite' | 'skip' | 'upsert' | 'schema-only' | 'insert-ignore';
|
||||
|
||||
export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'> {
|
||||
name: string;
|
||||
title?: string;
|
||||
namespace?: string;
|
||||
migrationRules?: MigrationRule[];
|
||||
dumpRules?: DumpRules;
|
||||
tableName?: string;
|
||||
inherits?: string[] | string;
|
||||
viewName?: string;
|
||||
writableView?: boolean;
|
||||
|
||||
isThrough?: boolean;
|
||||
filterTargetKey?: string | string[];
|
||||
fields?: FieldOptions[];
|
||||
model?: string | ModelStatic<Model>;
|
||||
|
@ -309,6 +309,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['schema-only', 'overwrite', 'skip'],
|
||||
origin: '@nocobase/database',
|
||||
fields: [{ type: 'string', name: 'name', primaryKey: true }],
|
||||
});
|
||||
|
@ -7,6 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { lodash } from '@nocobase/utils';
|
||||
import { BaseDialect } from './base-dialect';
|
||||
|
||||
export class MysqlDialect extends BaseDialect {
|
||||
@ -22,4 +23,9 @@ export class MysqlDialect extends BaseDialect {
|
||||
version: '>=8.0.17',
|
||||
};
|
||||
}
|
||||
|
||||
getSequelizeOptions(options: any) {
|
||||
lodash.set(options, 'dialectOptions.multipleStatements', true);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
@ -144,6 +144,9 @@ export class BelongsToManyField extends RelationField {
|
||||
} else {
|
||||
const throughCollectionOptions = {
|
||||
name: through,
|
||||
isThrough: true,
|
||||
sourceCollectionName: this.collection.name,
|
||||
targetCollectionName: this.target,
|
||||
};
|
||||
|
||||
if (this.collection.options.dumpRules) {
|
||||
|
@ -76,6 +76,7 @@ import packageJson from '../package.json';
|
||||
import { ServiceContainer } from './service-container';
|
||||
import { availableActions } from './acl/available-action';
|
||||
import { AuditManager } from './audit-manager';
|
||||
import { Environment } from './environment';
|
||||
|
||||
export type PluginType = string | typeof Plugin;
|
||||
export type PluginConfiguration = PluginType | [PluginType, any];
|
||||
@ -309,6 +310,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
return this._maintainingMessage;
|
||||
}
|
||||
|
||||
private _env: Environment;
|
||||
|
||||
get environment() {
|
||||
return this._env;
|
||||
}
|
||||
|
||||
protected _cronJobManager: CronJobManager;
|
||||
|
||||
get cronJobManager() {
|
||||
@ -1183,6 +1190,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
this.createMainDataSource(options);
|
||||
|
||||
this._cronJobManager = new CronJobManager(this);
|
||||
this._env = new Environment();
|
||||
|
||||
this._cli = this.createCLI();
|
||||
this._i18n = createI18n(options);
|
||||
|
47
packages/core/server/src/environment.ts
Normal file
47
packages/core/server/src/environment.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { parse } from '@nocobase/utils';
|
||||
import _ from 'lodash';
|
||||
|
||||
export class Environment {
|
||||
private vars = {};
|
||||
|
||||
setVariable(key: string, value: string) {
|
||||
this.vars[key] = value;
|
||||
}
|
||||
|
||||
removeVariable(key: string) {
|
||||
delete this.vars[key];
|
||||
}
|
||||
|
||||
getVariablesAndSecrets() {
|
||||
return this.vars;
|
||||
}
|
||||
|
||||
getVariables() {
|
||||
return this.vars;
|
||||
}
|
||||
|
||||
renderJsonTemplate(template: any, options?: { omit?: string[] }) {
|
||||
if (options?.omit) {
|
||||
const omitTemplate = _.omit(template, options.omit);
|
||||
const parsed = parse(omitTemplate)({
|
||||
$env: this.vars,
|
||||
});
|
||||
for (const key of options.omit) {
|
||||
_.set(parsed, key, _.get(template, key));
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
return parse(template)({
|
||||
$env: this.vars,
|
||||
});
|
||||
}
|
||||
}
|
@ -20,6 +20,7 @@ export class ApplicationVersion {
|
||||
app.db.collection({
|
||||
origin: '@nocobase/server',
|
||||
name: 'applicationVersion',
|
||||
migrationRules: ['schema-only', 'skip'],
|
||||
dataType: 'meta',
|
||||
timestamps: false,
|
||||
dumpRules: 'required',
|
||||
|
@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
name: 'applicationPlugins',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
repository: 'PluginManagerRepository',
|
||||
origin: '@nocobase/server',
|
||||
fields: [
|
||||
|
@ -18,6 +18,7 @@ export * from './mock-server';
|
||||
|
||||
export const pgOnly: () => any = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip);
|
||||
export const isPg = () => process.env.DB_DIALECT == 'postgres';
|
||||
export const isMysql = () => process.env.DB_DIALECT == 'mysql';
|
||||
|
||||
export function randomStr() {
|
||||
// create random string
|
||||
|
@ -11,8 +11,10 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'rolesUsers',
|
||||
description: "User's roles",
|
||||
dumpRules: {
|
||||
group: 'user',
|
||||
},
|
||||
migrationRules: ['schema-only', 'overwrite', 'skip'],
|
||||
fields: [{ type: 'boolean', name: 'default' }],
|
||||
});
|
||||
|
@ -12,6 +12,8 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
origin: '@nocobase/plugin-acl',
|
||||
dumpRules: 'required',
|
||||
description: 'Role data',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
name: 'roles',
|
||||
title: '{{t("Roles")}}',
|
||||
autoGenId: false,
|
||||
|
@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'rolesResources',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
model: 'RoleResourceModel',
|
||||
indexes: [
|
||||
{
|
||||
|
@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'rolesResourcesActions',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
model: 'RoleResourceActionModel',
|
||||
fields: [
|
||||
{
|
||||
|
@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'rolesResourcesScopes',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
fields: [
|
||||
{
|
||||
type: 'uid',
|
||||
|
@ -11,6 +11,7 @@ import { extendCollection } from '@nocobase/database';
|
||||
|
||||
export default extendCollection({
|
||||
name: 'users',
|
||||
migrationRules: ['schema-only', 'overwrite', 'skip'],
|
||||
fields: [
|
||||
{
|
||||
interface: 'm2m',
|
||||
|
@ -20,7 +20,7 @@ import {
|
||||
useRequest,
|
||||
} from '@nocobase/client';
|
||||
import { App } from 'antd';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { listByCurrentRoleUrl } from '../constants';
|
||||
import { useCustomRequestVariableOptions, useGetCustomRequest } from '../hooks';
|
||||
import { useCustomRequestsResource } from '../hooks/useCustomRequestsResource';
|
||||
@ -33,9 +33,15 @@ export function CustomRequestSettingsItem() {
|
||||
const dataSourceKey = useDataSourceKey();
|
||||
const fieldSchema = useFieldSchema();
|
||||
const customRequestsResource = useCustomRequestsResource();
|
||||
const { message } = App.useApp();
|
||||
const { data, refresh } = useGetCustomRequest();
|
||||
const { dn } = useDesignable();
|
||||
const initialValues = useMemo(() => {
|
||||
const values = { ...data?.data?.options };
|
||||
if (values.data && typeof values.data !== 'string') {
|
||||
values.data = JSON.stringify(values.data, null, 2);
|
||||
}
|
||||
return values;
|
||||
}, [data?.data?.options]);
|
||||
return (
|
||||
<>
|
||||
<SchemaSettingsActionModalItem
|
||||
@ -46,9 +52,7 @@ export function CustomRequestSettingsItem() {
|
||||
beforeOpen={() => !data && refresh()}
|
||||
scope={{ useCustomRequestVariableOptions }}
|
||||
schema={CustomRequestConfigurationFieldsSchema}
|
||||
initialValues={{
|
||||
...data?.data?.options,
|
||||
}}
|
||||
initialValues={initialValues}
|
||||
onSubmit={async (config) => {
|
||||
const { ...requestSettings } = config;
|
||||
fieldSchema['x-response-type'] = requestSettings.responseType;
|
||||
@ -69,7 +73,8 @@ export function CustomRequestSettingsItem() {
|
||||
'x-uid': fieldSchema['x-uid'],
|
||||
},
|
||||
});
|
||||
message.success(t('Saved successfully'));
|
||||
refresh();
|
||||
dn.refresh();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
useCollectionFilterOptions,
|
||||
useCollectionRecordData,
|
||||
useCompile,
|
||||
useGlobalVariable,
|
||||
} from '@nocobase/client';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from '../locale';
|
||||
@ -26,13 +27,13 @@ export const useCustomRequestVariableOptions = () => {
|
||||
const compile = useCompile();
|
||||
const recordData = useCollectionRecordData();
|
||||
const { name: blockType } = useBlockContext() || {};
|
||||
|
||||
const [fields, userFields] = useMemo(() => {
|
||||
return [compile(fieldsOptions), compile(userFieldOptions)];
|
||||
}, [fieldsOptions, userFieldOptions]);
|
||||
|
||||
const environmentVariables = useGlobalVariable('$env');
|
||||
return useMemo(() => {
|
||||
return [
|
||||
environmentVariables,
|
||||
recordData && {
|
||||
name: 'currentRecord',
|
||||
title: t('Current record', { ns: 'client' }),
|
||||
|
@ -154,6 +154,7 @@ export async function send(this: CustomRequestPlugin, ctx: Context, next: Next)
|
||||
currentTime: new Date().toISOString(),
|
||||
$nToken: ctx.getBearerToken(),
|
||||
$nForm,
|
||||
$env: ctx.app.environment.getVariables(),
|
||||
};
|
||||
|
||||
const axiosRequestConfig = {
|
||||
@ -169,8 +170,6 @@ export async function send(this: CustomRequestPlugin, ctx: Context, next: Next)
|
||||
data: getParsedValue(data, variables),
|
||||
};
|
||||
|
||||
console.log(axiosRequestConfig);
|
||||
|
||||
const requestUrl = axios.getUri(axiosRequestConfig);
|
||||
this.logger.info(`custom-request:send:${filterByTk} request url ${requestUrl}`);
|
||||
this.logger.info(
|
||||
|
@ -13,6 +13,7 @@ export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'customRequests',
|
||||
autoGenId: false,
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
fields: [
|
||||
{
|
||||
type: 'uid',
|
||||
|
@ -12,4 +12,5 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'customRequestsRoles',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
});
|
||||
|
@ -9,7 +9,6 @@
|
||||
|
||||
import { Logger, LoggerOptions } from '@nocobase/logger';
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
import { resolve } from 'path';
|
||||
import { listByCurrentRole } from './actions/listByCurrentRole';
|
||||
import { send } from './actions/send';
|
||||
|
||||
@ -32,9 +31,7 @@ export class PluginActionCustomRequestServer extends Plugin {
|
||||
}
|
||||
|
||||
async load() {
|
||||
await this.importCollections(resolve(__dirname, 'collections'));
|
||||
|
||||
this.app.resource({
|
||||
this.app.resourceManager.define({
|
||||
name: 'customRequests',
|
||||
actions: {
|
||||
send: send.bind(this),
|
||||
|
@ -14,6 +14,7 @@ export default {
|
||||
dumpRules: {
|
||||
group: 'user',
|
||||
},
|
||||
migrationRules: ['schema-only', 'skip'],
|
||||
shared: true,
|
||||
name: 'apiKeys',
|
||||
sortable: 'sort',
|
||||
|
@ -13,6 +13,7 @@ export default defineCollection({
|
||||
dumpRules: {
|
||||
group: 'log',
|
||||
},
|
||||
migrationRules: ['schema-only', 'skip'],
|
||||
name: 'auditLogs',
|
||||
createdBy: false,
|
||||
updatedBy: false,
|
||||
|
@ -16,6 +16,7 @@ export default defineCollection({
|
||||
dumpRules: {
|
||||
group: 'third-party',
|
||||
},
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
shared: true,
|
||||
name: 'authenticators',
|
||||
sortable: true,
|
||||
|
@ -13,6 +13,7 @@ export default defineCollection({
|
||||
dumpRules: {
|
||||
group: 'log',
|
||||
},
|
||||
migrationRules: ['schema-only', 'skip'],
|
||||
shared: true,
|
||||
name: 'tokenBlacklist',
|
||||
model: 'TokenBlacklistModel',
|
||||
|
@ -18,6 +18,7 @@ export default defineCollection({
|
||||
group: 'user',
|
||||
},
|
||||
shared: true,
|
||||
migrationRules: ['schema-only', 'overwrite', 'skip'],
|
||||
name: 'usersAuthenticators',
|
||||
model: 'UserAuthModel',
|
||||
createdBy: true,
|
||||
|
@ -10,6 +10,7 @@
|
||||
import { Cache } from '@nocobase/cache';
|
||||
import { Model } from '@nocobase/database';
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
import { tval } from '@nocobase/utils';
|
||||
import { namespace, presetAuthType, presetAuthenticator } from '../preset';
|
||||
import authActions from './actions/auth';
|
||||
import authenticatorsActions from './actions/authenticators';
|
||||
@ -17,7 +18,6 @@ import { BasicAuth } from './basic-auth';
|
||||
import { AuthModel } from './model/authenticator';
|
||||
import { Storer } from './storer';
|
||||
import { TokenBlacklistService } from './token-blacklist';
|
||||
import { tval } from '@nocobase/utils';
|
||||
|
||||
export class PluginAuthServer extends Plugin {
|
||||
cache: Cache;
|
||||
@ -36,8 +36,10 @@ export class PluginAuthServer extends Plugin {
|
||||
});
|
||||
// Set up auth manager
|
||||
const storer = new Storer({
|
||||
app: this.app,
|
||||
db: this.db,
|
||||
cache: this.cache,
|
||||
authManager: this.app.authManager,
|
||||
});
|
||||
this.app.authManager.setStorer(storer);
|
||||
|
||||
|
@ -7,32 +7,62 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Storer as IStorer } from '@nocobase/auth';
|
||||
import { AuthManager, Storer as IStorer } from '@nocobase/auth';
|
||||
import { Cache } from '@nocobase/cache';
|
||||
import { Database, Model } from '@nocobase/database';
|
||||
import { Application } from '@nocobase/server';
|
||||
import { AuthModel } from './model/authenticator';
|
||||
|
||||
export class Storer implements IStorer {
|
||||
db: Database;
|
||||
cache: Cache;
|
||||
app: Application;
|
||||
authManager: AuthManager;
|
||||
key = 'authenticators';
|
||||
|
||||
constructor({ db, cache }: { db: Database; cache: Cache }) {
|
||||
constructor({
|
||||
app,
|
||||
db,
|
||||
cache,
|
||||
authManager,
|
||||
}: {
|
||||
app?: Application;
|
||||
db: Database;
|
||||
cache: Cache;
|
||||
authManager: AuthManager;
|
||||
}) {
|
||||
this.app = app;
|
||||
this.db = db;
|
||||
this.cache = cache;
|
||||
this.authManager = authManager;
|
||||
|
||||
this.db.on('authenticators.afterSave', async (model: AuthModel) => {
|
||||
if (!model.enabled) {
|
||||
await this.cache.delValueInObject(this.key, model.name);
|
||||
return;
|
||||
}
|
||||
await this.cache.setValueInObject(this.key, model.name, model);
|
||||
await this.cache.setValueInObject(this.key, model.name, this.renderJsonTemplate(model));
|
||||
});
|
||||
this.db.on('authenticators.afterDestroy', async (model: AuthModel) => {
|
||||
await this.cache.delValueInObject(this.key, model.name);
|
||||
});
|
||||
}
|
||||
|
||||
renderJsonTemplate(authenticator: any) {
|
||||
if (!authenticator) {
|
||||
return authenticator;
|
||||
}
|
||||
const $env = this.app?.environment;
|
||||
if (!$env) {
|
||||
return authenticator;
|
||||
}
|
||||
const config = this.authManager.getAuthConfig(authenticator.authType);
|
||||
authenticator.dataValues.options = $env.renderJsonTemplate(authenticator.dataValues.options, {
|
||||
omit: config?.auth?.['optionsKeysNotAllowedInEnv'],
|
||||
});
|
||||
return authenticator;
|
||||
}
|
||||
|
||||
async getCache(): Promise<AuthModel[]> {
|
||||
const authenticators = (await this.cache.get(this.key)) as Record<string, AuthModel>;
|
||||
if (!authenticators) {
|
||||
@ -43,7 +73,7 @@ export class Storer implements IStorer {
|
||||
|
||||
async setCache(authenticators: AuthModel[]) {
|
||||
const obj = authenticators.reduce((obj, authenticator) => {
|
||||
obj[authenticator.name] = authenticator;
|
||||
obj[authenticator.name] = this.renderJsonTemplate(authenticator);
|
||||
return obj;
|
||||
}, {});
|
||||
await this.cache.set(this.key, obj);
|
||||
@ -55,6 +85,7 @@ export class Storer implements IStorer {
|
||||
const repo = this.db.getRepository('authenticators');
|
||||
authenticators = await repo.find({ filter: { enabled: true } });
|
||||
await this.setCache(authenticators);
|
||||
authenticators = await this.getCache();
|
||||
}
|
||||
const authenticator = authenticators.find((authenticator: Model) => authenticator.name === name);
|
||||
return authenticator || authenticators[0];
|
||||
|
@ -13,6 +13,7 @@ export default {
|
||||
namespace: 'iframe-block.iframe-html-storage',
|
||||
dumpRules: 'required',
|
||||
name: 'iframeHtml',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
shared: true,
|
||||
|
@ -8,11 +8,11 @@
|
||||
*/
|
||||
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import * as process from 'node:process';
|
||||
import { resolve } from 'path';
|
||||
import { getAntdLocale } from './antd';
|
||||
import { getCronLocale } from './cron';
|
||||
import { getCronstrueLocale } from './cronstrue';
|
||||
import * as process from 'node:process';
|
||||
|
||||
async function getLang(ctx) {
|
||||
const SystemSetting = ctx.db.getRepository('systemSettings');
|
||||
@ -66,11 +66,11 @@ export class PluginClientServer extends Plugin {
|
||||
this.app.acl.allow('app', 'getInfo');
|
||||
this.app.acl.registerSnippet({
|
||||
name: 'app',
|
||||
actions: ['app:restart', 'app:clearCache'],
|
||||
actions: ['app:restart', 'app:refresh', 'app:clearCache'],
|
||||
});
|
||||
const dialect = this.app.db.sequelize.getDialect();
|
||||
|
||||
this.app.resource({
|
||||
this.app.resourceManager.define({
|
||||
name: 'app',
|
||||
actions: {
|
||||
async getInfo(ctx, next) {
|
||||
@ -116,10 +116,14 @@ export class PluginClientServer extends Plugin {
|
||||
ctx.app.runAsCLI(['restart'], { from: 'user' });
|
||||
await next();
|
||||
},
|
||||
async refresh(ctx, next) {
|
||||
ctx.app.runCommand('refresh');
|
||||
await next();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.app.auditManager.registerActions(['app:restart', 'app:clearCache']);
|
||||
this.app.auditManager.registerActions(['app:restart', 'app:refresh', 'app:clearCache']);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -36,9 +36,13 @@ class PluginCollectionTreeServer extends Plugin {
|
||||
|
||||
//always define tree path collection
|
||||
const options = {};
|
||||
|
||||
options['mainCollection'] = collection.name;
|
||||
|
||||
if (collection.options.schema) {
|
||||
options['schema'] = collection.options.schema;
|
||||
}
|
||||
|
||||
this.defineTreePathCollection(name, options);
|
||||
|
||||
//afterSync
|
||||
|
@ -13,6 +13,7 @@ export default {
|
||||
dumpRules: {
|
||||
group: 'required',
|
||||
},
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
shared: true,
|
||||
name: 'collectionCategories',
|
||||
autoGenId: true,
|
||||
|
@ -11,6 +11,7 @@ import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
shared: true,
|
||||
name: 'collections',
|
||||
sortable: 'sort',
|
||||
|
@ -11,6 +11,7 @@ import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
shared: true,
|
||||
name: 'fields',
|
||||
autoGenId: false,
|
||||
|
@ -13,6 +13,8 @@ import { Plugin } from '@nocobase/server';
|
||||
import lodash from 'lodash';
|
||||
import path from 'path';
|
||||
import { CollectionRepository } from '.';
|
||||
import { FieldIsDependedOnByOtherError } from './errors/field-is-depended-on-by-other';
|
||||
import { FieldNameExistsError } from './errors/field-name-exists-error';
|
||||
import {
|
||||
afterCreateForForeignKeyField,
|
||||
afterCreateForReverseField,
|
||||
@ -20,15 +22,13 @@ import {
|
||||
beforeDestroyForeignKey,
|
||||
beforeInitOptions,
|
||||
} from './hooks';
|
||||
import { beforeCreateCheckFieldInMySQL } from './hooks/beforeCreateCheckFieldInMySQL';
|
||||
import { beforeCreateForValidateField, beforeUpdateForValidateField } from './hooks/beforeCreateForValidateField';
|
||||
import { beforeCreateForViewCollection } from './hooks/beforeCreateForViewCollection';
|
||||
import { beforeDestoryField } from './hooks/beforeDestoryField';
|
||||
import { CollectionModel, FieldModel } from './models';
|
||||
import collectionActions from './resourcers/collections';
|
||||
import viewResourcer from './resourcers/views';
|
||||
import { FieldNameExistsError } from './errors/field-name-exists-error';
|
||||
import { beforeDestoryField } from './hooks/beforeDestoryField';
|
||||
import { FieldIsDependedOnByOtherError } from './errors/field-is-depended-on-by-other';
|
||||
import { beforeCreateCheckFieldInMySQL } from './hooks/beforeCreateCheckFieldInMySQL';
|
||||
|
||||
export class PluginDataSourceMainServer extends Plugin {
|
||||
private loadFilter: Filter = {};
|
||||
@ -394,7 +394,6 @@ export class PluginDataSourceMainServer extends Plugin {
|
||||
}
|
||||
|
||||
async load() {
|
||||
await this.importCollections(path.resolve(__dirname, './collections'));
|
||||
this.db.getRepository<CollectionRepository>('collections').setApp(this.app);
|
||||
|
||||
const errorHandlerPlugin = this.app.getPlugin<PluginErrorHandler>('error-handler');
|
||||
|
@ -13,6 +13,7 @@ export default defineCollection({
|
||||
name: 'dataSourcesCollections',
|
||||
model: 'DataSourcesCollectionModel',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
shared: true,
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
|
@ -13,6 +13,7 @@ export default defineCollection({
|
||||
name: 'dataSourcesFields',
|
||||
model: 'DataSourcesFieldModel',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
shared: true,
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
|
@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
name: 'dataSourcesRolesResourcesActions',
|
||||
model: 'DataSourcesRolesResourcesActionModel',
|
||||
fields: [
|
||||
|
@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
name: 'dataSourcesRolesResourcesScopes',
|
||||
fields: [
|
||||
{
|
||||
|
@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
name: 'dataSourcesRolesResources',
|
||||
model: 'DataSourcesRolesResourcesModel',
|
||||
fields: [
|
||||
|
@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
name: 'dataSourcesRoles',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
model: 'DataSourcesRolesModel',
|
||||
|
@ -15,6 +15,7 @@ export default defineCollection({
|
||||
autoGenId: false,
|
||||
shared: true,
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
|
@ -7,13 +7,13 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Model, Transaction } from '@nocobase/database';
|
||||
import { Application } from '@nocobase/server';
|
||||
import { setCurrentRole } from '@nocobase/plugin-acl';
|
||||
import { ACL, AvailableActionOptions } from '@nocobase/acl';
|
||||
import { DataSourcesRolesModel } from './data-sources-roles-model';
|
||||
import { Model, Transaction } from '@nocobase/database';
|
||||
import { setCurrentRole } from '@nocobase/plugin-acl';
|
||||
import { Application } from '@nocobase/server';
|
||||
import path from 'path';
|
||||
import PluginDataSourceManagerServer from '../plugin';
|
||||
import * as path from 'path';
|
||||
import { DataSourcesRolesModel } from './data-sources-roles-model';
|
||||
|
||||
const availableActions: {
|
||||
[key: string]: AvailableActionOptions;
|
||||
|
@ -17,12 +17,12 @@ import rolesConnectionResourcesResourcer from './resourcers/data-sources-resourc
|
||||
import databaseConnectionsRolesResourcer from './resourcers/data-sources-roles';
|
||||
import { rolesRemoteCollectionsResourcer } from './resourcers/roles-data-sources-collections';
|
||||
|
||||
import { LoadingProgress } from '@nocobase/data-source-manager';
|
||||
import lodash from 'lodash';
|
||||
import { DataSourcesRolesResourcesModel } from './models/connections-roles-resources';
|
||||
import { DataSourcesRolesResourcesActionModel } from './models/connections-roles-resources-action';
|
||||
import { DataSourceModel } from './models/data-source';
|
||||
import { DataSourcesRolesModel } from './models/data-sources-roles-model';
|
||||
import { LoadingProgress } from '@nocobase/data-source-manager';
|
||||
|
||||
type DataSourceState = 'loading' | 'loaded' | 'loading-failed' | 'reloading' | 'reloading-failed';
|
||||
|
||||
@ -131,6 +131,10 @@ export class PluginDataSourceManagerServer extends Plugin {
|
||||
[dataSourceKey: string]: LoadingProgress;
|
||||
} = {};
|
||||
|
||||
renderJsonTemplate(template) {
|
||||
return this.app.environment.renderJsonTemplate(template);
|
||||
}
|
||||
|
||||
async beforeLoad() {
|
||||
this.app.db.registerModels({
|
||||
DataSourcesCollectionModel,
|
||||
@ -187,7 +191,7 @@ export class PluginDataSourceManagerServer extends Plugin {
|
||||
}
|
||||
|
||||
try {
|
||||
await klass.testConnection(dataSourceOptions);
|
||||
await klass.testConnection(this.renderJsonTemplate(dataSourceOptions || {}));
|
||||
} catch (error) {
|
||||
throw new Error(`Test connection failed: ${error.message}`);
|
||||
}
|
||||
@ -398,7 +402,7 @@ export class PluginDataSourceManagerServer extends Plugin {
|
||||
const klass = ctx.app.dataSourceManager.factory.getClass(type);
|
||||
|
||||
try {
|
||||
await klass.testConnection(options);
|
||||
await klass.testConnection(self.renderJsonTemplate(options));
|
||||
} catch (error) {
|
||||
throw new Error(`Test connection failed: ${error.message}`);
|
||||
}
|
||||
|
@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/src
|
@ -0,0 +1 @@
|
||||
# @nocobase/plugin-environment-variables
|
2
packages/plugins/@nocobase/plugin-environment-variables/client.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-environment-variables/client.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/client';
|
||||
export { default } from './dist/client';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client/index.js');
|
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@nocobase/plugin-environment-variables",
|
||||
"version": "1.6.0-alpha.9",
|
||||
"main": "dist/server/index.js",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"@nocobase/client": "1.x",
|
||||
"@nocobase/server": "1.x",
|
||||
"@nocobase/test": "1.x"
|
||||
},
|
||||
"displayName": "Environment variables",
|
||||
"displayName.zh-CN": "环境变量",
|
||||
"description": "Centralized configuration and management of environment variables, used for sensitive data storage, configuration data reuse, multi-environment isolation, and more.",
|
||||
"description.zh-CN": "集中配置和管理环境变量,用于敏感数据存储、配置数据重用、多环境隔离等。",
|
||||
"keywords": [
|
||||
"System management"
|
||||
]
|
||||
}
|
2
packages/plugins/@nocobase/plugin-environment-variables/server.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-environment-variables/server.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/server';
|
||||
export { default } from './dist/server';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/server/index.js');
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { observer } from '@formily/react';
|
||||
import { useIsAdminPage, useRequest } from '@nocobase/client';
|
||||
import React, { createContext } from 'react';
|
||||
|
||||
const EnvAndSecretsContext = createContext<any>({});
|
||||
|
||||
const InternalProvider = (props) => {
|
||||
const variablesRequest = useRequest<any>({
|
||||
url: 'environmentVariables?paginate=false',
|
||||
});
|
||||
return <EnvAndSecretsContext.Provider value={{ variablesRequest }}>{props.children}</EnvAndSecretsContext.Provider>;
|
||||
};
|
||||
|
||||
const EnvironmentVariablesAndSecretsProvider = observer(
|
||||
(props) => {
|
||||
const isAdminPage = useIsAdminPage();
|
||||
if (!isAdminPage) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
return <InternalProvider {...props} />;
|
||||
},
|
||||
{
|
||||
displayName: 'EnvironmentVariablesAndSecretsProvider',
|
||||
},
|
||||
);
|
||||
|
||||
export { EnvAndSecretsContext, EnvironmentVariablesAndSecretsProvider };
|
249
packages/plugins/@nocobase/plugin-environment-variables/src/client/client.d.ts
vendored
Normal file
249
packages/plugins/@nocobase/plugin-environment-variables/src/client/client.d.ts
vendored
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
// CSS modules
|
||||
type CSSModuleClasses = { readonly [key: string]: string };
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.scss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sass' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.less' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.styl' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.stylus' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.pcss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
|
||||
// CSS
|
||||
declare module '*.css' { }
|
||||
declare module '*.scss' { }
|
||||
declare module '*.sass' { }
|
||||
declare module '*.less' { }
|
||||
declare module '*.styl' { }
|
||||
declare module '*.stylus' { }
|
||||
declare module '*.pcss' { }
|
||||
declare module '*.sss' { }
|
||||
|
||||
// Built-in asset types
|
||||
// see `src/node/constants.ts`
|
||||
|
||||
// images
|
||||
declare module '*.apng' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jfif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.gif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ico' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.avif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// media
|
||||
declare module '*.mp4' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webm' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ogg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.wav' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.flac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.aac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.opus' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mov' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.m4a' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.vtt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// fonts
|
||||
declare module '*.woff' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.woff2' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.eot' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ttf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.otf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// other
|
||||
declare module '*.webmanifest' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pdf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.txt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// wasm?init
|
||||
declare module '*.wasm?init' {
|
||||
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
|
||||
export default initWasm;
|
||||
}
|
||||
|
||||
// web worker
|
||||
declare module '*?worker' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&inline' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&inline' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?inline' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EnvironmentTabs } from './EnvironmentTabs';
|
||||
|
||||
export default function EnvironmentPage() {
|
||||
return <EnvironmentTabs />;
|
||||
}
|
@ -0,0 +1,510 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import { DownOutlined, PlusOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Checkbox,
|
||||
FormButtonGroup,
|
||||
FormDrawer,
|
||||
FormItem,
|
||||
FormLayout,
|
||||
Input,
|
||||
Radio,
|
||||
Reset,
|
||||
Submit,
|
||||
} from '@formily/antd-v5';
|
||||
import { useField } from '@formily/react';
|
||||
import { registerValidateRules } from '@formily/core';
|
||||
import { createSchemaField } from '@formily/react';
|
||||
import {
|
||||
SchemaComponentOptions,
|
||||
useAPIClient,
|
||||
SchemaComponent,
|
||||
useFilterFieldProps,
|
||||
useFilterFieldOptions,
|
||||
} from '@nocobase/client';
|
||||
import { Alert, App, Button, Card, Dropdown, Flex, Space, Table, Tag } from 'antd';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { VAR_NAME_RE } from '../../re';
|
||||
import { EnvAndSecretsContext } from '../EnvironmentVariablesAndSecretsProvider';
|
||||
import { useT } from '../locale';
|
||||
|
||||
registerValidateRules({
|
||||
env_name_rule(value) {
|
||||
if (!value) return '';
|
||||
return VAR_NAME_RE.test(value)
|
||||
? ''
|
||||
: 'Only letters, numbers and underscores are allowed, and it must start with a letter.';
|
||||
},
|
||||
});
|
||||
|
||||
const SchemaField = createSchemaField({
|
||||
components: {
|
||||
FormItem,
|
||||
Input,
|
||||
Checkbox,
|
||||
Radio,
|
||||
},
|
||||
});
|
||||
|
||||
const bulkSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
variables: {
|
||||
type: 'string',
|
||||
title: `{{ t("Plain text") }}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.TextArea',
|
||||
'x-component-props': {
|
||||
autoSize: { minRows: 10, maxRows: 20 },
|
||||
placeholder: `FOO=aaa
|
||||
BAR=bbb
|
||||
`,
|
||||
},
|
||||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
title: `{{ t("Encrypted") }}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.TextArea',
|
||||
'x-component-props': {
|
||||
autoSize: { minRows: 10, maxRows: 20 },
|
||||
placeholder: `FOO=aaa
|
||||
BAR=bbb
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: `{{ t("Name") }}`,
|
||||
required: true,
|
||||
'x-validator': {
|
||||
env_name_rule: true,
|
||||
},
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-disabled': '{{ !createOnly }}',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
title: `{{ t("Type") }}`,
|
||||
required: true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Radio.Group',
|
||||
default: 'default',
|
||||
enum: [
|
||||
{
|
||||
value: 'default',
|
||||
label: '{{t("Plain text")}}',
|
||||
},
|
||||
{
|
||||
value: 'secret',
|
||||
label: '{{t("Encrypted")}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
title: `{{ t("Value") }}`,
|
||||
required: true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.TextArea',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function EnvironmentVariables({ request, setSelectRowKeys }) {
|
||||
const { modal } = App.useApp();
|
||||
const t = useT();
|
||||
const api = useAPIClient();
|
||||
const { data, loading, refresh } = request || {};
|
||||
|
||||
const typEnum = {
|
||||
default: {
|
||||
label: t('Plain text'),
|
||||
color: 'green',
|
||||
},
|
||||
secret: {
|
||||
label: t('Encrypted'),
|
||||
color: 'red',
|
||||
},
|
||||
};
|
||||
|
||||
const resource = api.resource('environmentVariables');
|
||||
|
||||
const handleDelete = (data) => {
|
||||
modal.confirm({
|
||||
title: t('Delete variable'),
|
||||
content: t('Are you sure you want to delete it?'),
|
||||
async onOk() {
|
||||
await resource.destroy({
|
||||
filterByTk: data.name,
|
||||
});
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = async (initialValues) => {
|
||||
const drawer = FormDrawer({ title: t('Edit') }, () => {
|
||||
return (
|
||||
<FormLayout layout={'vertical'}>
|
||||
<SchemaComponentOptions scope={{ createOnly: false, t }}>
|
||||
<SchemaField schema={schema} />
|
||||
</SchemaComponentOptions>
|
||||
<FormDrawer.Footer>
|
||||
<FormButtonGroup align="right">
|
||||
<Reset
|
||||
onClick={() => {
|
||||
drawer.close();
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Reset>
|
||||
<Submit
|
||||
onSubmit={async (data) => {
|
||||
await api.request({
|
||||
url: `environmentVariables:update?filterByTk=${initialValues.name}`,
|
||||
method: 'post',
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
request.refresh();
|
||||
}}
|
||||
>
|
||||
{t('Submit')}
|
||||
</Submit>
|
||||
</FormButtonGroup>
|
||||
</FormDrawer.Footer>
|
||||
</FormLayout>
|
||||
);
|
||||
});
|
||||
drawer.open({
|
||||
initialValues: { ...initialValues },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
loading={loading}
|
||||
size="middle"
|
||||
rowKey={'name'}
|
||||
dataSource={data?.data}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: t('Name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('Type'),
|
||||
dataIndex: 'type',
|
||||
render: (value) => <Tag color={typEnum[value].color}>{typEnum[value].label}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('Value'),
|
||||
ellipsis: true,
|
||||
render: (record) => <div>{record.type === 'default' ? record.value : '******'}</div>,
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 200,
|
||||
render: (record) => (
|
||||
<Space>
|
||||
<a onClick={() => handleEdit(record)}>{t('Edit')}</a>
|
||||
<a onClick={() => handleDelete(record)}>{t('Delete')}</a>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rowSelection={{
|
||||
type: 'checkbox',
|
||||
onChange: (rowKeys, selectedRows) => {
|
||||
setSelectRowKeys(rowKeys);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @param {string} input - The input string containing key-value pairs, separated by `=` and line breaks.
|
||||
* @returns {Array<{name: string, value: string}>} - The converted array of objects.
|
||||
*/
|
||||
function parseKeyValuePairs(input, type) {
|
||||
return input
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const [name, ...valueParts] = line.split('='); // 按 `=` 分割
|
||||
return (
|
||||
name && {
|
||||
name: name.trim(),
|
||||
value: valueParts.join('=').trim(),
|
||||
type: type === 'secret' ? 'secret' : 'default',
|
||||
}
|
||||
); // 去除多余空格
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentTabs() {
|
||||
const api = useAPIClient();
|
||||
const t = useT();
|
||||
const { modal } = App.useApp();
|
||||
const { variablesRequest } = useContext(EnvAndSecretsContext);
|
||||
const [selectRowKeys, setSelectRowKeys] = useState([]);
|
||||
const resource = api.resource('environmentVariables');
|
||||
|
||||
const handleBulkImport = async (importData) => {
|
||||
const arr = Object.entries(importData).map(([type, dataString]) => {
|
||||
return parseKeyValuePairs(dataString, type).filter(Boolean);
|
||||
});
|
||||
await api.request({
|
||||
url: 'environmentVariables:create',
|
||||
method: 'post',
|
||||
data: arr.flat(),
|
||||
});
|
||||
};
|
||||
const handelBulkDelete = () => {
|
||||
if (selectRowKeys.length > 0) {
|
||||
modal.confirm({
|
||||
title: t('Delete variable'),
|
||||
content: t('Are you sure you want to delete it?'),
|
||||
async onOk() {
|
||||
await resource.destroy({
|
||||
filterByTk: selectRowKeys,
|
||||
});
|
||||
variablesRequest?.refresh?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
const handelRefresh = () => {
|
||||
variablesRequest?.refresh?.();
|
||||
};
|
||||
|
||||
const filterOptions = [
|
||||
{
|
||||
name: 'name',
|
||||
title: t('Name'),
|
||||
operators: [
|
||||
{ label: '{{t("contains")}}', value: '$includes', selected: true },
|
||||
{ label: '{{t("does not contain")}}', value: '$notIncludes' },
|
||||
{ label: '{{t("is")}}', value: '$eq' },
|
||||
{ label: '{{t("is not")}}', value: '$ne' },
|
||||
],
|
||||
schema: {
|
||||
type: 'string',
|
||||
title: t('Name'),
|
||||
'x-component': 'Input',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
title: t('Type'),
|
||||
operators: [
|
||||
{
|
||||
label: '{{t("is")}}',
|
||||
value: '$match',
|
||||
selected: true,
|
||||
schema: {
|
||||
'x-component': 'Select',
|
||||
'x-component-props': { mode: 'tags' },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '{{t("is not")}}',
|
||||
value: '$notMatch',
|
||||
schema: {
|
||||
'x-component': 'Select',
|
||||
'x-component-props': { mode: 'tags' },
|
||||
},
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
type: 'string',
|
||||
title: t('Type'),
|
||||
'x-component': 'Select',
|
||||
enum: [
|
||||
{
|
||||
value: 'default',
|
||||
label: '{{t("Plain text")}}',
|
||||
},
|
||||
{
|
||||
value: 'secret',
|
||||
label: '{{t("Encrypted")}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
title: t('Value'),
|
||||
operators: [
|
||||
{ label: '{{t("contains")}}', value: '$includes', selected: true },
|
||||
{ label: '{{t("does not contain")}}', value: '$notIncludes' },
|
||||
{ label: '{{t("is")}}', value: '$eq' },
|
||||
{ label: '{{t("is not")}}', value: '$ne' },
|
||||
],
|
||||
schema: {
|
||||
type: 'string',
|
||||
title: t('Value'),
|
||||
'x-component': 'Input',
|
||||
},
|
||||
},
|
||||
];
|
||||
const useFilterActionProps = () => {
|
||||
const field = useField<any>();
|
||||
const { run } = variablesRequest;
|
||||
|
||||
return {
|
||||
options: filterOptions,
|
||||
onSubmit: async (values) => {
|
||||
run(values);
|
||||
|
||||
field.setValue(values);
|
||||
},
|
||||
onReset: (values) => {
|
||||
field.setValue(values);
|
||||
},
|
||||
};
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
{variablesRequest?.data?.meta?.updated && (
|
||||
<Alert
|
||||
type="warning"
|
||||
style={{ marginBottom: '1.2em', alignItems: 'center' }}
|
||||
description={
|
||||
<div>
|
||||
{t('Environment variables have been updated. A restart is required for the changes to take effect.')}{' '}
|
||||
</div>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
size="middle"
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
await api.resource('app').refresh();
|
||||
}}
|
||||
>
|
||||
{t('Restart now')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Card>
|
||||
<div style={{ float: 'left' }}>
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
name: 'filter',
|
||||
type: 'object',
|
||||
title: '{{ t("Filter") }}',
|
||||
default: {
|
||||
$and: [{ name: { $includes: '' } }],
|
||||
},
|
||||
'x-component': 'Filter.Action',
|
||||
|
||||
enum: filterOptions,
|
||||
'x-use-component-props': useFilterActionProps,
|
||||
}}
|
||||
scope={{ t }}
|
||||
/>
|
||||
</div>
|
||||
<Flex justify="end" style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={handelRefresh}>
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
|
||||
<Button icon={<DeleteOutlined />} onClick={handelBulkDelete}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{
|
||||
onClick(info) {
|
||||
FormDrawer(
|
||||
{
|
||||
variable: t('Add variable'),
|
||||
bulk: t('Bulk import'),
|
||||
}[info.key],
|
||||
() => {
|
||||
return (
|
||||
<FormLayout layout={'vertical'}>
|
||||
<SchemaComponentOptions scope={{ createOnly: true, t }}>
|
||||
<SchemaField schema={info.key === 'bulk' ? bulkSchema : schema} />
|
||||
</SchemaComponentOptions>
|
||||
<FormDrawer.Footer>
|
||||
<FormButtonGroup align="right">
|
||||
<Reset>{t('Cancel')}</Reset>
|
||||
<Submit
|
||||
onSubmit={async (data) => {
|
||||
if (info.key === 'bulk') {
|
||||
await handleBulkImport(data);
|
||||
variablesRequest.refresh();
|
||||
} else {
|
||||
await api.request({
|
||||
url: 'environmentVariables:create',
|
||||
method: 'post',
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
variablesRequest.refresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Submit')}
|
||||
</Submit>
|
||||
</FormButtonGroup>
|
||||
</FormDrawer.Footer>
|
||||
</FormLayout>
|
||||
);
|
||||
},
|
||||
)
|
||||
.open({
|
||||
initialValues: {},
|
||||
})
|
||||
.then(console.log)
|
||||
.catch(console.log);
|
||||
},
|
||||
items: [
|
||||
{
|
||||
key: 'variable',
|
||||
label: t('Add variable'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'bulk',
|
||||
label: t('Bulk import'),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
{t('Add new')} <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Flex>
|
||||
<EnvironmentVariables request={variablesRequest} setSelectRowKeys={setSelectRowKeys} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Plugin } from '@nocobase/client';
|
||||
import { EnvironmentVariablesAndSecretsProvider } from './EnvironmentVariablesAndSecretsProvider';
|
||||
import EnvironmentPage from './components/EnvironmentPage';
|
||||
import { useGetEnvironmentVariables } from './utils';
|
||||
|
||||
export class PluginEnvironmentVariablesClient extends Plugin {
|
||||
async load() {
|
||||
this.app.pluginSettingsManager.add('environment', {
|
||||
title: this.t('Environment variables'),
|
||||
icon: 'TableOutlined',
|
||||
Component: EnvironmentPage,
|
||||
});
|
||||
this.app.addGlobalVar('$env', useGetEnvironmentVariables);
|
||||
this.app.use(EnvironmentVariablesAndSecretsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginEnvironmentVariablesClient;
|
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
// @ts-ignore
|
||||
import pkg from './../../package.json';
|
||||
import { useApp } from '@nocobase/client';
|
||||
|
||||
export function useT() {
|
||||
const app = useApp();
|
||||
return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] });
|
||||
}
|
||||
|
||||
export function tStr(key: string) {
|
||||
return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`;
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useContext } from 'react';
|
||||
import { EnvAndSecretsContext } from './EnvironmentVariablesAndSecretsProvider';
|
||||
import { useT } from './locale';
|
||||
|
||||
export const useGetEnvironmentVariables = () => {
|
||||
const t = useT();
|
||||
const { variablesRequest } = useContext(EnvAndSecretsContext);
|
||||
const { data: variables, loading: variablesLoading } = variablesRequest || {};
|
||||
if (!variablesLoading && variables?.data?.length) {
|
||||
return {
|
||||
name: '$env',
|
||||
title: t('Environment variables'),
|
||||
value: '$env',
|
||||
label: t('Environment variables'),
|
||||
children: variables?.data
|
||||
.map((v) => {
|
||||
return { title: v.name, name: v.name, value: v.name, label: v.name };
|
||||
})
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './server';
|
||||
export { default } from './server';
|
@ -0,0 +1 @@
|
||||
{}
|
@ -0,0 +1,16 @@
|
||||
{
|
||||
"Environment": "环境",
|
||||
"Environment variables": "环境变量",
|
||||
"Variables": "变量",
|
||||
"Secrets": "密钥",
|
||||
"Add variable": "添加变量",
|
||||
"Bulk import": "批量导入",
|
||||
"Name": "名称",
|
||||
"Value": "值",
|
||||
"Type": "类型",
|
||||
"Plain text": "明文",
|
||||
"Encrypted": "加密",
|
||||
"Delete variable": "删除变量",
|
||||
"Restart now": "立即重启",
|
||||
"Environment variables have been updated. A restart is required for the changes to take effect.": "检测到环境变量有更新,需重启应用才能生效。"
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const VAR_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
|
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
class AesEncryptor {
|
||||
private key: Buffer;
|
||||
|
||||
constructor(key: Buffer) {
|
||||
if (key.length !== 32) {
|
||||
throw new Error('Key must be 32 bytes for AES-256 encryption.');
|
||||
}
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
async encrypt(text: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', this.key as any, iv as any);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(Buffer.from(text, 'utf8') as any), cipher.final()] as any[]);
|
||||
|
||||
resolve(iv.toString('hex') + encrypted.toString('hex'));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async decrypt(encryptedText: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const iv = Buffer.from(encryptedText.slice(0, 32), 'hex'); // 提取前 16 字节作为 IV
|
||||
const encrypted = Buffer.from(encryptedText.slice(32), 'hex'); // 提取密文
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key as any, iv as any);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted as any), decipher.final()] as any);
|
||||
|
||||
resolve(decrypted.toString('utf8'));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async getOrGenerateKey(keyFilePath: string): Promise<Buffer> {
|
||||
try {
|
||||
const key = await fs.readFile(keyFilePath);
|
||||
if (key.length !== 32) {
|
||||
throw new Error('Invalid key length in file.');
|
||||
}
|
||||
return key;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
const key = crypto.randomBytes(32);
|
||||
await fs.mkdir(path.dirname(keyFilePath), { recursive: true });
|
||||
await fs.writeFile(keyFilePath, key as any);
|
||||
return key;
|
||||
} else {
|
||||
throw new Error(`Failed to load key: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AesEncryptor;
|
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
import { VAR_NAME_RE } from '../../re';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'environmentVariables',
|
||||
autoGenId: false,
|
||||
migrationRules: ['schema-only'],
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
primaryKey: true,
|
||||
validate: {
|
||||
is: VAR_NAME_RE,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
},
|
||||
],
|
||||
});
|
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export { default } from './plugin';
|
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import path from 'path';
|
||||
import AesEncryptor from './AesEncryptor';
|
||||
|
||||
export class PluginEnvironmentVariablesServer extends Plugin {
|
||||
aesEncryptor: AesEncryptor;
|
||||
updated = false;
|
||||
async handleSyncMessage(message) {
|
||||
const { type, name, value } = message;
|
||||
if (type === 'updated') {
|
||||
this.updated = true;
|
||||
} else if (type === 'setVariable') {
|
||||
this.app.environment.setVariable(name, value);
|
||||
} else if (type === 'removeVariable') {
|
||||
this.app.environment.removeVariable(name);
|
||||
this.updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.createAesEncryptor();
|
||||
this.registerACL();
|
||||
this.onEnvironmentSaved();
|
||||
await this.loadVariables();
|
||||
}
|
||||
|
||||
async createAesEncryptor() {
|
||||
const key = await AesEncryptor.getOrGenerateKey(
|
||||
path.resolve(process.cwd(), 'storage', this.name, this.app.name, 'aes_key.dat'),
|
||||
);
|
||||
this.aesEncryptor = new AesEncryptor(key);
|
||||
}
|
||||
|
||||
registerACL() {
|
||||
this.app.acl.allow('environmentVariables', 'list', 'loggedIn');
|
||||
this.app.acl.registerSnippet({
|
||||
name: `pm.${this.name}`,
|
||||
actions: ['environmentVariables:*', 'app:refresh'],
|
||||
});
|
||||
}
|
||||
|
||||
async listEnvironmentVariables() {
|
||||
const repository = this.db.getRepository('environmentVariables');
|
||||
const items = await repository.find({
|
||||
sort: 'name',
|
||||
});
|
||||
|
||||
return items.map(({ name, type }) => ({ name, type }));
|
||||
}
|
||||
|
||||
async setEnvironmentVariablesByText(texts: Array<{ text: string; secret: boolean }>) {
|
||||
/*
|
||||
text format:
|
||||
KEY1=VALUE1
|
||||
KEY2=VALUE2
|
||||
# This is a comment
|
||||
KEY3=VALUE3
|
||||
*/
|
||||
const repository = this.db.getRepository('environmentVariables');
|
||||
|
||||
for (const { text, secret } of texts) {
|
||||
// Split by newline and process each line
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim()) // Remove leading/trailing spaces
|
||||
.filter((line) => line && !line.startsWith('#')); // Remove empty lines and comments
|
||||
|
||||
for (const line of lines) {
|
||||
// Find first '=' to support values containing '='
|
||||
const equalIndex = line.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
this.app.log.warn(`Invalid environment variable format: ${line}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, equalIndex).trim();
|
||||
const value = line.slice(equalIndex + 1).trim();
|
||||
|
||||
if (!key || !value) {
|
||||
this.app.log.warn(`Empty key or value found: ${line}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await repository.create({
|
||||
values: {
|
||||
name: key,
|
||||
type: secret ? 'secret' : 'default',
|
||||
value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onEnvironmentSaved() {
|
||||
this.db.on('environmentVariables.afterUpdate', async (model, { transaction }) => {
|
||||
this.updated = true;
|
||||
this.sendSyncMessage({ type: 'updated' }, { transaction });
|
||||
});
|
||||
this.db.on('environmentVariables.beforeSave', async (model) => {
|
||||
if (model.type === 'secret' && model.changed('value')) {
|
||||
const encrypted = await this.aesEncryptor.encrypt(model.value);
|
||||
model.set('value', encrypted);
|
||||
}
|
||||
});
|
||||
this.app.resourceManager.registerActionHandler('environmentVariables:list', async (ctx, next) => {
|
||||
const repository = this.db.getRepository('environmentVariables');
|
||||
const items = await repository.find({
|
||||
sort: 'name',
|
||||
filter: ctx.action.params.filter,
|
||||
});
|
||||
for (const model of items) {
|
||||
if (model.type === 'secret') {
|
||||
model.set('value', undefined);
|
||||
}
|
||||
}
|
||||
ctx.withoutDataWrapping = true;
|
||||
ctx.body = {
|
||||
data: items,
|
||||
meta: {
|
||||
updated: this.updated,
|
||||
},
|
||||
};
|
||||
await next();
|
||||
});
|
||||
this.db.on('environmentVariables.afterSave', async (model, { transaction }) => {
|
||||
if (model.type === 'secret') {
|
||||
try {
|
||||
const decrypted = await this.aesEncryptor.decrypt(model.value);
|
||||
model.set('value', decrypted);
|
||||
} catch (error) {
|
||||
this.app.log.error(error);
|
||||
}
|
||||
}
|
||||
this.app.environment.setVariable(model.name, model.value);
|
||||
this.sendSyncMessage({ type: 'setVariable', name: model.name, value: model.value }, { transaction });
|
||||
});
|
||||
this.db.on('environmentVariables.afterDestroy', async (model, { transaction }) => {
|
||||
this.app.environment.removeVariable(model.name);
|
||||
this.updated = true;
|
||||
this.sendSyncMessage({ type: 'removeVariable', name: model.name }, { transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async loadVariables() {
|
||||
const repository = this.db.getRepository('environmentVariables');
|
||||
const r = await repository.collection.existsInDb();
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
const items = await repository.find();
|
||||
for (const model of items) {
|
||||
if (model.type === 'secret') {
|
||||
try {
|
||||
const decrypted = await this.aesEncryptor.decrypt(model.value);
|
||||
model.set('value', decrypted);
|
||||
} catch (error) {
|
||||
this.app.log.error(error);
|
||||
}
|
||||
}
|
||||
this.app.environment.setVariable(model.name, model.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginEnvironmentVariablesServer;
|
@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
dumpRules: 'skipped',
|
||||
migrationRules: ['schema-only', 'overwrite', 'skip'],
|
||||
name: 'chinaRegions',
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
|
@ -65,6 +65,7 @@ export default defineCollection({
|
||||
});
|
||||
},
|
||||
},
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
name: 'sequences',
|
||||
shared: true,
|
||||
fields: [
|
||||
|
@ -31,35 +31,36 @@ const schema = {
|
||||
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
accessKeyId: {
|
||||
title: `{{t("AccessKey ID", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
accessKeySecret: {
|
||||
title: `{{t("AccessKey Secret", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Password',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': { password: true },
|
||||
required: true,
|
||||
},
|
||||
bucket: {
|
||||
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
thumbnailRule: {
|
||||
title: 'Thumbnail rule',
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -69,14 +70,14 @@ const schema = {
|
||||
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
SecretId: {
|
||||
title: `{{t("SecretId", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
SecretKey: {
|
||||
@ -90,7 +91,7 @@ const schema = {
|
||||
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
@ -101,35 +102,36 @@ const schema = {
|
||||
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
accessKeyId: {
|
||||
title: `{{t("AccessKey ID", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
secretAccessKey: {
|
||||
title: `{{t("AccessKey Secret", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Password',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': { password: true },
|
||||
required: true,
|
||||
},
|
||||
bucket: {
|
||||
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
endpoint: {
|
||||
title: `{{t("Endpoint", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -55,7 +55,7 @@ const collection = {
|
||||
uiSchema: {
|
||||
title: `{{t("Access base URL", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
} as ISchema,
|
||||
},
|
||||
@ -66,7 +66,7 @@ const collection = {
|
||||
uiSchema: {
|
||||
title: `{{t("Path", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
} as ISchema,
|
||||
},
|
||||
{
|
||||
|
@ -28,7 +28,7 @@ export default {
|
||||
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
description: `{{t('Aliyun OSS region part of the bucket. For example: "oss-cn-beijing".', { ns: "${NAMESPACE}" })}}`,
|
||||
required: true,
|
||||
},
|
||||
@ -36,28 +36,29 @@ export default {
|
||||
title: `{{t("AccessKey ID", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
accessKeySecret: {
|
||||
title: `{{t("AccessKey Secret", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Password',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': { password: true },
|
||||
required: true,
|
||||
},
|
||||
bucket: {
|
||||
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
thumbnailRule: {
|
||||
title: 'Thumbnail rule',
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': {
|
||||
placeholder: '?x-oss-process=image/auto-orient,1/resize,m_fill,w_94,h_94/quality,q_90',
|
||||
},
|
||||
|
@ -25,35 +25,36 @@ export default {
|
||||
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
accessKeyId: {
|
||||
title: `{{t("AccessKey ID", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
secretAccessKey: {
|
||||
title: `{{t("AccessKey Secret", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Password',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': { password: true },
|
||||
required: true,
|
||||
},
|
||||
bucket: {
|
||||
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
endpoint: {
|
||||
title: `{{t("Endpoint", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -27,35 +27,38 @@ export default {
|
||||
title: `{{t("Region", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
SecretId: {
|
||||
title: `{{t("SecretId", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
SecretKey: {
|
||||
title: `{{t("SecretKey", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Password',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': {
|
||||
password: true,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
Bucket: {
|
||||
title: `{{t("Bucket", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
required: true,
|
||||
},
|
||||
thumbnailRule: {
|
||||
title: 'Thumbnail rule',
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-component-props': {
|
||||
placeholder: '?imageMogr2/thumbnail/!50p',
|
||||
},
|
||||
|
@ -94,7 +94,7 @@ export class FileCollectionTemplate extends CollectionTemplate {
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: `{{t("Path", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-component': 'Input',
|
||||
'x-component': 'TextAreaWithGlobalScope',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
|
@ -125,7 +125,9 @@ export async function createMiddleware(ctx: Context, next: Next) {
|
||||
const StorageRepo = ctx.db.getRepository('storages');
|
||||
const storage = await StorageRepo.findOne({ filter: storageName ? { name: storageName } : { default: true } });
|
||||
|
||||
ctx.storage = storage;
|
||||
const plugin = ctx.app.pm.get(Plugin);
|
||||
ctx.storage = plugin.parseStorage(storage);
|
||||
|
||||
if (ctx?.request.is('multipart/*')) {
|
||||
await multipart(ctx, next);
|
||||
} else {
|
||||
@ -172,11 +174,12 @@ export async function destroyMiddleware(ctx: Context, next: Next) {
|
||||
|
||||
let count = 0;
|
||||
const undeleted = [];
|
||||
const plugin = ctx.app.pm.get(Plugin);
|
||||
await storages.reduce(
|
||||
(promise, storage) =>
|
||||
promise.then(async () => {
|
||||
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
|
||||
const result = await storageConfig.delete(storage, storageGroupedRecords[storage.id]);
|
||||
const storageConfig = plugin.storageTypes.get(storage.type);
|
||||
const result = await storageConfig.delete(plugin.parseStorage(storage), storageGroupedRecords[storage.id]);
|
||||
count += result[0];
|
||||
undeleted.push(...result[1]);
|
||||
}),
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user