diff --git a/.github/workflows/nocobase-test-backend.yml b/.github/workflows/nocobase-test-backend.yml index 921deff502..26b56fc28d 100644 --- a/.github/workflows/nocobase-test-backend.yml +++ b/.github/workflows/nocobase-test-backend.yml @@ -46,7 +46,7 @@ jobs: container: node:${{ matrix.node_version }} services: redis: - image: redis:latest + image: redis/redis-stack-server:latest ports: - 6379:6379 steps: @@ -75,6 +75,13 @@ jobs: underscored: [true, false] schema: [public, nocobase] collection_schema: [public, user_schema] + cache_redis: [''] + include: + - node_version: 20 + underscored: true + schema: public + collection_schema: public + cache_redis: redis://redis:6379/0 runs-on: ubuntu-latest container: node:${{ matrix.node_version }} services: @@ -93,7 +100,7 @@ jobs: --health-timeout 5s --health-retries 5 redis: - image: redis:latest + image: redis/redis-stack-server:latest ports: - 6379:6379 steps: @@ -125,6 +132,7 @@ jobs: DB_TEST_DISTRIBUTOR_PORT: 23450 DB_TEST_PREFIX: test ENCRYPTION_FIELD_KEY: 1%&glK; { expect(await bloomFilter.exists('not-reserved', 'hello')).toBeFalsy(); }); }); + +// It is required to install redis stack server that includes redis bloom. +(process.env.CACHE_REDIS_URL ? describe : describe.skip)('bloomFilter with redis', () => { + let bloomFilter: BloomFilter; + let cacheManager: CacheManager; + + beforeEach(async () => { + cacheManager = new CacheManager({ + stores: { + redis: { + url: process.env.CACHE_REDIS_URL, + }, + }, + }); + bloomFilter = await cacheManager.createBloomFilter({ store: 'redis' }); + await bloomFilter.reserve('bloom-test', 0.01, 1000); + }); + + afterEach(async () => { + await cacheManager.flushAll(); + }); + + it('should add and check', async () => { + await bloomFilter.add('bloom-test', 'hello'); + expect(await bloomFilter.exists('bloom-test', 'hello')).toBeTruthy(); + expect(await bloomFilter.exists('bloom-test', 'world')).toBeFalsy(); + }); + + it('should mAdd and check', async () => { + await bloomFilter.mAdd('bloom-test', ['hello', 'world']); + expect(await bloomFilter.exists('bloom-test', 'hello')).toBeTruthy(); + expect(await bloomFilter.exists('bloom-test', 'world')).toBeTruthy(); + }); + + it('should return false if not reserved', async () => { + expect(await bloomFilter.exists('not-reserved', 'hello')).toBeFalsy(); + }); +}); diff --git a/packages/core/cache/src/__tests__/counter.test.ts b/packages/core/cache/src/__tests__/counter.test.ts new file mode 100644 index 0000000000..189d943d61 --- /dev/null +++ b/packages/core/cache/src/__tests__/counter.test.ts @@ -0,0 +1,127 @@ +/** + * 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 { LockManager } from '@nocobase/lock-manager'; +import { CacheManager } from '../cache-manager'; +import { Counter, LockCounter } from '../counter'; + +describe('memory counter', () => { + let counter: Counter; + let cacheManager: CacheManager; + + beforeEach(async () => { + cacheManager = new CacheManager(); + cacheManager.registerStore({ name: 'memory', store: 'memory' }); + counter = await cacheManager.createCounter({ name: 'test-counter', store: 'memory' }); + }); + + afterEach(async () => { + await cacheManager.flushAll(); + }); + + test('incr, incrby, get, reset', async () => { + const res = await counter.incr('test-key-1'); + expect(res).toBe(1); + const res2 = await counter.incr('test-key-1'); + expect(res2).toBe(2); + const res3 = await counter.incrby('test-key-1', 3); + expect(res3).toBe(5); + expect(await counter.get('test-key-1')).toBe(5); + await counter.reset('test-key-1'); + expect(await counter.get('test-key-1')).toBe(0); + }); + + test('atomic', async () => { + const promises = []; + for (let i = 0; i < 100; i++) { + promises.push(counter.incr('test-key-1')); + } + await Promise.all(promises); + expect(await counter.get('test-key-1')).toBe(100); + }); +}); + +// It is required to install redis +(process.env.CACHE_REDIS_URL ? describe : describe.skip)('redis counter', () => { + let counter: Counter; + let cacheManager: CacheManager; + + beforeEach(async () => { + cacheManager = new CacheManager({ + stores: { + redis: { + url: process.env.CACHE_REDIS_URL, + }, + }, + }); + counter = await cacheManager.createCounter({ name: 'test-counter', store: 'redis' }); + }); + + afterEach(async () => { + await cacheManager.flushAll(); + }); + + test('incr, incrby, get, reset', async () => { + const res = await counter.incr('test-key-1'); + expect(res).toBe(1); + const res2 = await counter.incr('test-key-1'); + expect(res2).toBe(2); + const res3 = await counter.incrby('test-key-1', 3); + expect(res3).toBe(5); + expect(await counter.get('test-key-1')).toBe(5); + await counter.reset('test-key-1'); + expect(await counter.get('test-key-1')).toBe(0); + }); + + test('atomic', async () => { + const promises = []; + for (let i = 0; i < 100; i++) { + promises.push(counter.incr('test-key-1')); + } + await Promise.all(promises); + expect(await counter.get('test-key-1')).toBe(100); + }); +}); + +describe('lock counter', () => { + let counter: Counter; + let cacheManager: CacheManager; + + beforeEach(async () => { + cacheManager = new CacheManager(); + const cache = await cacheManager.createCache({ name: 'memory', store: 'memory' }); + const lockManager = new LockManager(); + counter = new LockCounter(cache, lockManager); + }); + + afterEach(async () => { + await cacheManager.flushAll(); + }); + + test('incr, incrby, get, reset', async () => { + const res = await counter.incr('test-key-1'); + expect(res).toBe(1); + const res2 = await counter.incr('test-key-1'); + expect(res2).toBe(2); + const res3 = await counter.incrby('test-key-1', 3); + expect(res3).toBe(5); + expect(await counter.get('test-key-1')).toBe(5); + await counter.reset('test-key-1'); + expect(await counter.get('test-key-1')).toBe(0); + }); + + test('atomic', async () => { + const promises = []; + for (let i = 0; i < 100; i++) { + promises.push(counter.incr('test-key-1')); + } + await Promise.all(promises); + expect(await counter.get('test-key-1')).toBe(100); + }); +}); diff --git a/packages/core/cache/src/bloom-filter/redis-bloom-filter.ts b/packages/core/cache/src/bloom-filter/redis-bloom-filter.ts index 5c9f0b638d..dc0320499a 100644 --- a/packages/core/cache/src/bloom-filter/redis-bloom-filter.ts +++ b/packages/core/cache/src/bloom-filter/redis-bloom-filter.ts @@ -21,27 +21,30 @@ export class RedisBloomFilter implements BloomFilter { this.cache = cache; } - getStore() { + private get store() { return this.cache.store.store as RedisStore; } async reserve(key: string, errorRate: number, capacity: number) { - const store = this.getStore(); - await store.client.bf.reserve(key, errorRate, capacity); + try { + await this.store.client.bf.reserve(key, errorRate, capacity); + } catch (error) { + if (error.message.includes('ERR item exists')) { + return; + } + throw error; + } } async add(key: string, value: string) { - const store = this.getStore(); - await store.client.bf.add(key, value); + await this.store.client.bf.add(key, value); } async mAdd(key: string, values: string[]) { - const store = this.getStore(); - await store.client.bf.mAdd(key, values); + await this.store.client.bf.mAdd(key, values); } async exists(key: string, value: string) { - const store = this.getStore(); - return await store.client.bf.exists(key, value); + return this.store.client.bf.exists(key, value); } } diff --git a/packages/core/cache/src/cache-manager.ts b/packages/core/cache/src/cache-manager.ts index 95a9b98ee0..e4af324208 100644 --- a/packages/core/cache/src/cache-manager.ts +++ b/packages/core/cache/src/cache-manager.ts @@ -15,6 +15,8 @@ import deepmerge from 'deepmerge'; import { MemoryBloomFilter } from './bloom-filter/memory-bloom-filter'; import { BloomFilter } from './bloom-filter'; import { RedisBloomFilter } from './bloom-filter/redis-bloom-filter'; +import { Counter, MemoryCounter, RedisCounter, LockCounter } from './counter'; +import { LockManager } from '@nocobase/lock-manager'; type StoreOptions = { store?: 'memory' | FactoryStore; @@ -28,10 +30,12 @@ export type CacheManagerOptions = Partial<{ stores: { [storeType: string]: StoreOptions; }; + prefix: string; }>; export class CacheManager { defaultStore: string; + prefix?: string; private stores = new Map< string, { @@ -69,8 +73,9 @@ export class CacheManager { }, }; const cacheOptions = deepmerge(defaultOptions, options || {}); - const { defaultStore = 'memory', stores } = cacheOptions; + const { defaultStore = 'memory', stores, prefix } = cacheOptions; this.defaultStore = defaultStore; + this.prefix = prefix; for (const [name, store] of Object.entries(stores)) { const { store: s, ...globalConfig } = store; this.registerStore({ name, store: s, ...globalConfig }); @@ -102,7 +107,9 @@ export class CacheManager { } async createCache(options: { name: string; prefix?: string; store?: string; [key: string]: any }) { - const { name, prefix, store = this.defaultStore, ...config } = options; + const { name, store = this.defaultStore, ...config } = options; + let { prefix } = options; + prefix = this.prefix ? (prefix ? `${this.prefix}:${prefix}` : this.prefix) : prefix; if (!lodash.isEmpty(config) || store === 'memory') { const newStore = await this.createStore({ name, storeType: store, ...config }); return this.newCache({ name, prefix, store: newStore }); @@ -161,4 +168,33 @@ export class CacheManager { throw new Error(`BloomFilter store [${store}] is not supported`); } } + + /** + * @experimental + */ + async createCounter( + options: { name: string; prefix?: string; store?: string }, + lockManager?: LockManager, + ): Promise { + const { store = this.defaultStore, name, prefix } = options || {}; + let cache: Cache; + if (store !== 'memory') { + try { + cache = this.getCache(name); + } catch (error) { + cache = await this.createCache({ name, store, prefix }); + } + } + switch (store) { + case 'memory': + return new MemoryCounter(); + case 'redis': + return new RedisCounter(cache); + default: + if (!lockManager) { + throw new Error(`Counter store [${store}] is not supported`); + } + return new LockCounter(cache, lockManager); + } + } } diff --git a/packages/core/cache/src/counter/index.ts b/packages/core/cache/src/counter/index.ts new file mode 100644 index 0000000000..772b42bff9 --- /dev/null +++ b/packages/core/cache/src/counter/index.ts @@ -0,0 +1,25 @@ +/** + * 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. + */ + +/** + * @experimental + * atomic counter + */ +export interface Counter { + get(key: string): Promise; + incr(key: string): Promise; + incr(key: string, ttl: number): Promise; + incrby(key: string, val: number): Promise; + incrby(key: string, val: number, ttl: number): Promise; + reset(key: string): Promise; +} + +export { MemoryCounter } from './memory-counter'; +export { RedisCounter } from './redis-counter'; +export { LockCounter } from './lock-counter'; diff --git a/packages/core/cache/src/counter/lock-counter.ts b/packages/core/cache/src/counter/lock-counter.ts new file mode 100644 index 0000000000..478b70e406 --- /dev/null +++ b/packages/core/cache/src/counter/lock-counter.ts @@ -0,0 +1,52 @@ +/** + * 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 { LockManager } from '@nocobase/lock-manager'; +import { Counter as ICounter } from '.'; +import { Cache } from '../cache'; + +/** + * @experimental + */ +export class LockCounter implements ICounter { + cache: Cache; + lockManager: LockManager; + constructor(cache: Cache, lockManager: LockManager) { + this.cache = cache; + this.lockManager = lockManager; + } + + async get(key: string) { + return ((await this.cache.get(key)) as number) || 0; + } + + async incr(key: string, ttl?: number) { + return this.incrby(key, 1, ttl); + } + + async incrby(key: string, value: number, ttl?: number) { + const lockKey = `lock:${key}`; + const release = await this.lockManager.acquire(lockKey, 3000); + try { + const v = (await this.cache.get(key)) as number; + const n = v || 0; + const newValue = n + value; + await this.cache.set(key, newValue, ttl); + return newValue; + } catch (error) { + throw error; + } finally { + await release(); + } + } + + async reset(key: string) { + return this.cache.del(key); + } +} diff --git a/packages/core/cache/src/counter/memory-counter.ts b/packages/core/cache/src/counter/memory-counter.ts new file mode 100644 index 0000000000..4ab1bba4ed --- /dev/null +++ b/packages/core/cache/src/counter/memory-counter.ts @@ -0,0 +1,74 @@ +/** + * 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 { Counter as ICounter } from '.'; + +// Since the memory store of cache-manager only offers a promise-based API, +// we use a simple memory cache with a synchronous API for the atomic counter. +// The implementation is based on https://github.com/isaacs/node-lru-cache?tab=readme-ov-file#storage-bounds-safety +class Cache { + data = new Map(); + timers = new Map(); + + set(k: string, v: any, ttl?: number) { + if (ttl) { + if (this.timers.has(k)) { + clearTimeout(this.timers.get(k)); + } + this.timers.set( + k, + setTimeout(() => this.del(k), ttl), + ); + } + this.data.set(k, v); + } + + get(k: string) { + return this.data.get(k); + } + + del(k: string) { + if (this.timers.has(k)) { + clearTimeout(this.timers.get(k)); + } + this.timers.delete(k); + return this.data.delete(k); + } +} + +/** + * @experimental + */ +export class MemoryCounter implements ICounter { + cache = new Cache(); + + async get(key: string) { + return this.cache.get(key) || 0; + } + + async incr(key: string, ttl?: number) { + return this.incrby(key, 1, ttl); + } + + async incrby(key: string, value: number, ttl?: number) { + const v = this.cache.get(key); + const n = v || 0; + const newValue = n + value; + if (!v) { + this.cache.set(key, newValue, ttl); + } else { + this.cache.set(key, newValue); + } + return newValue; + } + + async reset(key: string) { + this.cache.del(key); + } +} diff --git a/packages/core/cache/src/counter/redis-counter.ts b/packages/core/cache/src/counter/redis-counter.ts new file mode 100644 index 0000000000..015ff1da24 --- /dev/null +++ b/packages/core/cache/src/counter/redis-counter.ts @@ -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 { RedisStore } from 'cache-manager-redis-yet'; +import { Counter as ICounter } from '.'; +import { Cache } from '../cache'; + +const script = ` +local key = KEYS[1] +local value = tonumber(ARGV[1]) or 1 +local ttl = tonumber(ARGV[2]) +local current = redis.call('INCRBY', key, value) +if tonumber(current) == value and ttl then + redis.call('PEXPIRE', key, ttl) +end +return current +`; + +/** + * @experimental + */ +export class RedisCounter implements ICounter { + cache: Cache; + scriptSha: string; + constructor(cache: Cache) { + this.cache = cache; + } + + private get store() { + return this.cache.store.store as RedisStore; + } + + async get(key: string) { + return ((await this.cache.get(key)) as number) || 0; + } + + async incr(key: string, ttl?: number) { + return this.incrby(key, 1, ttl); + } + + async incrby(key: string, value: number, ttl?: number) { + if (!this.scriptSha) { + this.scriptSha = await this.store.client.scriptLoad(script); + } + const result = await this.store.client.evalSha(this.scriptSha, { + keys: [this.cache.key(key)], + arguments: [value, ttl].map((v) => (v ? v.toString() : '')), + }); + return Number(result); + } + + async reset(key: string) { + return this.cache.del(key); + } +} diff --git a/packages/core/cache/src/index.ts b/packages/core/cache/src/index.ts index 19a72a6809..595aa5a453 100644 --- a/packages/core/cache/src/index.ts +++ b/packages/core/cache/src/index.ts @@ -10,3 +10,4 @@ export * from './cache-manager'; export * from './cache'; export * from './bloom-filter'; +export * from './counter'; diff --git a/packages/core/client/src/api-client/APIClient.ts b/packages/core/client/src/api-client/APIClient.ts index d97ccc369e..0bb1663496 100644 --- a/packages/core/client/src/api-client/APIClient.ts +++ b/packages/core/client/src/api-client/APIClient.ts @@ -111,7 +111,7 @@ export class APIClient extends APIClientSDK { this.auth.setRole(null); window.location.reload(); } - if (errs.find((error: { code?: string }) => error.code === 'TOKEN_INVALID')) { + if (errs.find((error: { code?: string }) => error.code === 'TOKEN_INVALID' || error.code === 'USER_LOCKED')) { this.auth.setToken(null); } if (errs.find((error: { code?: string }) => error.code === 'ROLE_NOT_FOUND_FOR_USER')) { diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index 79095828e6..7e980371d2 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -889,7 +889,7 @@ "Data source": "数据源", "DataSource": "数据源", "Data model": "数据模型", - "Security": "认证与安全", + "Security": "安全性", "Action": "操作", "System": "系统管理", "Other": "其他", @@ -1036,8 +1036,8 @@ "Bulk enable": "批量激活", "Search plugin...": "搜索插件...", "Package name": "包名", - "Associate":"关联", - "Please add or select record":"请添加或选择数据", - "No data":"暂无数据", + "Associate": "关联", + "Please add or select record": "请添加或选择数据", + "No data": "暂无数据", "Fields can only be used correctly if they are defined with an interface.": "只有字段设置了interface字段才能正常使用" } diff --git a/packages/core/client/src/nocobase-buildin-plugin/index.tsx b/packages/core/client/src/nocobase-buildin-plugin/index.tsx index 85e24ed73a..3caea7ddc7 100644 --- a/packages/core/client/src/nocobase-buildin-plugin/index.tsx +++ b/packages/core/client/src/nocobase-buildin-plugin/index.tsx @@ -15,7 +15,6 @@ import { Button, Modal, Result, Spin } from 'antd'; import React, { FC } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import { ACLPlugin } from '../acl'; -import { useAPIClient } from '../api-client'; import { Application } from '../application'; import { Plugin } from '../application/Plugin'; import { BlockSchemaComponentPlugin } from '../block-provider'; @@ -33,6 +32,7 @@ import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates'; import { SystemSettingsPlugin } from '../system-settings'; import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user'; import { LocalePlugin } from './plugins/LocalePlugin'; +import { tval } from '@nocobase/utils/client'; const AppSpin = () => { return ( @@ -285,6 +285,11 @@ export class NocoBaseBuildInPlugin extends Plugin { this.app.use(CurrentUserProvider); this.app.use(CurrentUserSettingsMenuProvider); + + this.app.pluginSettingsManager.add('security', { + title: tval('Security'), + icon: 'SafetyOutlined', + }); } addRoutes() { diff --git a/packages/core/client/src/schema-component/antd/action/hooks.ts b/packages/core/client/src/schema-component/antd/action/hooks.ts index 2fe6dfab73..9370352c70 100644 --- a/packages/core/client/src/schema-component/antd/action/hooks.ts +++ b/packages/core/client/src/schema-component/antd/action/hooks.ts @@ -32,12 +32,12 @@ export const useActionContext = () => { async onOk() { ctx.setFormValueChanged(false); ctx.setVisible?.(false); - form?.reset?.(); }, }); } else { ctx.setVisible?.(false); } + form?.reset?.(); } else { ctx.setVisible?.(visible); } diff --git a/packages/core/client/src/user/ChangePassword.tsx b/packages/core/client/src/user/ChangePassword.tsx index 7523ca370d..7c374a2485 100644 --- a/packages/core/client/src/user/ChangePassword.tsx +++ b/packages/core/client/src/user/ChangePassword.tsx @@ -28,9 +28,7 @@ const useCloseAction = () => { return { async run() { setVisible(false); - form.submit((values) => { - console.log(values); - }); + await form.reset(); }, }; }; @@ -60,7 +58,7 @@ const schema: ISchema = { 'x-decorator': 'Form', 'x-component': 'Action.Drawer', 'x-component-props': { - zIndex: 10000, + zIndex: 2000, }, type: 'void', title: '{{t("Change password")}}', @@ -78,6 +76,7 @@ const schema: ISchema = { required: true, 'x-component': 'Password', 'x-decorator': 'FormItem', + 'x-validator': { password: true }, 'x-component-props': { checkStrength: true, style: {} }, 'x-reactions': [ { diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index a57311e298..eb86f1da44 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -581,7 +581,10 @@ export class Application exten } async createCacheManager() { - this._cacheManager = await createCacheManager(this, this.options.cacheManager); + this._cacheManager = await createCacheManager(this, { + prefix: this.name, + ...this.options.cacheManager, + }); return this._cacheManager; } diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/basic/SignUpForm.tsx b/packages/plugins/@nocobase/plugin-auth/src/client/basic/SignUpForm.tsx index 5b83947f1b..ac01ea81c7 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/basic/SignUpForm.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client/basic/SignUpForm.tsx @@ -55,6 +55,7 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({ title: '{{t("Password")}}', 'x-component': 'Password', 'x-decorator': 'FormItem', + 'x-validator': { password: true }, 'x-component-props': { checkStrength: true, style: {} }, 'x-reactions': [ { @@ -73,6 +74,7 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({ 'x-component': 'Password', 'x-decorator': 'FormItem', title: '{{t("Confirm password")}}', + 'x-validator': { password: true }, 'x-component-props': { style: {} }, 'x-reactions': [ { diff --git a/packages/plugins/@nocobase/plugin-auth/src/index.ts b/packages/plugins/@nocobase/plugin-auth/src/index.ts index 2ed7b8eb58..5064b591b7 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/index.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/index.ts @@ -7,4 +7,4 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export { AuthModel, BasicAuth, default } from './server'; +export { AuthModel, BasicAuth, default, presetAuthType } from './server'; diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts b/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts index 193f95b7e4..5a378e9ddd 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts @@ -46,7 +46,10 @@ export class BasicAuth extends BaseAuth { const field = this.userCollection.getField('password'); const valid = await field.verify(password, user.password); if (!valid) { - ctx.throw(401, ctx.t('The username/email or password is incorrect, please re-enter', { ns: namespace })); + ctx.throw(401, ctx.t('The username/email or password is incorrect, please re-enter', { ns: namespace }), { + code: 'INCORRECT_PASSWORD', + user, + }); } return user; } diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/index.ts b/packages/plugins/@nocobase/plugin-auth/src/server/index.ts index 564510e4d5..a03067f382 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/index.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/index.ts @@ -9,5 +9,6 @@ export { BasicAuth } from './basic-auth'; export { AuthModel } from './model/authenticator'; +export { presetAuthType } from '../preset'; export { default } from './plugin'; diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts index 84db4cf684..98e142fe11 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts @@ -23,6 +23,7 @@ export class PluginAuthServer extends Plugin { cache: Cache; afterAdd() {} + async beforeLoad() { this.app.db.registerModels({ AuthModel }); } @@ -33,8 +34,7 @@ export class PluginAuthServer extends Plugin { prefix: 'auth', store: 'memory', }); - - // Set up auth manager and register preset auth type + // Set up auth manager const storer = new Storer({ db: this.db, cache: this.cache, @@ -45,7 +45,7 @@ export class PluginAuthServer extends Plugin { // If blacklist service is not set, should configure default blacklist service this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this)); } - + // register preset auth type this.app.authManager.registerTypes(presetAuthType, { auth: BasicAuth, title: tval('Password', { ns: namespace }), diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/token-blacklist.ts b/packages/plugins/@nocobase/plugin-auth/src/server/token-blacklist.ts index 11dee1ced6..54cacdf70f 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/token-blacklist.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/token-blacklist.ts @@ -30,11 +30,22 @@ export class TokenBlacklistService implements ITokenBlacklistService { // 0.1% error rate requires 14.4 bits per item // 14.4*1000000/8/1024/1024 = 1.72MB await this.bloomFilter.reserve(this.cacheKey, 0.001, 1000000); - const data = await this.repo.find({ fields: ['token'], raw: true }); + const data = await this.repo.find({ + fields: ['token'], + filter: { + expiration: { + $dateAfter: new Date(), + }, + }, + raw: true, + }); const tokens = data.map((item: any) => item.token); + if (!tokens.length) { + return; + } await this.bloomFilter.mAdd(this.cacheKey, tokens); } catch (error) { - plugin.app.logger.error('token-blacklist: create bloom filter failed', error); + plugin.app.logger.warn('token-blacklist: create bloom filter failed', error); this.bloomFilter = null; } }); diff --git a/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/__tests__/cache.test.ts b/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/__tests__/cache.test.ts new file mode 100644 index 0000000000..f3775697dd --- /dev/null +++ b/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/__tests__/cache.test.ts @@ -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 { createMockServer, MockServer, MockServerOptions } from '@nocobase/test'; +import { AppSupervisor } from '@nocobase/server'; + +describe('cache', async () => { + let app: MockServer; + let agent: any; + + beforeEach(async () => { + const options: MockServerOptions = { + plugins: ['multi-app-manager', 'field-sort'], + }; + if (process.env.CACHE_REDIS_URL) { + options.cacheManager = { + defaultStore: 'redis', + stores: { + redis: { + url: process.env.CACHE_REDIS_URL, + }, + }, + }; + } + app = await createMockServer(options); + await app.db.getRepository('applications').create({ + values: { + name: 'test_sub', + options: { + plugins: [], + }, + }, + context: { + waitSubAppInstall: true, + }, + }); + + agent = app.agent(); + }); + + afterEach(async () => { + await app.cacheManager.flushAll(); + await app.destroy(); + }); + + test('should separate cache for multiple apps', async () => { + const cache1 = app.cache; + const subApp = await AppSupervisor.getInstance().getApp('test_sub'); + const cache2 = subApp.cache; + await cache1.set('test', 'test1'); + await cache2.set('test', 'test2'); + expect(await cache1.get('test')).toBe('test1'); + expect(await cache2.get('test')).toBe('test2'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/__tests__/mock-server.test.ts b/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/__tests__/mock-server.test.ts index aebd69adde..fb5aed9f14 100644 --- a/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/__tests__/mock-server.test.ts +++ b/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/__tests__/mock-server.test.ts @@ -34,6 +34,10 @@ describe('sub app', async () => { }); afterEach(async () => { + const subApp = await AppSupervisor.getInstance().getApp('test_sub'); + await subApp.db.clean({ drop: true }); + await subApp.destroy(); + await app.db.clean({ drop: true }); await app.destroy(); }); diff --git a/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/server.ts b/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/server.ts index 5aa3af80e7..66bd97bf21 100644 --- a/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-multi-app-manager/src/server/server.ts @@ -134,6 +134,10 @@ const defaultAppOptionsFactory = (appName: string, mainApp: Application) => { resourcer: { prefix: process.env.API_BASE_PATH, }, + cacheManager: { + ...mainApp.options.cacheManager, + prefix: appName, + }, }; }; diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/InboxContent.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/InboxContent.tsx index f369292c4d..4e5d01c915 100644 --- a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/InboxContent.tsx +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/InboxContent.tsx @@ -9,12 +9,11 @@ import React from 'react'; import { observer } from '@formily/reactive-react'; - +import { Schema } from '@formily/react'; import { Layout, List, Badge, Button, Flex, Tabs, ConfigProvider, theme } from 'antd'; import { css } from '@emotion/css'; import { dayjs } from '@nocobase/utils/client'; import { useLocalTranslation } from '../../locale'; - import { fetchChannels, selectedChannelNameObs, @@ -25,11 +24,12 @@ import { channelStatusFilterObs, ChannelStatus, } from '../observables'; - import MessageList from './MessageList'; import FilterTab from './FilterTab'; +import { useApp } from '@nocobase/client'; const InnerInboxContent = () => { + const app = useApp(); const { token } = theme.useToken(); const { t } = useLocalTranslation(); const channels = channelListObs.value; @@ -82,6 +82,7 @@ const InnerInboxContent = () => { style={{ paddingBottom: '20px' }} loading={channels.length === 0 && isFetchingChannelsObs.value} renderItem={(item) => { + const title = Schema.compile(item.title, { t: app.i18n.t }); const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText; const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary; return ( @@ -116,8 +117,9 @@ const InnerInboxContent = () => { whiteSpace: 'nowrap', fontWeight: 'bold', }} + title={title} > - {item.title} + {title}
{ + const app = useApp(); const { t } = useLocalTranslation(); const navigate = useNavigate(); const { token } = theme.useToken(); @@ -70,6 +72,8 @@ const MessageList = observer(() => { fetchMessages({ filter, limit: 30 }); }, [messages, selectedChannelName]); + const title = Schema.compile(channelMapObs.value[selectedChannelName].title, { t: app.i18n.t }); + return ( { }} > - {channelMapObs.value[selectedChannelName].title} + {title} {messages.length === 0 && isFecthingMessageObs.value ? ( diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/ChannelList.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/ChannelList.tsx index c9d0bf3d6c..7acaa97665 100644 --- a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/ChannelList.tsx +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/ChannelList.tsx @@ -15,7 +15,11 @@ import { useNavigate } from 'react-router-dom'; import { dayjs } from '@nocobase/utils/client'; import InfiniteScrollContent from './InfiniteScrollContent'; import { channelListObs, channelStatusFilterObs, showChannelLoadingMoreObs, fetchChannels } from '../../observables'; +import { Schema } from '@formily/react'; +import { useApp } from '@nocobase/client'; + const InternalChannelList = () => { + const app = useApp(); const navigate = useNavigate(); const channels = channelListObs.value; const listRef = useRef(null); @@ -57,6 +61,7 @@ const InternalChannelList = () => { }} > {channelListObs.value.map((item) => { + const channelTitle = Schema.compile(item.title, { t: app.i18n.t }); return ( { alignItems: 'center', }} > -
{item.title}
+
{channelTitle}
{dayjs(item.latestMsgReceiveTimestamp).fromNow(true)}
diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MessagePage.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MessagePage.tsx index e2473b31d9..d0befaa9d1 100644 --- a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MessagePage.tsx +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MessagePage.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { List, Badge, InfiniteScroll, NavBar, DotLoading } from 'antd-mobile'; import { observer } from '@formily/reactive-react'; -import { useCurrentUserContext, css } from '@nocobase/client'; +import { useCurrentUserContext, css, useApp } from '@nocobase/client'; import { useSearchParams } from 'react-router-dom'; import { dayjs } from '@nocobase/utils/client'; @@ -33,7 +33,10 @@ import { } from '../../observables'; import { useLocalTranslation } from '../../../locale'; import InfiniteScrollContent from './InfiniteScrollContent'; +import { Schema } from '@formily/react'; + const MobileMessagePageInner = () => { + const app = useApp(); const { t } = useLocalTranslation(); const navigate = useNavigate(); const ctx = useCurrentUserContext(); @@ -95,7 +98,7 @@ const MobileMessagePageInner = () => { setFecthMsgStatus('failure'); } }, [messages]); - const title = selectedChannelObs.value?.title || t('Message'); + const title = Schema.compile(selectedChannelObs.value?.title, { t: app.i18n.t }) || t('Message'); return ( diff --git a/packages/plugins/@nocobase/plugin-users/src/client/PasswordField.tsx b/packages/plugins/@nocobase/plugin-users/src/client/PasswordField.tsx index 2ca8aa6696..32ee3b0c4d 100644 --- a/packages/plugins/@nocobase/plugin-users/src/client/PasswordField.tsx +++ b/packages/plugins/@nocobase/plugin-users/src/client/PasswordField.tsx @@ -28,6 +28,13 @@ export const PasswordField: React.FC = () => { field.reset(); }, [field, ctx.visible]); + useEffect(() => { + if (!field.value) { + return; + } + field.validate(); + }, [field.value]); + return ( diff --git a/packages/plugins/@nocobase/plugin-users/src/client/schemas/users.ts b/packages/plugins/@nocobase/plugin-users/src/client/schemas/users.ts index 7a52ff1d01..73f567f646 100644 --- a/packages/plugins/@nocobase/plugin-users/src/client/schemas/users.ts +++ b/packages/plugins/@nocobase/plugin-users/src/client/schemas/users.ts @@ -327,6 +327,12 @@ export const usersSchema: ISchema = { 'x-use-decorator-props': 'useEditFormProps', title: '{{t("Change password")}}', properties: { + username: { + 'x-component': 'CollectionField', + 'x-component-props': { + hidden: true, + }, + }, password: { 'x-component': 'CollectionField', 'x-component-props': { diff --git a/packages/plugins/@nocobase/plugin-users/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-users/src/locale/en-US.json index a65c1d425a..43595b785d 100644 --- a/packages/plugins/@nocobase/plugin-users/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-users/src/locale/en-US.json @@ -1,6 +1,7 @@ { "Users & Permissions": "Users & Permissions", "Add users": "Add users", + "Add user": "Add user", "Remove user": "Remove user", "Are you sure you want to remove it?": "Are you sure you want to remove it?", "Random password": "Random password", diff --git a/packages/plugins/@nocobase/plugin-users/src/locale/ja-JP.json b/packages/plugins/@nocobase/plugin-users/src/locale/ja-JP.json index 26098f5c85..01c6cdd0aa 100644 --- a/packages/plugins/@nocobase/plugin-users/src/locale/ja-JP.json +++ b/packages/plugins/@nocobase/plugin-users/src/locale/ja-JP.json @@ -1,7 +1,8 @@ { "Users & Permissions": "ユーザーと権限", "Add users": "ユーザーを追加", + "Add user": "ユーザーを追加", "Remove user": "ユーザーを削除", "Are you sure you want to remove it?": "本当に削除してよろしいですか?", "Random password": "ランダムパスワード" -} \ No newline at end of file +} diff --git a/packages/plugins/@nocobase/plugin-users/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-users/src/locale/zh-CN.json index b5415cacdb..ef59edf886 100644 --- a/packages/plugins/@nocobase/plugin-users/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-users/src/locale/zh-CN.json @@ -1,6 +1,7 @@ { "Users & Permissions": "用户和权限", "Add users": "添加用户", + "Add user": "添加用户", "Remove user": "移除用户", "Are you sure you want to remove it?": "你确定要移除吗?", "Random password": "随机密码", diff --git a/packages/plugins/@nocobase/plugin-users/src/server/collections/users.ts b/packages/plugins/@nocobase/plugin-users/src/server/collections/users.ts index fdfaf654cf..94fd60bb93 100644 --- a/packages/plugins/@nocobase/plugin-users/src/server/collections/users.ts +++ b/packages/plugins/@nocobase/plugin-users/src/server/collections/users.ts @@ -89,6 +89,7 @@ export default defineCollection({ type: 'string', title: '{{t("Password")}}', 'x-component': 'Password', + 'x-validator': { password: true }, }, }, { diff --git a/packages/plugins/@nocobase/plugin-users/src/server/migrations/20241221135800-update-password-validator.ts b/packages/plugins/@nocobase/plugin-users/src/server/migrations/20241221135800-update-password-validator.ts new file mode 100644 index 0000000000..458373d776 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-users/src/server/migrations/20241221135800-update-password-validator.ts @@ -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 { Migration } from '@nocobase/server'; + +export default class UpdatePasswordValidatorMigration extends Migration { + on = 'afterLoad'; // 'beforeLoad' or 'afterLoad' + // appVersion = '<1.6.0-alpha.7'; + + async up() { + const Field = this.context.db.getRepository('fields'); + const field = await Field.findOne({ + filter: { + name: 'password', + collectionName: 'users', + }, + }); + if (!field) { + return; + } + await field.update({ + uiSchema: { + ...field.options.uiSchema, + 'x-validator': { password: true }, + }, + }); + } + + async down() {} +}