mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 21:49:25 +08:00
feat: security (#5923)
* feat: password policy * feat: password validator * fix(inbox): i18n of channel title * feat: atomic counter * fix: bug * fix: bloom * chore: i18n * fix: counter * fix: build * fix: bug * fix: z-index * fix: export * test: add redis cache test * fix: test * fix: test * fix: test * fix: bug * fix: form reset * fix: locale * fix: version * fix: separate cache for sub apps * chore: update
This commit is contained in:
parent
bee085710d
commit
84cfa82304
16
.github/workflows/nocobase-test-backend.yml
vendored
16
.github/workflows/nocobase-test-backend.yml
vendored
@ -46,7 +46,7 @@ jobs:
|
|||||||
container: node:${{ matrix.node_version }}
|
container: node:${{ matrix.node_version }}
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis/redis-stack-server:latest
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
steps:
|
steps:
|
||||||
@ -75,6 +75,13 @@ jobs:
|
|||||||
underscored: [true, false]
|
underscored: [true, false]
|
||||||
schema: [public, nocobase]
|
schema: [public, nocobase]
|
||||||
collection_schema: [public, user_schema]
|
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
|
runs-on: ubuntu-latest
|
||||||
container: node:${{ matrix.node_version }}
|
container: node:${{ matrix.node_version }}
|
||||||
services:
|
services:
|
||||||
@ -93,7 +100,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis/redis-stack-server:latest
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
steps:
|
steps:
|
||||||
@ -125,6 +132,7 @@ jobs:
|
|||||||
DB_TEST_DISTRIBUTOR_PORT: 23450
|
DB_TEST_DISTRIBUTOR_PORT: 23450
|
||||||
DB_TEST_PREFIX: test
|
DB_TEST_PREFIX: test
|
||||||
ENCRYPTION_FIELD_KEY: 1%&glK;<UA}aIxJVc53-4G(rTi0vg@J]
|
ENCRYPTION_FIELD_KEY: 1%&glK;<UA}aIxJVc53-4G(rTi0vg@J]
|
||||||
|
CACHE_REDIS_URL: ${{ matrix.cache_redis }}
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
mysql-test:
|
mysql-test:
|
||||||
@ -142,7 +150,7 @@ jobs:
|
|||||||
MYSQL_DATABASE: nocobase
|
MYSQL_DATABASE: nocobase
|
||||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis/redis-stack-server:latest
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
steps:
|
steps:
|
||||||
@ -188,7 +196,7 @@ jobs:
|
|||||||
MARIADB_DATABASE: nocobase
|
MARIADB_DATABASE: nocobase
|
||||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis/redis-stack-server:latest
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
steps:
|
steps:
|
||||||
|
@ -103,7 +103,9 @@ export class BaseAuth extends Auth {
|
|||||||
try {
|
try {
|
||||||
user = await this.validate();
|
user = await this.validate();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.ctx.throw(err.status || 401, err.message);
|
this.ctx.throw(err.status || 401, err.message, {
|
||||||
|
...err,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.ctx.throw(401, 'Unauthorized');
|
this.ctx.throw(401, 'Unauthorized');
|
||||||
|
1
packages/core/cache/package.json
vendored
1
packages/core/cache/package.json
vendored
@ -6,6 +6,7 @@
|
|||||||
"main": "./lib/index.js",
|
"main": "./lib/index.js",
|
||||||
"types": "./lib/index.d.ts",
|
"types": "./lib/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nocobase/lock-manager": "1.6.0-alpha.6",
|
||||||
"bloom-filters": "^3.0.1",
|
"bloom-filters": "^3.0.1",
|
||||||
"cache-manager": "^5.2.4",
|
"cache-manager": "^5.2.4",
|
||||||
"cache-manager-redis-yet": "^4.1.2"
|
"cache-manager-redis-yet": "^4.1.2"
|
||||||
|
@ -41,3 +41,41 @@ describe('bloomFilter', () => {
|
|||||||
expect(await bloomFilter.exists('not-reserved', 'hello')).toBeFalsy();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
127
packages/core/cache/src/__tests__/counter.test.ts
vendored
Normal file
127
packages/core/cache/src/__tests__/counter.test.ts
vendored
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -21,27 +21,30 @@ export class RedisBloomFilter implements BloomFilter {
|
|||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
getStore() {
|
private get store() {
|
||||||
return this.cache.store.store as RedisStore;
|
return this.cache.store.store as RedisStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reserve(key: string, errorRate: number, capacity: number) {
|
async reserve(key: string, errorRate: number, capacity: number) {
|
||||||
const store = this.getStore();
|
try {
|
||||||
await store.client.bf.reserve(key, errorRate, capacity);
|
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) {
|
async add(key: string, value: string) {
|
||||||
const store = this.getStore();
|
await this.store.client.bf.add(key, value);
|
||||||
await store.client.bf.add(key, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async mAdd(key: string, values: string[]) {
|
async mAdd(key: string, values: string[]) {
|
||||||
const store = this.getStore();
|
await this.store.client.bf.mAdd(key, values);
|
||||||
await store.client.bf.mAdd(key, values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(key: string, value: string) {
|
async exists(key: string, value: string) {
|
||||||
const store = this.getStore();
|
return this.store.client.bf.exists(key, value);
|
||||||
return await store.client.bf.exists(key, value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
40
packages/core/cache/src/cache-manager.ts
vendored
40
packages/core/cache/src/cache-manager.ts
vendored
@ -15,6 +15,8 @@ import deepmerge from 'deepmerge';
|
|||||||
import { MemoryBloomFilter } from './bloom-filter/memory-bloom-filter';
|
import { MemoryBloomFilter } from './bloom-filter/memory-bloom-filter';
|
||||||
import { BloomFilter } from './bloom-filter';
|
import { BloomFilter } from './bloom-filter';
|
||||||
import { RedisBloomFilter } from './bloom-filter/redis-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 = {
|
type StoreOptions = {
|
||||||
store?: 'memory' | FactoryStore<Store, any>;
|
store?: 'memory' | FactoryStore<Store, any>;
|
||||||
@ -28,10 +30,12 @@ export type CacheManagerOptions = Partial<{
|
|||||||
stores: {
|
stores: {
|
||||||
[storeType: string]: StoreOptions;
|
[storeType: string]: StoreOptions;
|
||||||
};
|
};
|
||||||
|
prefix: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export class CacheManager {
|
export class CacheManager {
|
||||||
defaultStore: string;
|
defaultStore: string;
|
||||||
|
prefix?: string;
|
||||||
private stores = new Map<
|
private stores = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@ -69,8 +73,9 @@ export class CacheManager {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const cacheOptions = deepmerge(defaultOptions, options || {});
|
const cacheOptions = deepmerge(defaultOptions, options || {});
|
||||||
const { defaultStore = 'memory', stores } = cacheOptions;
|
const { defaultStore = 'memory', stores, prefix } = cacheOptions;
|
||||||
this.defaultStore = defaultStore;
|
this.defaultStore = defaultStore;
|
||||||
|
this.prefix = prefix;
|
||||||
for (const [name, store] of Object.entries(stores)) {
|
for (const [name, store] of Object.entries(stores)) {
|
||||||
const { store: s, ...globalConfig } = store;
|
const { store: s, ...globalConfig } = store;
|
||||||
this.registerStore({ name, store: s, ...globalConfig });
|
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 }) {
|
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') {
|
if (!lodash.isEmpty(config) || store === 'memory') {
|
||||||
const newStore = await this.createStore({ name, storeType: store, ...config });
|
const newStore = await this.createStore({ name, storeType: store, ...config });
|
||||||
return this.newCache({ name, prefix, store: newStore });
|
return this.newCache({ name, prefix, store: newStore });
|
||||||
@ -161,4 +168,33 @@ export class CacheManager {
|
|||||||
throw new Error(`BloomFilter store [${store}] is not supported`);
|
throw new Error(`BloomFilter store [${store}] is not supported`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
async createCounter(
|
||||||
|
options: { name: string; prefix?: string; store?: string },
|
||||||
|
lockManager?: LockManager,
|
||||||
|
): Promise<Counter> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
25
packages/core/cache/src/counter/index.ts
vendored
Normal file
25
packages/core/cache/src/counter/index.ts
vendored
Normal file
@ -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<number>;
|
||||||
|
incr(key: string): Promise<number>;
|
||||||
|
incr(key: string, ttl: number): Promise<number>;
|
||||||
|
incrby(key: string, val: number): Promise<number>;
|
||||||
|
incrby(key: string, val: number, ttl: number): Promise<number>;
|
||||||
|
reset(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MemoryCounter } from './memory-counter';
|
||||||
|
export { RedisCounter } from './redis-counter';
|
||||||
|
export { LockCounter } from './lock-counter';
|
52
packages/core/cache/src/counter/lock-counter.ts
vendored
Normal file
52
packages/core/cache/src/counter/lock-counter.ts
vendored
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
74
packages/core/cache/src/counter/memory-counter.ts
vendored
Normal file
74
packages/core/cache/src/counter/memory-counter.ts
vendored
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
61
packages/core/cache/src/counter/redis-counter.ts
vendored
Normal file
61
packages/core/cache/src/counter/redis-counter.ts
vendored
Normal 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 { 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);
|
||||||
|
}
|
||||||
|
}
|
1
packages/core/cache/src/index.ts
vendored
1
packages/core/cache/src/index.ts
vendored
@ -10,3 +10,4 @@
|
|||||||
export * from './cache-manager';
|
export * from './cache-manager';
|
||||||
export * from './cache';
|
export * from './cache';
|
||||||
export * from './bloom-filter';
|
export * from './bloom-filter';
|
||||||
|
export * from './counter';
|
||||||
|
@ -111,7 +111,7 @@ export class APIClient extends APIClientSDK {
|
|||||||
this.auth.setRole(null);
|
this.auth.setRole(null);
|
||||||
window.location.reload();
|
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);
|
this.auth.setToken(null);
|
||||||
}
|
}
|
||||||
if (errs.find((error: { code?: string }) => error.code === 'ROLE_NOT_FOUND_FOR_USER')) {
|
if (errs.find((error: { code?: string }) => error.code === 'ROLE_NOT_FOUND_FOR_USER')) {
|
||||||
|
@ -889,7 +889,7 @@
|
|||||||
"Data source": "数据源",
|
"Data source": "数据源",
|
||||||
"DataSource": "数据源",
|
"DataSource": "数据源",
|
||||||
"Data model": "数据模型",
|
"Data model": "数据模型",
|
||||||
"Security": "认证与安全",
|
"Security": "安全性",
|
||||||
"Action": "操作",
|
"Action": "操作",
|
||||||
"System": "系统管理",
|
"System": "系统管理",
|
||||||
"Other": "其他",
|
"Other": "其他",
|
||||||
@ -1036,8 +1036,8 @@
|
|||||||
"Bulk enable": "批量激活",
|
"Bulk enable": "批量激活",
|
||||||
"Search plugin...": "搜索插件...",
|
"Search plugin...": "搜索插件...",
|
||||||
"Package name": "包名",
|
"Package name": "包名",
|
||||||
"Associate":"关联",
|
"Associate": "关联",
|
||||||
"Please add or select record":"请添加或选择数据",
|
"Please add or select record": "请添加或选择数据",
|
||||||
"No data":"暂无数据",
|
"No data": "暂无数据",
|
||||||
"Fields can only be used correctly if they are defined with an interface.": "只有字段设置了interface字段才能正常使用"
|
"Fields can only be used correctly if they are defined with an interface.": "只有字段设置了interface字段才能正常使用"
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,6 @@ import { Button, Modal, Result, Spin } from 'antd';
|
|||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { ACLPlugin } from '../acl';
|
import { ACLPlugin } from '../acl';
|
||||||
import { useAPIClient } from '../api-client';
|
|
||||||
import { Application } from '../application';
|
import { Application } from '../application';
|
||||||
import { Plugin } from '../application/Plugin';
|
import { Plugin } from '../application/Plugin';
|
||||||
import { BlockSchemaComponentPlugin } from '../block-provider';
|
import { BlockSchemaComponentPlugin } from '../block-provider';
|
||||||
@ -33,6 +32,7 @@ import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
|
|||||||
import { SystemSettingsPlugin } from '../system-settings';
|
import { SystemSettingsPlugin } from '../system-settings';
|
||||||
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
|
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
|
||||||
import { LocalePlugin } from './plugins/LocalePlugin';
|
import { LocalePlugin } from './plugins/LocalePlugin';
|
||||||
|
import { tval } from '@nocobase/utils/client';
|
||||||
|
|
||||||
const AppSpin = () => {
|
const AppSpin = () => {
|
||||||
return (
|
return (
|
||||||
@ -285,6 +285,11 @@ export class NocoBaseBuildInPlugin extends Plugin {
|
|||||||
|
|
||||||
this.app.use(CurrentUserProvider);
|
this.app.use(CurrentUserProvider);
|
||||||
this.app.use(CurrentUserSettingsMenuProvider);
|
this.app.use(CurrentUserSettingsMenuProvider);
|
||||||
|
|
||||||
|
this.app.pluginSettingsManager.add('security', {
|
||||||
|
title: tval('Security'),
|
||||||
|
icon: 'SafetyOutlined',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addRoutes() {
|
addRoutes() {
|
||||||
|
@ -32,12 +32,12 @@ export const useActionContext = () => {
|
|||||||
async onOk() {
|
async onOk() {
|
||||||
ctx.setFormValueChanged(false);
|
ctx.setFormValueChanged(false);
|
||||||
ctx.setVisible?.(false);
|
ctx.setVisible?.(false);
|
||||||
form?.reset?.();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ctx.setVisible?.(false);
|
ctx.setVisible?.(false);
|
||||||
}
|
}
|
||||||
|
form?.reset?.();
|
||||||
} else {
|
} else {
|
||||||
ctx.setVisible?.(visible);
|
ctx.setVisible?.(visible);
|
||||||
}
|
}
|
||||||
|
@ -28,9 +28,7 @@ const useCloseAction = () => {
|
|||||||
return {
|
return {
|
||||||
async run() {
|
async run() {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
form.submit((values) => {
|
await form.reset();
|
||||||
console.log(values);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -60,7 +58,7 @@ const schema: ISchema = {
|
|||||||
'x-decorator': 'Form',
|
'x-decorator': 'Form',
|
||||||
'x-component': 'Action.Drawer',
|
'x-component': 'Action.Drawer',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
zIndex: 10000,
|
zIndex: 2000,
|
||||||
},
|
},
|
||||||
type: 'void',
|
type: 'void',
|
||||||
title: '{{t("Change password")}}',
|
title: '{{t("Change password")}}',
|
||||||
@ -78,6 +76,7 @@ const schema: ISchema = {
|
|||||||
required: true,
|
required: true,
|
||||||
'x-component': 'Password',
|
'x-component': 'Password',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
|
'x-validator': { password: true },
|
||||||
'x-component-props': { checkStrength: true, style: {} },
|
'x-component-props': { checkStrength: true, style: {} },
|
||||||
'x-reactions': [
|
'x-reactions': [
|
||||||
{
|
{
|
||||||
|
@ -581,7 +581,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createCacheManager() {
|
async createCacheManager() {
|
||||||
this._cacheManager = await createCacheManager(this, this.options.cacheManager);
|
this._cacheManager = await createCacheManager(this, {
|
||||||
|
prefix: this.name,
|
||||||
|
...this.options.cacheManager,
|
||||||
|
});
|
||||||
return this._cacheManager;
|
return this._cacheManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,6 +55,7 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
|
|||||||
title: '{{t("Password")}}',
|
title: '{{t("Password")}}',
|
||||||
'x-component': 'Password',
|
'x-component': 'Password',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
|
'x-validator': { password: true },
|
||||||
'x-component-props': { checkStrength: true, style: {} },
|
'x-component-props': { checkStrength: true, style: {} },
|
||||||
'x-reactions': [
|
'x-reactions': [
|
||||||
{
|
{
|
||||||
@ -73,6 +74,7 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
|
|||||||
'x-component': 'Password',
|
'x-component': 'Password',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
title: '{{t("Confirm password")}}',
|
title: '{{t("Confirm password")}}',
|
||||||
|
'x-validator': { password: true },
|
||||||
'x-component-props': { style: {} },
|
'x-component-props': { style: {} },
|
||||||
'x-reactions': [
|
'x-reactions': [
|
||||||
{
|
{
|
||||||
|
@ -7,4 +7,4 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { AuthModel, BasicAuth, default } from './server';
|
export { AuthModel, BasicAuth, default, presetAuthType } from './server';
|
||||||
|
@ -46,7 +46,10 @@ export class BasicAuth extends BaseAuth {
|
|||||||
const field = this.userCollection.getField<PasswordField>('password');
|
const field = this.userCollection.getField<PasswordField>('password');
|
||||||
const valid = await field.verify(password, user.password);
|
const valid = await field.verify(password, user.password);
|
||||||
if (!valid) {
|
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;
|
return user;
|
||||||
}
|
}
|
||||||
|
@ -9,5 +9,6 @@
|
|||||||
|
|
||||||
export { BasicAuth } from './basic-auth';
|
export { BasicAuth } from './basic-auth';
|
||||||
export { AuthModel } from './model/authenticator';
|
export { AuthModel } from './model/authenticator';
|
||||||
|
export { presetAuthType } from '../preset';
|
||||||
|
|
||||||
export { default } from './plugin';
|
export { default } from './plugin';
|
||||||
|
@ -23,6 +23,7 @@ export class PluginAuthServer extends Plugin {
|
|||||||
cache: Cache;
|
cache: Cache;
|
||||||
|
|
||||||
afterAdd() {}
|
afterAdd() {}
|
||||||
|
|
||||||
async beforeLoad() {
|
async beforeLoad() {
|
||||||
this.app.db.registerModels({ AuthModel });
|
this.app.db.registerModels({ AuthModel });
|
||||||
}
|
}
|
||||||
@ -33,8 +34,7 @@ export class PluginAuthServer extends Plugin {
|
|||||||
prefix: 'auth',
|
prefix: 'auth',
|
||||||
store: 'memory',
|
store: 'memory',
|
||||||
});
|
});
|
||||||
|
// Set up auth manager
|
||||||
// Set up auth manager and register preset auth type
|
|
||||||
const storer = new Storer({
|
const storer = new Storer({
|
||||||
db: this.db,
|
db: this.db,
|
||||||
cache: this.cache,
|
cache: this.cache,
|
||||||
@ -45,7 +45,7 @@ export class PluginAuthServer extends Plugin {
|
|||||||
// If blacklist service is not set, should configure default blacklist service
|
// If blacklist service is not set, should configure default blacklist service
|
||||||
this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this));
|
this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this));
|
||||||
}
|
}
|
||||||
|
// register preset auth type
|
||||||
this.app.authManager.registerTypes(presetAuthType, {
|
this.app.authManager.registerTypes(presetAuthType, {
|
||||||
auth: BasicAuth,
|
auth: BasicAuth,
|
||||||
title: tval('Password', { ns: namespace }),
|
title: tval('Password', { ns: namespace }),
|
||||||
|
@ -30,11 +30,22 @@ export class TokenBlacklistService implements ITokenBlacklistService {
|
|||||||
// 0.1% error rate requires 14.4 bits per item
|
// 0.1% error rate requires 14.4 bits per item
|
||||||
// 14.4*1000000/8/1024/1024 = 1.72MB
|
// 14.4*1000000/8/1024/1024 = 1.72MB
|
||||||
await this.bloomFilter.reserve(this.cacheKey, 0.001, 1000000);
|
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);
|
const tokens = data.map((item: any) => item.token);
|
||||||
|
if (!tokens.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.bloomFilter.mAdd(this.cacheKey, tokens);
|
await this.bloomFilter.mAdd(this.cacheKey, tokens);
|
||||||
} catch (error) {
|
} 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;
|
this.bloomFilter = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -34,6 +34,10 @@ describe('sub app', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(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();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -134,6 +134,10 @@ const defaultAppOptionsFactory = (appName: string, mainApp: Application) => {
|
|||||||
resourcer: {
|
resourcer: {
|
||||||
prefix: process.env.API_BASE_PATH,
|
prefix: process.env.API_BASE_PATH,
|
||||||
},
|
},
|
||||||
|
cacheManager: {
|
||||||
|
...mainApp.options.cacheManager,
|
||||||
|
prefix: appName,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -9,12 +9,11 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { observer } from '@formily/reactive-react';
|
import { observer } from '@formily/reactive-react';
|
||||||
|
import { Schema } from '@formily/react';
|
||||||
import { Layout, List, Badge, Button, Flex, Tabs, ConfigProvider, theme } from 'antd';
|
import { Layout, List, Badge, Button, Flex, Tabs, ConfigProvider, theme } from 'antd';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { dayjs } from '@nocobase/utils/client';
|
import { dayjs } from '@nocobase/utils/client';
|
||||||
import { useLocalTranslation } from '../../locale';
|
import { useLocalTranslation } from '../../locale';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchChannels,
|
fetchChannels,
|
||||||
selectedChannelNameObs,
|
selectedChannelNameObs,
|
||||||
@ -25,11 +24,12 @@ import {
|
|||||||
channelStatusFilterObs,
|
channelStatusFilterObs,
|
||||||
ChannelStatus,
|
ChannelStatus,
|
||||||
} from '../observables';
|
} from '../observables';
|
||||||
|
|
||||||
import MessageList from './MessageList';
|
import MessageList from './MessageList';
|
||||||
import FilterTab from './FilterTab';
|
import FilterTab from './FilterTab';
|
||||||
|
import { useApp } from '@nocobase/client';
|
||||||
|
|
||||||
const InnerInboxContent = () => {
|
const InnerInboxContent = () => {
|
||||||
|
const app = useApp();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { t } = useLocalTranslation();
|
const { t } = useLocalTranslation();
|
||||||
const channels = channelListObs.value;
|
const channels = channelListObs.value;
|
||||||
@ -82,6 +82,7 @@ const InnerInboxContent = () => {
|
|||||||
style={{ paddingBottom: '20px' }}
|
style={{ paddingBottom: '20px' }}
|
||||||
loading={channels.length === 0 && isFetchingChannelsObs.value}
|
loading={channels.length === 0 && isFetchingChannelsObs.value}
|
||||||
renderItem={(item) => {
|
renderItem={(item) => {
|
||||||
|
const title = Schema.compile(item.title, { t: app.i18n.t });
|
||||||
const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText;
|
const titleColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorText;
|
||||||
const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary;
|
const textColor = selectedChannelName === item.name ? token.colorPrimaryText : token.colorTextTertiary;
|
||||||
return (
|
return (
|
||||||
@ -116,8 +117,9 @@ const InnerInboxContent = () => {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
}}
|
}}
|
||||||
|
title={title}
|
||||||
>
|
>
|
||||||
{item.title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { observer } from '@formily/reactive-react';
|
import { observer } from '@formily/reactive-react';
|
||||||
|
import { Schema } from '@formily/react';
|
||||||
import { Card, Descriptions, Button, Spin, Tag, ConfigProvider, Typography, Tooltip, theme } from 'antd';
|
import { Card, Descriptions, Button, Spin, Tag, ConfigProvider, Typography, Tooltip, theme } from 'antd';
|
||||||
import { dayjs } from '@nocobase/utils/client';
|
import { dayjs } from '@nocobase/utils/client';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@ -25,8 +25,10 @@ import {
|
|||||||
updateMessage,
|
updateMessage,
|
||||||
inboxVisible,
|
inboxVisible,
|
||||||
} from '../observables';
|
} from '../observables';
|
||||||
|
import { useApp } from '@nocobase/client';
|
||||||
|
|
||||||
const MessageList = observer(() => {
|
const MessageList = observer(() => {
|
||||||
|
const app = useApp();
|
||||||
const { t } = useLocalTranslation();
|
const { t } = useLocalTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@ -70,6 +72,8 @@ const MessageList = observer(() => {
|
|||||||
fetchMessages({ filter, limit: 30 });
|
fetchMessages({ filter, limit: 30 });
|
||||||
}, [messages, selectedChannelName]);
|
}, [messages, selectedChannelName]);
|
||||||
|
|
||||||
|
const title = Schema.compile(channelMapObs.value[selectedChannelName].title, { t: app.i18n.t });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
theme={{
|
theme={{
|
||||||
@ -77,7 +81,7 @@ const MessageList = observer(() => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography.Title level={4} style={{ marginBottom: token.marginLG }}>
|
<Typography.Title level={4} style={{ marginBottom: token.marginLG }}>
|
||||||
{channelMapObs.value[selectedChannelName].title}
|
{title}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|
||||||
{messages.length === 0 && isFecthingMessageObs.value ? (
|
{messages.length === 0 && isFecthingMessageObs.value ? (
|
||||||
|
@ -15,7 +15,11 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { dayjs } from '@nocobase/utils/client';
|
import { dayjs } from '@nocobase/utils/client';
|
||||||
import InfiniteScrollContent from './InfiniteScrollContent';
|
import InfiniteScrollContent from './InfiniteScrollContent';
|
||||||
import { channelListObs, channelStatusFilterObs, showChannelLoadingMoreObs, fetchChannels } from '../../observables';
|
import { channelListObs, channelStatusFilterObs, showChannelLoadingMoreObs, fetchChannels } from '../../observables';
|
||||||
|
import { Schema } from '@formily/react';
|
||||||
|
import { useApp } from '@nocobase/client';
|
||||||
|
|
||||||
const InternalChannelList = () => {
|
const InternalChannelList = () => {
|
||||||
|
const app = useApp();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const channels = channelListObs.value;
|
const channels = channelListObs.value;
|
||||||
const listRef = useRef<ListRef>(null);
|
const listRef = useRef<ListRef>(null);
|
||||||
@ -57,6 +61,7 @@ const InternalChannelList = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{channelListObs.value.map((item) => {
|
{channelListObs.value.map((item) => {
|
||||||
|
const channelTitle = Schema.compile(item.title, { t: app.i18n.t });
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@ -84,7 +89,7 @@ const InternalChannelList = () => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div> {item.title}</div>
|
<div> {channelTitle}</div>
|
||||||
<div style={{ color: 'var(--adm-color-weak)', fontSize: 'var(--adm-font-size-main)' }}>
|
<div style={{ color: 'var(--adm-color-weak)', fontSize: 'var(--adm-font-size-main)' }}>
|
||||||
{dayjs(item.latestMsgReceiveTimestamp).fromNow(true)}
|
{dayjs(item.latestMsgReceiveTimestamp).fromNow(true)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,7 +11,7 @@ import React, { useEffect, useCallback, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { List, Badge, InfiniteScroll, NavBar, DotLoading } from 'antd-mobile';
|
import { List, Badge, InfiniteScroll, NavBar, DotLoading } from 'antd-mobile';
|
||||||
import { observer } from '@formily/reactive-react';
|
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 { useSearchParams } from 'react-router-dom';
|
||||||
import { dayjs } from '@nocobase/utils/client';
|
import { dayjs } from '@nocobase/utils/client';
|
||||||
|
|
||||||
@ -33,7 +33,10 @@ import {
|
|||||||
} from '../../observables';
|
} from '../../observables';
|
||||||
import { useLocalTranslation } from '../../../locale';
|
import { useLocalTranslation } from '../../../locale';
|
||||||
import InfiniteScrollContent from './InfiniteScrollContent';
|
import InfiniteScrollContent from './InfiniteScrollContent';
|
||||||
|
import { Schema } from '@formily/react';
|
||||||
|
|
||||||
const MobileMessagePageInner = () => {
|
const MobileMessagePageInner = () => {
|
||||||
|
const app = useApp();
|
||||||
const { t } = useLocalTranslation();
|
const { t } = useLocalTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const ctx = useCurrentUserContext();
|
const ctx = useCurrentUserContext();
|
||||||
@ -95,7 +98,7 @@ const MobileMessagePageInner = () => {
|
|||||||
setFecthMsgStatus('failure');
|
setFecthMsgStatus('failure');
|
||||||
}
|
}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
const title = selectedChannelObs.value?.title || t('Message');
|
const title = Schema.compile(selectedChannelObs.value?.title, { t: app.i18n.t }) || t('Message');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobilePageProvider>
|
<MobilePageProvider>
|
||||||
|
@ -28,6 +28,13 @@ export const PasswordField: React.FC = () => {
|
|||||||
field.reset();
|
field.reset();
|
||||||
}, [field, ctx.visible]);
|
}, [field, ctx.visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!field.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.validate();
|
||||||
|
}, [field.value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gutter={10}>
|
<Row gutter={10}>
|
||||||
<Col span={18}>
|
<Col span={18}>
|
||||||
|
@ -327,6 +327,12 @@ export const usersSchema: ISchema = {
|
|||||||
'x-use-decorator-props': 'useEditFormProps',
|
'x-use-decorator-props': 'useEditFormProps',
|
||||||
title: '{{t("Change password")}}',
|
title: '{{t("Change password")}}',
|
||||||
properties: {
|
properties: {
|
||||||
|
username: {
|
||||||
|
'x-component': 'CollectionField',
|
||||||
|
'x-component-props': {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
password: {
|
password: {
|
||||||
'x-component': 'CollectionField',
|
'x-component': 'CollectionField',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Users & Permissions": "Users & Permissions",
|
"Users & Permissions": "Users & Permissions",
|
||||||
"Add users": "Add users",
|
"Add users": "Add users",
|
||||||
|
"Add user": "Add user",
|
||||||
"Remove user": "Remove user",
|
"Remove user": "Remove user",
|
||||||
"Are you sure you want to remove it?": "Are you sure you want to remove it?",
|
"Are you sure you want to remove it?": "Are you sure you want to remove it?",
|
||||||
"Random password": "Random password",
|
"Random password": "Random password",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Users & Permissions": "ユーザーと権限",
|
"Users & Permissions": "ユーザーと権限",
|
||||||
"Add users": "ユーザーを追加",
|
"Add users": "ユーザーを追加",
|
||||||
|
"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": "ランダムパスワード"
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Users & Permissions": "用户和权限",
|
"Users & Permissions": "用户和权限",
|
||||||
"Add users": "添加用户",
|
"Add users": "添加用户",
|
||||||
|
"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": "随机密码",
|
||||||
|
@ -89,6 +89,7 @@ export default defineCollection({
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
title: '{{t("Password")}}',
|
title: '{{t("Password")}}',
|
||||||
'x-component': 'Password',
|
'x-component': 'Password',
|
||||||
|
'x-validator': { password: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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() {}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user