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:
Katherine 2025-01-08 09:32:49 +08:00 committed by GitHub
parent cbdd5ffe8c
commit 5d5f455b3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
162 changed files with 1902 additions and 486 deletions

14
.gitignore vendored
View File

@ -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--**

View File

@ -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 \

View File

@ -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)" \

View File

@ -2,9 +2,7 @@
"version": "1.6.0-alpha.9",
"npmClient": "yarn",
"useWorkspaces": true,
"npmClientArgs": [
"--ignore-engines"
],
"npmClientArgs": ["--ignore-engines"],
"command": {
"version": {
"forcePublish": true,

View File

@ -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: {

View File

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

View File

@ -11,3 +11,4 @@ export * from './useApp';
export * from './useAppSpin';
export * from './usePlugin';
export * from './useRouter';
export * from './useGlobalVariable';

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -8,3 +8,4 @@
*/
export * from './Variable';
export { TextAreaWithGlobalScope } from './TextAreaWithGlobalScope';

View File

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

View File

@ -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: {

View File

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

View File

@ -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) {}

View File

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

View File

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

View File

@ -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) {

View File

@ -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) {

View File

@ -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;

View File

@ -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;

View File

@ -147,7 +147,7 @@ describe('underscored options', () => {
type: 'belongsToMany',
name: 'tags',
through: 'collectionCategory',
target: 'posts',
target: 'tags',
sourceKey: 'name',
foreignKey: 'postsName',
targetKey: 'name',

View File

@ -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>;

View File

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

View File

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

View File

@ -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) {

View File

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

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

View File

@ -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',

View File

@ -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: [

View File

@ -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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
export default defineCollection({
dumpRules: 'required',
name: 'rolesResources',
migrationRules: ['overwrite', 'skip'],
model: 'RoleResourceModel',
indexes: [
{

View File

@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
export default defineCollection({
dumpRules: 'required',
name: 'rolesResourcesActions',
migrationRules: ['overwrite', 'skip'],
model: 'RoleResourceActionModel',
fields: [
{

View File

@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
export default defineCollection({
dumpRules: 'required',
name: 'rolesResourcesScopes',
migrationRules: ['overwrite', 'skip'],
fields: [
{
type: 'uid',

View File

@ -11,6 +11,7 @@ import { extendCollection } from '@nocobase/database';
export default extendCollection({
name: 'users',
migrationRules: ['schema-only', 'overwrite', 'skip'],
fields: [
{
interface: 'm2m',

View File

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

View File

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

View File

@ -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(

View File

@ -13,6 +13,7 @@ export default defineCollection({
dumpRules: 'required',
name: 'customRequests',
autoGenId: false,
migrationRules: ['overwrite', 'skip'],
fields: [
{
type: 'uid',

View File

@ -12,4 +12,5 @@ import { defineCollection } from '@nocobase/database';
export default defineCollection({
dumpRules: 'required',
name: 'customRequestsRoles',
migrationRules: ['overwrite', 'skip'],
});

View File

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

View File

@ -14,6 +14,7 @@ export default {
dumpRules: {
group: 'user',
},
migrationRules: ['schema-only', 'skip'],
shared: true,
name: 'apiKeys',
sortable: 'sort',

View File

@ -13,6 +13,7 @@ export default defineCollection({
dumpRules: {
group: 'log',
},
migrationRules: ['schema-only', 'skip'],
name: 'auditLogs',
createdBy: false,
updatedBy: false,

View File

@ -16,6 +16,7 @@ export default defineCollection({
dumpRules: {
group: 'third-party',
},
migrationRules: ['overwrite', 'skip'],
shared: true,
name: 'authenticators',
sortable: true,

View File

@ -13,6 +13,7 @@ export default defineCollection({
dumpRules: {
group: 'log',
},
migrationRules: ['schema-only', 'skip'],
shared: true,
name: 'tokenBlacklist',
model: 'TokenBlacklistModel',

View File

@ -18,6 +18,7 @@ export default defineCollection({
group: 'user',
},
shared: true,
migrationRules: ['schema-only', 'overwrite', 'skip'],
name: 'usersAuthenticators',
model: 'UserAuthModel',
createdBy: true,

View File

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

View File

@ -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];

View File

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

View File

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

View File

@ -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

View File

@ -13,6 +13,7 @@ export default {
dumpRules: {
group: 'required',
},
migrationRules: ['overwrite', 'skip'],
shared: true,
name: 'collectionCategories',
autoGenId: true,

View File

@ -11,6 +11,7 @@ import { CollectionOptions } from '@nocobase/database';
export default {
dumpRules: 'required',
migrationRules: ['overwrite', 'skip'],
shared: true,
name: 'collections',
sortable: 'sort',

View File

@ -11,6 +11,7 @@ import { CollectionOptions } from '@nocobase/database';
export default {
dumpRules: 'required',
migrationRules: ['overwrite', 'skip'],
shared: true,
name: 'fields',
autoGenId: false,

View File

@ -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');

View File

@ -13,6 +13,7 @@ export default defineCollection({
name: 'dataSourcesCollections',
model: 'DataSourcesCollectionModel',
dumpRules: 'required',
migrationRules: ['overwrite', 'skip'],
shared: true,
autoGenId: false,
timestamps: false,

View File

@ -13,6 +13,7 @@ export default defineCollection({
name: 'dataSourcesFields',
model: 'DataSourcesFieldModel',
dumpRules: 'required',
migrationRules: ['overwrite', 'skip'],
shared: true,
autoGenId: false,
timestamps: false,

View File

@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
export default defineCollection({
dumpRules: 'required',
migrationRules: ['overwrite', 'skip'],
name: 'dataSourcesRolesResourcesActions',
model: 'DataSourcesRolesResourcesActionModel',
fields: [

View File

@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
export default defineCollection({
dumpRules: 'required',
migrationRules: ['overwrite', 'skip'],
name: 'dataSourcesRolesResourcesScopes',
fields: [
{

View File

@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
export default defineCollection({
dumpRules: 'required',
migrationRules: ['overwrite', 'skip'],
name: 'dataSourcesRolesResources',
model: 'DataSourcesRolesResourcesModel',
fields: [

View File

@ -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',

View File

@ -15,6 +15,7 @@ export default defineCollection({
autoGenId: false,
shared: true,
dumpRules: 'required',
migrationRules: ['overwrite', 'skip'],
fields: [
{
type: 'string',

View File

@ -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;

View File

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

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-environment-variables

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -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"
]
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

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

View 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;
}

View File

@ -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 />;
}

View File

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

View File

@ -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;

View File

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

View File

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

View File

@ -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';

View File

@ -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.": "检测到环境变量有更新,需重启应用才能生效。"
}

View File

@ -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_]*$/;

View File

@ -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;

View File

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

View File

@ -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';

View File

@ -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;

View File

@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
export default defineCollection({
dumpRules: 'skipped',
migrationRules: ['schema-only', 'overwrite', 'skip'],
name: 'chinaRegions',
autoGenId: false,
fields: [

View File

@ -65,6 +65,7 @@ export default defineCollection({
});
},
},
migrationRules: ['overwrite', 'skip'],
name: 'sequences',
shared: true,
fields: [

View File

@ -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',
},
},
},

View File

@ -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,
},
{

View File

@ -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',
},

View File

@ -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',
},
},
},

View File

@ -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',
},

View File

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

View File

@ -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