snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
14
packages/backend/server/src/base/cache/index.ts
vendored
Normal file
14
packages/backend/server/src/base/cache/index.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { Cache, SessionCache } from './instances';
|
||||
import { CacheInterceptor } from './interceptor';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [Cache, SessionCache, CacheInterceptor],
|
||||
exports: [Cache, SessionCache, CacheInterceptor],
|
||||
})
|
||||
export class CacheModule {}
|
||||
export { Cache, SessionCache };
|
||||
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
|
||||
export { isValidCacheTtl } from './provider';
|
||||
18
packages/backend/server/src/base/cache/instances.ts
vendored
Normal file
18
packages/backend/server/src/base/cache/instances.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CacheRedis, SessionRedis } from '../redis';
|
||||
import { CacheProvider } from './provider';
|
||||
|
||||
@Injectable()
|
||||
export class Cache extends CacheProvider {
|
||||
constructor(redis: CacheRedis) {
|
||||
super(redis);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionCache extends CacheProvider {
|
||||
constructor(redis: SessionRedis) {
|
||||
super(redis);
|
||||
}
|
||||
}
|
||||
99
packages/backend/server/src/base/cache/interceptor.ts
vendored
Normal file
99
packages/backend/server/src/base/cache/interceptor.ts
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { mergeMap, Observable, of } from 'rxjs';
|
||||
|
||||
import { Cache } from './instances';
|
||||
|
||||
export const MakeCache = (key: string[], args?: string[]) =>
|
||||
SetMetadata('cacheKey', [key, args]);
|
||||
export const PreventCache = (key: string[], args?: string[]) =>
|
||||
SetMetadata('preventCache', [key, args]);
|
||||
|
||||
type CacheConfig = [string[], string[]?];
|
||||
|
||||
@Injectable()
|
||||
export class CacheInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(CacheInterceptor.name);
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly cache: Cache
|
||||
) {}
|
||||
async intercept(
|
||||
ctx: ExecutionContext,
|
||||
next: CallHandler<any>
|
||||
): Promise<Observable<any>> {
|
||||
const key = this.reflector.get<CacheConfig | undefined>(
|
||||
'cacheKey',
|
||||
ctx.getHandler()
|
||||
);
|
||||
const preventKey = this.reflector.get<CacheConfig | undefined>(
|
||||
'preventCache',
|
||||
ctx.getHandler()
|
||||
);
|
||||
|
||||
if (preventKey) {
|
||||
const key = await this.getCacheKey(ctx, preventKey);
|
||||
if (key) {
|
||||
this.logger.verbose(`cache ${key} staled`);
|
||||
await this.cache.delete(key);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
} else if (!key) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const cacheKey = await this.getCacheKey(ctx, key);
|
||||
|
||||
if (!cacheKey) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const cachedData = await this.cache.get(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
this.logger.verbose(`cache ${cacheKey} hit`);
|
||||
return of(cachedData);
|
||||
} else {
|
||||
this.logger.verbose(`cache ${cacheKey} miss`);
|
||||
return next.handle().pipe(
|
||||
mergeMap(async result => {
|
||||
await this.cache.set(cacheKey, result);
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getCacheKey(
|
||||
ctx: ExecutionContext,
|
||||
config: CacheConfig
|
||||
): Promise<string | null> {
|
||||
const [key, params] = config;
|
||||
|
||||
if (!params) {
|
||||
return key.join(':');
|
||||
} else if (ctx.getType<GqlContextType>() === 'graphql') {
|
||||
const args = GqlExecutionContext.create(ctx).getArgs();
|
||||
const cacheKey = params
|
||||
.map(name => args[name])
|
||||
.filter(v => v)
|
||||
.join(':');
|
||||
if (cacheKey) {
|
||||
return [...key, cacheKey].join(':');
|
||||
} else {
|
||||
return key.join(':');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
244
packages/backend/server/src/base/cache/provider.ts
vendored
Normal file
244
packages/backend/server/src/base/cache/provider.ts
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
export interface CacheSetOptions {
|
||||
/**
|
||||
* in milliseconds
|
||||
*/
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
const GET_AND_DELETE_LUA = `
|
||||
local value = redis.call("GET", KEYS[1])
|
||||
if value then
|
||||
redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return value
|
||||
`;
|
||||
|
||||
const INCREASE_WITH_TTL_LUA = `
|
||||
local value = redis.call("INCRBY", KEYS[1], ARGV[1])
|
||||
if value == tonumber(ARGV[1]) or redis.call("PTTL", KEYS[1]) == -1 then
|
||||
redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
||||
end
|
||||
return value
|
||||
`;
|
||||
|
||||
export function isValidCacheTtl(ttl: unknown): ttl is number {
|
||||
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
|
||||
}
|
||||
|
||||
export class CacheProvider {
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
// standard operation
|
||||
async get<T = unknown>(key: string): Promise<T> {
|
||||
return this.redis
|
||||
.get(key)
|
||||
.then(v => {
|
||||
if (v) {
|
||||
return JSON.parse(v);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async set<T = unknown>(
|
||||
key: string,
|
||||
value: T,
|
||||
opts: CacheSetOptions = {}
|
||||
): Promise<boolean> {
|
||||
if (isValidCacheTtl(opts.ttl)) {
|
||||
return this.redis
|
||||
.set(key, JSON.stringify(value), 'PX', opts.ttl)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
if (opts.ttl !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.redis
|
||||
.set(key, JSON.stringify(value))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async increase(key: string, count: number = 1): Promise<number> {
|
||||
return this.redis.incrby(key, count).catch(() => 0);
|
||||
}
|
||||
|
||||
async increaseWithTtl(
|
||||
key: string,
|
||||
ttl: number,
|
||||
count: number = 1
|
||||
): Promise<number> {
|
||||
if (!isValidCacheTtl(ttl)) return 0;
|
||||
return this.redis
|
||||
.eval(INCREASE_WITH_TTL_LUA, 1, key, count, ttl)
|
||||
.then(value => Number(value))
|
||||
.catch(() => 0);
|
||||
}
|
||||
|
||||
async decrease(key: string, count: number = 1): Promise<number> {
|
||||
return this.redis.decrby(key, count).catch(() => 0);
|
||||
}
|
||||
|
||||
async setnx<T = unknown>(
|
||||
key: string,
|
||||
value: T,
|
||||
opts: CacheSetOptions = {}
|
||||
): Promise<boolean> {
|
||||
if (isValidCacheTtl(opts.ttl)) {
|
||||
return this.redis
|
||||
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX')
|
||||
.then(v => !!v)
|
||||
.catch(() => false);
|
||||
}
|
||||
if (opts.ttl !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.redis
|
||||
.set(key, JSON.stringify(value), 'NX')
|
||||
.then(v => !!v)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return this.redis
|
||||
.del(key)
|
||||
.then(v => v > 0)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async getAndDelete<T = unknown>(key: string): Promise<T | undefined> {
|
||||
return this.redis
|
||||
.eval(GET_AND_DELETE_LUA, 1, key)
|
||||
.then(v => (typeof v === 'string' ? JSON.parse(v) : undefined))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return this.redis
|
||||
.exists(key)
|
||||
.then(v => v > 0)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return this.redis.ttl(key).catch(() => 0);
|
||||
}
|
||||
|
||||
async expire(key: string, ttl: number): Promise<boolean> {
|
||||
return this.redis
|
||||
.pexpire(key, ttl)
|
||||
.then(v => v > 0)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
// list operations
|
||||
async pushBack<T = unknown>(key: string, ...values: T[]): Promise<number> {
|
||||
return this.redis
|
||||
.rpush(key, ...values.map(v => JSON.stringify(v)))
|
||||
.catch(() => 0);
|
||||
}
|
||||
|
||||
async pushFront<T = unknown>(key: string, ...values: T[]): Promise<number> {
|
||||
return this.redis
|
||||
.lpush(key, ...values.map(v => JSON.stringify(v)))
|
||||
.catch(() => 0);
|
||||
}
|
||||
|
||||
async len(key: string): Promise<number> {
|
||||
return this.redis.llen(key).catch(() => 0);
|
||||
}
|
||||
|
||||
async list<T = unknown>(
|
||||
key: string,
|
||||
start: number,
|
||||
end: number
|
||||
): Promise<T[]> {
|
||||
return this.redis
|
||||
.lrange(key, start, end)
|
||||
.then(data => data.map(v => JSON.parse(v)))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
async popFront<T = unknown>(key: string, count: number = 1): Promise<T[]> {
|
||||
return this.redis
|
||||
.lpop(key, count)
|
||||
.then(data => (data ?? []).map(v => JSON.parse(v)))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
async popBack<T = unknown>(key: string, count: number = 1): Promise<T[]> {
|
||||
return this.redis
|
||||
.rpop(key, count)
|
||||
.then(data => (data ?? []).map(v => JSON.parse(v)))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
// map operations
|
||||
async mapSet<T = unknown>(
|
||||
map: string,
|
||||
key: string,
|
||||
value: T
|
||||
): Promise<boolean> {
|
||||
return this.redis
|
||||
.hset(map, key, JSON.stringify(value))
|
||||
.then(v => v > 0)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async mapIncrease(
|
||||
map: string,
|
||||
key: string,
|
||||
count: number = 1
|
||||
): Promise<number> {
|
||||
return this.redis.hincrby(map, key, count);
|
||||
}
|
||||
|
||||
async mapDecrease(
|
||||
map: string,
|
||||
key: string,
|
||||
count: number = 1
|
||||
): Promise<number> {
|
||||
return this.redis.hincrby(map, key, -count);
|
||||
}
|
||||
|
||||
async mapGet<T = unknown>(map: string, key: string): Promise<T | undefined> {
|
||||
return this.redis
|
||||
.hget(map, key)
|
||||
.then(v => (v ? JSON.parse(v) : undefined))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async mapDelete(map: string, key: string): Promise<boolean> {
|
||||
return this.redis
|
||||
.hdel(map, key)
|
||||
.then(v => v > 0)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async mapKeys(map: string): Promise<string[]> {
|
||||
return this.redis.hkeys(map).catch(() => []);
|
||||
}
|
||||
|
||||
async mapRandomKey(map: string): Promise<string | undefined> {
|
||||
return this.redis
|
||||
.hrandfield(map, 1)
|
||||
.then(v =>
|
||||
typeof v === 'string'
|
||||
? v
|
||||
: Array.isArray(v)
|
||||
? (v[0] as string)
|
||||
: undefined
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async mapLen(map: string): Promise<number> {
|
||||
return this.redis.hlen(map).catch(() => 0);
|
||||
}
|
||||
}
|
||||
220
packages/backend/server/src/base/config/__tests__/config.spec.ts
Normal file
220
packages/backend/server/src/base/config/__tests__/config.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { InvalidAppConfig } from '../../error';
|
||||
import { Config } from '../config';
|
||||
import { ConfigFactory, ConfigModule } from '../index';
|
||||
import { override } from '../register';
|
||||
|
||||
const module = await createModule();
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should create config', t => {
|
||||
const config = module.get(Config);
|
||||
|
||||
t.is(typeof config.auth.passwordRequirements.max, 'number');
|
||||
t.deepEqual(config.copilot.byok.allowedProviders, [
|
||||
'openai',
|
||||
'anthropic',
|
||||
'gemini',
|
||||
'fal',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should override config', async t => {
|
||||
await using module = await createModule({
|
||||
imports: [
|
||||
ConfigModule.override({
|
||||
auth: {
|
||||
passwordRequirements: {
|
||||
max: 100,
|
||||
min: 6,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const config = module.get(Config);
|
||||
const configFactory = module.get(ConfigFactory);
|
||||
|
||||
t.deepEqual(config.auth.passwordRequirements, {
|
||||
max: 100,
|
||||
min: 6,
|
||||
});
|
||||
|
||||
configFactory.override({
|
||||
auth: {
|
||||
passwordRequirements: {
|
||||
max: 10,
|
||||
min: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.deepEqual(config.auth.passwordRequirements, {
|
||||
max: 10,
|
||||
min: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate config', t => {
|
||||
const config = module.get(ConfigFactory);
|
||||
|
||||
t.is(
|
||||
config.validate([
|
||||
{
|
||||
module: 'auth',
|
||||
key: 'passwordRequirements',
|
||||
value: { max: 10, min: 6 },
|
||||
},
|
||||
]),
|
||||
null
|
||||
);
|
||||
|
||||
const [error] = config.validate([
|
||||
{
|
||||
module: 'auth',
|
||||
key: 'passwordRequirements',
|
||||
value: { max: 10, min: 10 },
|
||||
},
|
||||
])!;
|
||||
|
||||
t.true(error instanceof InvalidAppConfig);
|
||||
t.is(
|
||||
error.message,
|
||||
'Invalid app config for module `auth` with key `passwordRequirements`. Minimum length of password must be less than maximum length.'
|
||||
);
|
||||
|
||||
const [nativeError] = config.validate([
|
||||
{
|
||||
module: 'copilot',
|
||||
key: 'byok.allowedProviders',
|
||||
value: ['openai', 'openai'],
|
||||
},
|
||||
])!;
|
||||
t.true(nativeError instanceof InvalidAppConfig);
|
||||
t.regex(nativeError.message, /supported and unique/);
|
||||
});
|
||||
|
||||
test('should override correctly', t => {
|
||||
const config = {
|
||||
auth: {
|
||||
// object config
|
||||
passwordRequirements: {
|
||||
max: 10,
|
||||
min: 6,
|
||||
},
|
||||
allowSignup: false,
|
||||
// keyed config
|
||||
// 'session.ttl', 'session.ttr'
|
||||
session: {
|
||||
ttl: 2000,
|
||||
ttr: 1000,
|
||||
},
|
||||
},
|
||||
storages: {
|
||||
avatar: {
|
||||
// keyed config
|
||||
// "avatar.publicPath: String"
|
||||
publicPath: '/',
|
||||
// object config
|
||||
// "avatar.storage => Object { }"
|
||||
storage: {
|
||||
provider: 'fs',
|
||||
config: {
|
||||
path: '/path/to/avatar',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
override(config, {
|
||||
auth: {
|
||||
passwordRequirements: {
|
||||
max: 20,
|
||||
},
|
||||
allowSignup: true,
|
||||
session: {
|
||||
ttl: 3000,
|
||||
},
|
||||
},
|
||||
storages: {
|
||||
avatar: {
|
||||
storage: {
|
||||
provider: 'aws-s3',
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId: '1',
|
||||
secretAccessKey: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// simple value override
|
||||
t.deepEqual(config.auth.allowSignup, true);
|
||||
|
||||
// right covered left
|
||||
t.deepEqual(config.auth.passwordRequirements, {
|
||||
max: 20,
|
||||
});
|
||||
|
||||
// right merged to left
|
||||
t.deepEqual(config.auth.session, {
|
||||
ttl: 3000,
|
||||
ttr: 1000,
|
||||
});
|
||||
|
||||
// right covered left
|
||||
t.deepEqual(config.storages.avatar.storage, {
|
||||
provider: 'aws-s3',
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId: '1',
|
||||
secretAccessKey: '1',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should clone from original config without modifications', t => {
|
||||
const config = module.get(Config);
|
||||
const configFactory = module.get(ConfigFactory);
|
||||
|
||||
config.auth.allowSignup = !config.auth.allowSignup;
|
||||
|
||||
const newConfig = configFactory.clone();
|
||||
|
||||
t.not(newConfig.auth.allowSignup, config.auth.allowSignup);
|
||||
});
|
||||
|
||||
test('should override with undefined fields', async t => {
|
||||
await using module = await createModule({
|
||||
imports: [ConfigModule],
|
||||
});
|
||||
|
||||
const config = module.get(Config);
|
||||
const configFactory = module.get(ConfigFactory);
|
||||
|
||||
configFactory.override({
|
||||
copilot: {
|
||||
providers: {
|
||||
// @ts-expect-error undefined field
|
||||
unknown: {
|
||||
apiKey: '123',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error undefined field
|
||||
t.deepEqual(config.copilot.providers.unknown, {
|
||||
apiKey: '123',
|
||||
});
|
||||
});
|
||||
3
packages/backend/server/src/base/config/config.ts
Normal file
3
packages/backend/server/src/base/config/config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ApplyType } from '../utils';
|
||||
|
||||
export class Config extends ApplyType<AppConfig>() {}
|
||||
33
packages/backend/server/src/base/config/env.ts
Normal file
33
packages/backend/server/src/base/config/env.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type EnvConfigType = 'string' | 'integer' | 'float' | 'boolean';
|
||||
|
||||
/**
|
||||
* parse number value from environment variables
|
||||
*/
|
||||
function integer(value: string) {
|
||||
const n = parseInt(value);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
function float(value: string) {
|
||||
const n = parseFloat(value);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
function boolean(value: string) {
|
||||
return value === '1' || value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
const envParsers: Record<EnvConfigType, (value: string) => unknown> = {
|
||||
integer,
|
||||
float,
|
||||
boolean,
|
||||
string: value => value,
|
||||
};
|
||||
|
||||
export function parseEnvValue(value: string | undefined, type: EnvConfigType) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return envParsers[type](value);
|
||||
}
|
||||
73
packages/backend/server/src/base/config/factory.ts
Normal file
73
packages/backend/server/src/base/config/factory.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
|
||||
import { InvalidAppConfig } from '../error';
|
||||
import { APP_CONFIG_DESCRIPTORS, getDefaultConfig, override } from './register';
|
||||
|
||||
export const OVERRIDE_CONFIG_TOKEN = Symbol('OVERRIDE_CONFIG_TOKEN');
|
||||
|
||||
@Injectable()
|
||||
export class ConfigFactory {
|
||||
readonly #original: AppConfig;
|
||||
readonly #config: AppConfig;
|
||||
get config() {
|
||||
return this.#config;
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(OVERRIDE_CONFIG_TOKEN)
|
||||
@Optional()
|
||||
private readonly overrides: DeepPartial<AppConfig> = {}
|
||||
) {
|
||||
this.#original = this.loadDefault();
|
||||
this.#config = structuredClone(this.#original);
|
||||
}
|
||||
|
||||
clone() {
|
||||
// we did not freeze the #config object, it might be modified
|
||||
return structuredClone(this.#original);
|
||||
}
|
||||
|
||||
override(updates: DeepPartial<AppConfig>) {
|
||||
override(this.#original, updates);
|
||||
override(this.#config, updates);
|
||||
}
|
||||
|
||||
validate(updates: Array<{ module: string; key: string; value: any }>) {
|
||||
const errors: InvalidAppConfig[] = [];
|
||||
|
||||
updates.forEach(update => {
|
||||
const descriptor = APP_CONFIG_DESCRIPTORS[update.module]?.[update.key];
|
||||
if (!descriptor) {
|
||||
errors.push(
|
||||
new InvalidAppConfig({
|
||||
module: update.module,
|
||||
key: update.key,
|
||||
hint: `Unknown config [${update.key}]`,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { success, error } = descriptor.validate(update.value);
|
||||
if (!success) {
|
||||
error.issues.forEach(issue => {
|
||||
errors.push(
|
||||
new InvalidAppConfig({
|
||||
module: update.module,
|
||||
key: update.key,
|
||||
hint: issue.message,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors.length > 0 ? errors : null;
|
||||
}
|
||||
|
||||
private loadDefault() {
|
||||
const config = getDefaultConfig();
|
||||
override(config, this.overrides);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
34
packages/backend/server/src/base/config/index.ts
Normal file
34
packages/backend/server/src/base/config/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
|
||||
|
||||
import { Config } from './config';
|
||||
import { ConfigFactory, OVERRIDE_CONFIG_TOKEN } from './factory';
|
||||
import { ConfigProvider } from './provider';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ConfigProvider, ConfigFactory],
|
||||
exports: [ConfigProvider, ConfigFactory],
|
||||
})
|
||||
export class ConfigModule {
|
||||
static override(overrides: DeepPartial<AppConfigSchema> = {}): DynamicModule {
|
||||
const provider: Provider = {
|
||||
provide: OVERRIDE_CONFIG_TOKEN,
|
||||
useValue: overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
global: true,
|
||||
module: class ConfigOverrideModule {},
|
||||
providers: [provider],
|
||||
exports: [provider],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { Config, ConfigFactory };
|
||||
export {
|
||||
defineModuleConfig,
|
||||
defineNativeModuleConfig,
|
||||
type JSONSchema,
|
||||
type NativeAppConfigDescriptor,
|
||||
} from './register';
|
||||
12
packages/backend/server/src/base/config/provider.ts
Normal file
12
packages/backend/server/src/base/config/provider.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
|
||||
import { Config } from './config';
|
||||
import { ConfigFactory } from './factory';
|
||||
|
||||
export const ConfigProvider: FactoryProvider = {
|
||||
provide: Config,
|
||||
useFactory: (factory: ConfigFactory) => {
|
||||
return factory.config;
|
||||
},
|
||||
inject: [ConfigFactory],
|
||||
};
|
||||
394
packages/backend/server/src/base/config/register.ts
Normal file
394
packages/backend/server/src/base/config/register.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { mergeWith, once, set } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type EnvConfigType, parseEnvValue } from './env';
|
||||
import { AppConfigByPath } from './types';
|
||||
|
||||
export type JSONSchema = {
|
||||
$id?: string;
|
||||
$ref?: string;
|
||||
$schema?: string;
|
||||
additionalProperties?: boolean | JSONSchema;
|
||||
allOf?: JSONSchema[];
|
||||
anyOf?: JSONSchema[];
|
||||
definitions?: Record<string, JSONSchema>;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: unknown[];
|
||||
format?: string;
|
||||
items?: JSONSchema;
|
||||
minItems?: number;
|
||||
minLength?: number;
|
||||
oneOf?: JSONSchema[];
|
||||
pattern?: string;
|
||||
properties?: Record<string, JSONSchema>;
|
||||
required?: string[];
|
||||
title?: string;
|
||||
type?:
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'array'
|
||||
| 'object'
|
||||
| 'null'
|
||||
| Array<'string' | 'number' | 'boolean' | 'array' | 'object' | 'null'>;
|
||||
};
|
||||
|
||||
type ConfigType = EnvConfigType | 'array' | 'object' | 'any';
|
||||
export type ConfigDescriptor<T> = {
|
||||
desc: string;
|
||||
type: ConfigType;
|
||||
validate: (value: T) => z.SafeParseReturnType<T, T>;
|
||||
schema: JSONSchema;
|
||||
default: T;
|
||||
env?: [string, EnvConfigType];
|
||||
link?: string;
|
||||
internal?: boolean;
|
||||
};
|
||||
|
||||
type ConfigDefineDescriptor<T> = {
|
||||
desc: string;
|
||||
default: T;
|
||||
validate?: (value: T) => z.SafeParseReturnType<T, T>;
|
||||
shape?: z.ZodType<T>;
|
||||
env?: string | [string, EnvConfigType];
|
||||
link?: string;
|
||||
schema?: JSONSchema;
|
||||
internal?: boolean;
|
||||
};
|
||||
|
||||
function typeFromShape(shape: z.ZodType<any>): ConfigType {
|
||||
switch (shape.constructor) {
|
||||
case z.ZodString:
|
||||
return 'string';
|
||||
case z.ZodNumber:
|
||||
return 'float';
|
||||
case z.ZodBoolean:
|
||||
return 'boolean';
|
||||
case z.ZodArray:
|
||||
return 'array';
|
||||
case z.ZodObject:
|
||||
return 'object';
|
||||
case z.ZodOptional:
|
||||
case z.ZodNullable:
|
||||
// @ts-expect-error checked
|
||||
return typeFromShape(shape.unwrap());
|
||||
default:
|
||||
return 'any';
|
||||
}
|
||||
}
|
||||
|
||||
function shapeFromType(type: ConfigType): z.ZodType<any> {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return z.string();
|
||||
case 'float':
|
||||
return z.number();
|
||||
case 'boolean':
|
||||
return z.boolean();
|
||||
case 'integer':
|
||||
return z.number().int();
|
||||
case 'array':
|
||||
return z.array(z.any());
|
||||
case 'object':
|
||||
return z.object({});
|
||||
default:
|
||||
return z.any();
|
||||
}
|
||||
}
|
||||
|
||||
function typeFromSchema(schema: JSONSchema): ConfigType {
|
||||
switch (schema.type) {
|
||||
case 'string':
|
||||
return 'string';
|
||||
case 'number':
|
||||
return 'float';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'array':
|
||||
return 'array';
|
||||
case 'object':
|
||||
return 'object';
|
||||
}
|
||||
|
||||
return 'any';
|
||||
}
|
||||
|
||||
function schemaFromType(type: ConfigType): JSONSchema['type'] {
|
||||
switch (type) {
|
||||
case 'any':
|
||||
return undefined;
|
||||
case 'float':
|
||||
case 'integer':
|
||||
return 'number';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function typeFromDefault<T>(defaultValue: T): ConfigType {
|
||||
if (Array.isArray(defaultValue)) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
switch (typeof defaultValue) {
|
||||
case 'string':
|
||||
return 'string';
|
||||
case 'number':
|
||||
return 'float';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'object':
|
||||
return 'object';
|
||||
default:
|
||||
return 'any';
|
||||
}
|
||||
}
|
||||
|
||||
function standardizeDescriptor<T>(
|
||||
desc: ConfigDefineDescriptor<T>
|
||||
): ConfigDescriptor<T> {
|
||||
const env = desc.env
|
||||
? Array.isArray(desc.env)
|
||||
? desc.env
|
||||
: ([desc.env, 'string'] as [string, EnvConfigType])
|
||||
: undefined;
|
||||
|
||||
let type: ConfigType = 'any';
|
||||
|
||||
if (desc.default !== undefined && desc.default !== null) {
|
||||
type = typeFromDefault(desc.default);
|
||||
} else if (env) {
|
||||
type = env[1];
|
||||
} else if (desc.shape) {
|
||||
type = typeFromShape(desc.shape);
|
||||
} else if (desc.schema) {
|
||||
type = typeFromSchema(desc.schema);
|
||||
}
|
||||
|
||||
const shape = desc.shape ?? shapeFromType(type);
|
||||
|
||||
return {
|
||||
desc: desc.desc,
|
||||
default: desc.default,
|
||||
type,
|
||||
validate: (value: T) => {
|
||||
return desc.validate ? desc.validate(value) : shape.safeParse(value);
|
||||
},
|
||||
env,
|
||||
link: desc.link,
|
||||
internal: desc.internal,
|
||||
schema: {
|
||||
type: schemaFromType(type),
|
||||
description: desc.desc,
|
||||
...desc.schema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ModuleConfigDescriptors<T> = {
|
||||
[K in keyof T]: ConfigDefineDescriptor<T[K]>;
|
||||
};
|
||||
|
||||
export const APP_CONFIG_DESCRIPTORS: Record<
|
||||
string,
|
||||
Record<string, ConfigDescriptor<any>>
|
||||
> = {};
|
||||
|
||||
export const getDescriptors = once(() => {
|
||||
return Object.entries(APP_CONFIG_DESCRIPTORS).map(
|
||||
([module, descriptors]) => ({
|
||||
module,
|
||||
descriptors: Object.entries(descriptors).map(([key, descriptor]) => ({
|
||||
key,
|
||||
descriptor,
|
||||
})),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export function defineModuleConfig<T extends keyof AppConfigSchema>(
|
||||
module: T,
|
||||
defs: ModuleConfigDescriptors<AppConfigByPath<T>>
|
||||
) {
|
||||
registerModuleConfig(
|
||||
module,
|
||||
defs as Record<string, ConfigDefineDescriptor<unknown>>
|
||||
);
|
||||
}
|
||||
|
||||
function registerModuleConfig(
|
||||
module: string,
|
||||
defs: Record<string, ConfigDefineDescriptor<unknown>>
|
||||
) {
|
||||
const descriptors: Record<string, ConfigDescriptor<any>> = {};
|
||||
Object.entries(defs).forEach(([key, desc]) => {
|
||||
descriptors[key] = standardizeDescriptor(desc);
|
||||
});
|
||||
|
||||
APP_CONFIG_DESCRIPTORS[module] = {
|
||||
...APP_CONFIG_DESCRIPTORS[module],
|
||||
...descriptors,
|
||||
};
|
||||
}
|
||||
|
||||
export type NativeAppConfigDescriptor = {
|
||||
key: string;
|
||||
description: string;
|
||||
defaultValue: unknown;
|
||||
schema: JSONSchema;
|
||||
internal: boolean;
|
||||
};
|
||||
|
||||
export function defineNativeModuleConfig<T extends keyof AppConfigSchema>(
|
||||
module: T,
|
||||
descriptors: NativeAppConfigDescriptor[],
|
||||
validate: (module: string, key: string, value: unknown) => string[],
|
||||
nodeDefinitions: Partial<ModuleConfigDescriptors<AppConfigByPath<T>>> = {}
|
||||
) {
|
||||
registerModuleConfig(module, {
|
||||
...nodeDefinitions,
|
||||
...Object.fromEntries(
|
||||
descriptors.map(descriptor => [
|
||||
descriptor.key,
|
||||
{
|
||||
desc: descriptor.description,
|
||||
default: descriptor.defaultValue,
|
||||
schema: descriptor.schema,
|
||||
internal: descriptor.internal,
|
||||
validate: (value: unknown) => {
|
||||
const errors = validate(module, descriptor.key, value);
|
||||
return errors.length
|
||||
? {
|
||||
success: false as const,
|
||||
error: new z.ZodError(
|
||||
errors.map(message => ({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message,
|
||||
path: [],
|
||||
}))
|
||||
),
|
||||
}
|
||||
: { success: true as const, data: value };
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
} as Record<string, ConfigDefineDescriptor<unknown>>);
|
||||
}
|
||||
|
||||
export const CONFIG_JSON_PATHS = [
|
||||
join(env.projectRoot, 'config.json'),
|
||||
`${homedir()}/.affine/config/config.json`,
|
||||
];
|
||||
function readConfigJSONOverrides(path: string) {
|
||||
const overrides: DeepPartial<AppConfig> = {};
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(path, 'utf-8')) as AppConfig;
|
||||
|
||||
Object.entries(config).forEach(([key, value]) => {
|
||||
if (key === '$schema') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([k, v]) => {
|
||||
set(overrides, `${key}.${k}`, v);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Invalid json config file', e);
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export function override(config: AppConfig, update: DeepPartial<AppConfig>) {
|
||||
Object.keys(update).forEach(module => {
|
||||
const moduleDescriptors = APP_CONFIG_DESCRIPTORS[module];
|
||||
// ignore unknown config module
|
||||
if (!moduleDescriptors) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configKeys = new Set(Object.keys(moduleDescriptors));
|
||||
|
||||
const moduleConfig = config[module as keyof AppConfig];
|
||||
const moduleOverrides = update[module as keyof AppConfig];
|
||||
|
||||
const merge = (left: any, right: any, path: string = '') => {
|
||||
// if we found the key in the config keys
|
||||
// we should use the override object instead of merge it with left
|
||||
if (configKeys.has(path)) {
|
||||
return right;
|
||||
}
|
||||
|
||||
// EDGE CASE:
|
||||
// the right value is primitive and we're still not finding the key in descriptors,
|
||||
// which means the overrides has keys not defined
|
||||
// that's where we should return
|
||||
if (typeof right !== 'object') {
|
||||
return left;
|
||||
}
|
||||
|
||||
// go deeper
|
||||
return mergeWith(left, right, (left, right, key) => {
|
||||
return merge(left, right, path === '' ? key : `${path}.${key}`);
|
||||
});
|
||||
};
|
||||
|
||||
config[module as keyof AppConfig] = merge(moduleConfig, moduleOverrides);
|
||||
});
|
||||
}
|
||||
|
||||
export function getDefaultConfig(): AppConfig {
|
||||
const config = {} as AppConfig;
|
||||
const envs = process.env;
|
||||
|
||||
for (const [module, defs] of Object.entries(APP_CONFIG_DESCRIPTORS)) {
|
||||
const modulizedConfig = {};
|
||||
|
||||
for (const [key, desc] of Object.entries(defs)) {
|
||||
let defaultValue = desc.default;
|
||||
|
||||
if (desc.env) {
|
||||
const [env, parser] = desc.env;
|
||||
const envValue = envs[env];
|
||||
if (envValue) {
|
||||
defaultValue = parseEnvValue(envValue, parser);
|
||||
}
|
||||
}
|
||||
|
||||
const { success, error } = desc.validate(defaultValue);
|
||||
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
error.issues
|
||||
.map(issue => {
|
||||
return `Invalid config for module [${module}] with key [${key}]
|
||||
Value: ${JSON.stringify(defaultValue)}
|
||||
Error: ${issue.message}`;
|
||||
})
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
set(modulizedConfig, key, defaultValue);
|
||||
}
|
||||
|
||||
// @ts-expect-error all keys are known
|
||||
config[module] = modulizedConfig;
|
||||
}
|
||||
|
||||
CONFIG_JSON_PATHS.forEach(path => {
|
||||
const overrides = readConfigJSONOverrides(path);
|
||||
override(config, overrides);
|
||||
});
|
||||
|
||||
return config as AppConfigSchema;
|
||||
}
|
||||
20
packages/backend/server/src/base/config/types.ts
Normal file
20
packages/backend/server/src/base/config/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LeafPaths, PathType } from '../utils';
|
||||
|
||||
declare global {
|
||||
type ConfigItem<T> = Leaf<T>;
|
||||
interface AppConfigSchema {}
|
||||
type AppConfig = DeeplyEraseLeaf<AppConfigSchema>;
|
||||
}
|
||||
|
||||
export type AppConfigByPath<Module extends keyof AppConfigSchema> =
|
||||
AppConfigSchema[Module] extends infer Config
|
||||
? {
|
||||
[Path in LeafPaths<Config>]: Path extends string
|
||||
? PathType<Config, Path> extends infer Item
|
||||
? Item extends Leaf<infer V>
|
||||
? V
|
||||
: Item
|
||||
: never
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
124
packages/backend/server/src/base/cors.ts
Normal file
124
packages/backend/server/src/base/cors.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { URLHelper } from './helpers';
|
||||
|
||||
const DEV_LOOPBACK_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
const DEV_LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
const MOBILE_CLIENT_ORIGINS = new Set([
|
||||
'https://localhost',
|
||||
'capacitor://localhost',
|
||||
'ionic://localhost',
|
||||
]);
|
||||
const DESKTOP_CLIENT_ORIGINS = new Set([
|
||||
'assets://.',
|
||||
'assets://another-host',
|
||||
// for old versions of client, which use file:// as origin
|
||||
'file://',
|
||||
]);
|
||||
|
||||
export const CORS_ALLOWED_METHODS = [
|
||||
'GET',
|
||||
'HEAD',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'DELETE',
|
||||
'OPTIONS',
|
||||
];
|
||||
|
||||
export const CORS_ALLOWED_HEADERS = [
|
||||
'accept',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'x-affine-version',
|
||||
'x-affine-client-kind',
|
||||
'x-operation-name',
|
||||
'x-request-id',
|
||||
'x-captcha-token',
|
||||
'x-captcha-challenge',
|
||||
'x-captcha-provider',
|
||||
'x-affine-csrf-token',
|
||||
'x-requested-with',
|
||||
'range',
|
||||
];
|
||||
|
||||
export const CORS_EXPOSED_HEADERS = [
|
||||
'content-length',
|
||||
'content-range',
|
||||
'x-request-id',
|
||||
];
|
||||
|
||||
function normalizeHostname(hostname: string) {
|
||||
return hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
||||
}
|
||||
|
||||
function isDevLoopbackOrigin(origin: string) {
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
return (
|
||||
DEV_LOOPBACK_PROTOCOLS.has(parsed.protocol) &&
|
||||
DEV_LOOPBACK_HOSTS.has(normalizeHostname(parsed.hostname))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCorsOrigin(origin: string) {
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
// Some websocket clients send ws:// or wss:// as Origin.
|
||||
if (parsed.protocol === 'ws:' || parsed.protocol === 'wss:') {
|
||||
parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:';
|
||||
}
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCorsAllowedOrigins(url: URLHelper) {
|
||||
return new Set<string>([
|
||||
...url.allowedOrigins,
|
||||
...MOBILE_CLIENT_ORIGINS,
|
||||
...DESKTOP_CLIENT_ORIGINS,
|
||||
]);
|
||||
}
|
||||
|
||||
export function isCorsOriginAllowed(
|
||||
origin: string | undefined | null,
|
||||
allowedOrigins: Set<string>
|
||||
) {
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowedOrigins.has(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedOrigin = normalizeCorsOrigin(origin);
|
||||
if (normalizedOrigin && allowedOrigins.has(normalizedOrigin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((env.dev || env.testing) && isDevLoopbackOrigin(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function corsOriginCallback(
|
||||
origin: string | undefined,
|
||||
allowedOrigins: Set<string>,
|
||||
onBlocked: (origin: string) => void,
|
||||
callback: (error: Error | null, allow?: boolean) => void
|
||||
) {
|
||||
if (isCorsOriginAllowed(origin, allowedOrigins)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const blockedOrigin = origin ?? '<empty>';
|
||||
onBlocked(blockedOrigin);
|
||||
callback(null, false);
|
||||
}
|
||||
1036
packages/backend/server/src/base/error/def.ts
Normal file
1036
packages/backend/server/src/base/error/def.ts
Normal file
File diff suppressed because it is too large
Load Diff
1393
packages/backend/server/src/base/error/errors.gen.ts
Normal file
1393
packages/backend/server/src/base/error/errors.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
packages/backend/server/src/base/error/index.ts
Normal file
40
packages/backend/server/src/base/error/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { generateUserFriendlyErrors } from './def';
|
||||
import { ActionForbidden, ErrorDataUnionType, ErrorNames } from './errors.gen';
|
||||
|
||||
@Resolver(() => ErrorDataUnionType)
|
||||
class ErrorResolver {
|
||||
// only exists for type registering
|
||||
@Query(() => ErrorDataUnionType)
|
||||
error(@Args({ name: 'name', type: () => ErrorNames }) _name: ErrorNames) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [ErrorResolver],
|
||||
})
|
||||
export class ErrorModule implements OnModuleInit {
|
||||
logger = new Logger('ErrorModule');
|
||||
onModuleInit() {
|
||||
if (!env.dev) {
|
||||
return;
|
||||
}
|
||||
this.logger.log('Generating UserFriendlyError classes');
|
||||
const def = generateUserFriendlyErrors();
|
||||
|
||||
writeFileSync(
|
||||
join(fileURLToPath(import.meta.url), '../errors.gen.ts'),
|
||||
def
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { UserFriendlyError } from './def';
|
||||
export * from './errors.gen';
|
||||
48
packages/backend/server/src/base/event/def.ts
Normal file
48
packages/backend/server/src/base/event/def.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { OnOptions } from 'eventemitter2';
|
||||
|
||||
import { PushMetadata, sliceMetadata } from '../nestjs/decorator';
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Event definitions can be extended by
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* declare global {
|
||||
* interface Events {
|
||||
* 'user.subscription.created': {
|
||||
* userId: User['id'];
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
interface Events {}
|
||||
}
|
||||
|
||||
export type EventName = keyof Events;
|
||||
export const EVENT_LISTENER_METADATA = Symbol('event_listener');
|
||||
|
||||
interface EventHandlerMetadata {
|
||||
namespace: string;
|
||||
event: EventName;
|
||||
opts?: OnOptions;
|
||||
}
|
||||
|
||||
export interface EventOptions extends OnOptions {
|
||||
prepend?: boolean;
|
||||
name?: string;
|
||||
suppressError?: boolean;
|
||||
}
|
||||
|
||||
export const OnEvent = (event: EventName, opts?: EventOptions) => {
|
||||
const namespace = event.split('.')[0];
|
||||
return PushMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, {
|
||||
namespace,
|
||||
event,
|
||||
opts,
|
||||
});
|
||||
};
|
||||
|
||||
export function getEventHandlerMetadata(target: any): EventHandlerMetadata[] {
|
||||
return sliceMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, target);
|
||||
}
|
||||
204
packages/backend/server/src/base/event/eventbus.ts
Normal file
204
packages/backend/server/src/base/event/eventbus.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import EventEmitter2 from 'eventemitter2';
|
||||
import { once } from 'lodash-es';
|
||||
import { CLS_ID, ClsService, ClsServiceManager } from 'nestjs-cls';
|
||||
import type { Server, Socket } from 'socket.io';
|
||||
|
||||
import { wrapCallMetric } from '../metrics';
|
||||
import { genRequestId } from '../utils';
|
||||
import { type EventName, type EventOptions } from './def';
|
||||
import { EventHandlerScanner } from './scanner';
|
||||
|
||||
interface EventHandlerErrorPayload {
|
||||
event: string;
|
||||
payload: any;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* We use socket.io system to auto pub/sub on server to server broadcast events
|
||||
*/
|
||||
@WebSocketGateway({
|
||||
namespace: 's2s',
|
||||
})
|
||||
@Injectable()
|
||||
export class EventBus
|
||||
implements OnGatewayConnection, OnApplicationBootstrap, OnModuleInit
|
||||
{
|
||||
private readonly logger = new Logger(EventBus.name);
|
||||
|
||||
@WebSocketServer()
|
||||
private readonly server?: Server;
|
||||
|
||||
constructor(
|
||||
private readonly emitter: EventEmitter2,
|
||||
private readonly cls: ClsService,
|
||||
private readonly scanner: EventHandlerScanner
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
// for internal usage only, disallow any connection from client
|
||||
this.logger.warn(
|
||||
`EventBus get suspicious connection from client ${client.id}, disconnecting...`
|
||||
);
|
||||
client.disconnect();
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.bindEventHandlers();
|
||||
this.emitter.on('error', ({ event, error }: EventHandlerErrorPayload) => {
|
||||
this.logger.error(`Error happened when handling event ${event}`, error);
|
||||
});
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
// Proxy all events received from server(trigger by `server.serverSideEmit`)
|
||||
// to internal event system
|
||||
this.server?.on('broadcast', (event, payload, requestId?: string) => {
|
||||
this.cls.run(() => {
|
||||
requestId = requestId ?? genRequestId('event');
|
||||
this.cls.set(CLS_ID, requestId);
|
||||
this.logger.debug(`Server Event: ${event} (Received)`);
|
||||
this.emit(event, payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event to trigger all listeners on current instance
|
||||
*/
|
||||
async emitAsync<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.debug(`Dispatch event: ${event} (async)`);
|
||||
return await this.emitter.emitAsync(event, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event to trigger all listeners on current instance
|
||||
*/
|
||||
emit<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.debug(`Dispatch event: ${event}`);
|
||||
|
||||
this.dispatchAsync(event, payload);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event in detached cls context to avoid inheriting current transaction.
|
||||
*/
|
||||
emitDetached<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.debug(`Dispatch event: ${event} (detached)`);
|
||||
|
||||
const requestId = this.cls.getId();
|
||||
this.cls.run({ ifNested: 'override' }, () => {
|
||||
this.cls.set(CLS_ID, requestId ?? genRequestId('event'));
|
||||
this.dispatchAsync(event, payload);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Await listeners without inheriting the current transaction.
|
||||
*/
|
||||
async emitDetachedAsync<T extends EventName>(event: T, payload: Events[T]) {
|
||||
const requestId = this.cls.getId();
|
||||
return await this.cls.run({ ifNested: 'override' }, async () => {
|
||||
this.cls.set(CLS_ID, requestId ?? genRequestId('event'));
|
||||
return await this.emitAsync(event, payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast event to trigger all listeners on all instance in cluster
|
||||
*/
|
||||
broadcast<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.debug(`Server Event: ${event} (Send)`);
|
||||
this.server?.serverSideEmit('broadcast', event, payload, this.cls.getId());
|
||||
}
|
||||
|
||||
on<T extends EventName>(
|
||||
event: T,
|
||||
listener: (payload: Events[T]) => void | Promise<any>,
|
||||
opts: EventOptions = {}
|
||||
) {
|
||||
const namespace = event.split('.')[0];
|
||||
const { name, prepend, suppressError } = opts;
|
||||
const handlerName = name ?? listener.name ?? 'anonymous fn';
|
||||
let signature = `[${event}] (${handlerName})`;
|
||||
|
||||
const add = prepend ? this.emitter.prependListener : this.emitter.on;
|
||||
|
||||
const handler = wrapCallMetric(
|
||||
async (payload: any) => {
|
||||
this.logger.verbose(`Handle event ${signature}`);
|
||||
|
||||
const cls = ClsServiceManager.getClsService();
|
||||
return await cls.run({ ifNested: 'reuse' }, async () => {
|
||||
const requestId = cls.getId();
|
||||
if (!requestId) {
|
||||
cls.set(CLS_ID, genRequestId('event'));
|
||||
}
|
||||
try {
|
||||
return await listener(payload);
|
||||
} catch (e) {
|
||||
if (suppressError) {
|
||||
this.emitter.emit('error', {
|
||||
event,
|
||||
payload,
|
||||
error: e,
|
||||
} as EventHandlerErrorPayload);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
'event',
|
||||
'event_handler',
|
||||
{
|
||||
event,
|
||||
namespace,
|
||||
handler: handlerName,
|
||||
}
|
||||
);
|
||||
|
||||
add.call(this.emitter, event, handler as any, opts);
|
||||
|
||||
this.logger.log(`Event handler registered ${signature}`);
|
||||
|
||||
return () => {
|
||||
this.emitter.off(event, handler as any);
|
||||
};
|
||||
}
|
||||
|
||||
waitFor<T extends EventName>(name: T, timeout?: number) {
|
||||
return this.emitter.waitFor(name, timeout);
|
||||
}
|
||||
|
||||
private dispatchAsync<T extends EventName>(event: T, payload: Events[T]) {
|
||||
// NOTE:
|
||||
// Because all event handlers are wrapped in promisified metrics and cls context, they will always run in standalone tick.
|
||||
// In which way, if handler throws, an unhandled rejection will be triggered and end up with process exiting.
|
||||
// So we catch it here with `emitAsync`
|
||||
this.emitter.emitAsync(event, payload).catch(e => {
|
||||
this.emitter.emit('error', { event, payload, error: e });
|
||||
});
|
||||
}
|
||||
|
||||
private readonly bindEventHandlers = once(() => {
|
||||
this.scanner.scan().forEach(({ event, handler, opts }) => {
|
||||
this.on(event, handler, opts);
|
||||
});
|
||||
});
|
||||
}
|
||||
23
packages/backend/server/src/base/event/index.ts
Normal file
23
packages/backend/server/src/base/event/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import EventEmitter2 from 'eventemitter2';
|
||||
|
||||
import { EventBus } from './eventbus';
|
||||
import { EventHandlerScanner } from './scanner';
|
||||
|
||||
const EmitProvider = {
|
||||
provide: EventEmitter2,
|
||||
useFactory: () =>
|
||||
new EventEmitter2({
|
||||
maxListeners: 100,
|
||||
}),
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EventBus, EventHandlerScanner, EmitProvider],
|
||||
exports: [EventBus],
|
||||
})
|
||||
export class EventModule {}
|
||||
|
||||
export { EventBus };
|
||||
export { OnEvent } from './def';
|
||||
71
packages/backend/server/src/base/event/scanner.ts
Normal file
71
packages/backend/server/src/base/event/scanner.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { once } from 'lodash-es';
|
||||
|
||||
import { ModuleScanner } from '../nestjs/scanner';
|
||||
import {
|
||||
type EventName,
|
||||
type EventOptions,
|
||||
getEventHandlerMetadata,
|
||||
} from './def';
|
||||
|
||||
@Injectable()
|
||||
export class EventHandlerScanner {
|
||||
constructor(private readonly scanner: ModuleScanner) {}
|
||||
|
||||
scan = once(() => {
|
||||
const handlers: Array<{
|
||||
event: EventName;
|
||||
handler: (payload: any) => any;
|
||||
opts?: EventOptions;
|
||||
}> = [];
|
||||
const providers = this.scanner.getAtInjectables();
|
||||
|
||||
providers.forEach(wrapper => {
|
||||
const { instance, name } = wrapper;
|
||||
if (!instance || wrapper.isAlias) {
|
||||
return;
|
||||
}
|
||||
|
||||
const methods = this.scanner.getAllMethodNames(instance);
|
||||
|
||||
methods.forEach(method => {
|
||||
const fn = instance[method];
|
||||
|
||||
let defs = getEventHandlerMetadata(instance[method]);
|
||||
|
||||
if (defs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = `${name}.${method}`;
|
||||
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`Event handler [${signature}] is not a function.`);
|
||||
}
|
||||
|
||||
if (!wrapper.isDependencyTreeStatic()) {
|
||||
throw new Error(
|
||||
`Provider [${name}] could not be RequestScoped or TransientScoped injectable if it contains event handlers.`
|
||||
);
|
||||
}
|
||||
|
||||
defs.forEach(({ event, opts }) => {
|
||||
handlers.push({
|
||||
event,
|
||||
handler: (payload: any) => {
|
||||
// NOTE(@forehalo):
|
||||
// we might create spies on the event handlers when testing,
|
||||
// avoid reusing `fn` variable to fail the spies or stubs
|
||||
return instance[method].bind(instance)(payload);
|
||||
},
|
||||
opts: {
|
||||
name: signature,
|
||||
...opts,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return handlers;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# Snapshot report for `src/base/graphql/__tests__/pagination.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `pagination.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should return encode pageInfo
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
edges: [
|
||||
{
|
||||
cursor: 'MTE=',
|
||||
node: {
|
||||
id: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MTI=',
|
||||
node: {
|
||||
id: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MTM=',
|
||||
node: {
|
||||
id: 13,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MTQ=',
|
||||
node: {
|
||||
id: 14,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MTU=',
|
||||
node: {
|
||||
id: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MTY=',
|
||||
node: {
|
||||
id: 16,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MTc=',
|
||||
node: {
|
||||
id: 17,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MTg=',
|
||||
node: {
|
||||
id: 18,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MTk=',
|
||||
node: {
|
||||
id: 19,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'MjA=',
|
||||
node: {
|
||||
id: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: 'MjA=',
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
startCursor: 'MTE=',
|
||||
},
|
||||
totalCount: 105,
|
||||
}
|
||||
|
||||
## should return encode pageInfo with custom cursor
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
edges: [
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 13,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 14,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 16,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 17,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 18,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 19,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: 'eyJpZCI6MjAsIm5hbWUiOiJ0ZXN0MiJ9',
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0=',
|
||||
},
|
||||
totalCount: 105,
|
||||
}
|
||||
|
||||
## should decode with json
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
id: 10,
|
||||
name: 'test',
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,155 @@
|
||||
import { Args, Field, ObjectType, Query, Resolver } from '@nestjs/graphql';
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { createTestingApp } from '../../../__tests__/utils';
|
||||
import { Public } from '../../../core/auth';
|
||||
import {
|
||||
decodeWithJson,
|
||||
paginate,
|
||||
Paginated,
|
||||
paginateWithCustomCursor,
|
||||
PaginationInput,
|
||||
} from '../pagination';
|
||||
|
||||
const TOTAL_COUNT = 105;
|
||||
const ITEMS = Array.from({ length: TOTAL_COUNT }, (_, i) => ({ id: i + 1 }));
|
||||
const paginationStub = Sinon.stub().callsFake(input => {
|
||||
const start = input.offset + (input.after ? parseInt(input.after) : 0);
|
||||
return paginate(
|
||||
ITEMS.slice(start, start + input.first),
|
||||
'id',
|
||||
input,
|
||||
TOTAL_COUNT
|
||||
);
|
||||
});
|
||||
|
||||
const query = `query pagination($input: PaginationInput) {
|
||||
pagination(paginationInput: $input) {
|
||||
totalCount
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
@ObjectType()
|
||||
class TestType {
|
||||
@Field()
|
||||
id!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class PaginatedTestType extends Paginated(TestType) {}
|
||||
|
||||
@Public()
|
||||
@Resolver(() => TestType)
|
||||
class TestResolver {
|
||||
@Query(() => PaginatedTestType)
|
||||
async pagination(
|
||||
@Args(
|
||||
'paginationInput',
|
||||
{
|
||||
type: () => PaginationInput,
|
||||
defaultValue: { first: 10, offset: 0 },
|
||||
},
|
||||
PaginationInput.decode
|
||||
)
|
||||
input: PaginationInput
|
||||
) {
|
||||
return paginationStub(input);
|
||||
}
|
||||
}
|
||||
|
||||
const app = await createTestingApp({
|
||||
providers: [TestResolver],
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('should decode pagination input', async t => {
|
||||
await app.gql(query, {
|
||||
input: {
|
||||
first: 5,
|
||||
offset: 0,
|
||||
after: Buffer.from('4').toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
t.true(
|
||||
paginationStub.calledOnceWithExactly({
|
||||
first: 5,
|
||||
offset: 0,
|
||||
after: '4',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject mixed pagination cursor and offset', async t => {
|
||||
const res = await app.POST('/graphql').send({
|
||||
query,
|
||||
variables: {
|
||||
input: {
|
||||
first: 5,
|
||||
offset: 1,
|
||||
after: Buffer.from('4').toString('base64'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(res.status, 200);
|
||||
t.truthy(res.body.errors?.length);
|
||||
t.is(
|
||||
res.body.errors[0].message,
|
||||
'pagination.after and pagination.offset cannot be used together'
|
||||
);
|
||||
t.is(res.body.errors[0].extensions.status, 400);
|
||||
t.is(res.body.errors[0].extensions.name, 'BAD_REQUEST');
|
||||
});
|
||||
|
||||
test('should return encode pageInfo', async t => {
|
||||
const result = paginate(
|
||||
ITEMS.slice(10, 20),
|
||||
'id',
|
||||
{
|
||||
first: 10,
|
||||
offset: 0,
|
||||
after: '9',
|
||||
},
|
||||
TOTAL_COUNT
|
||||
);
|
||||
|
||||
t.snapshot(result);
|
||||
});
|
||||
|
||||
test('should return encode pageInfo with custom cursor', async t => {
|
||||
const result = paginateWithCustomCursor(
|
||||
ITEMS.slice(10, 20),
|
||||
TOTAL_COUNT,
|
||||
{ id: 10, name: 'test' },
|
||||
{ id: 20, name: 'test2' }
|
||||
);
|
||||
|
||||
t.snapshot(result);
|
||||
});
|
||||
|
||||
test('should decode with json', async t => {
|
||||
const result = decodeWithJson<{ id: number; name: string }>(
|
||||
'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0='
|
||||
);
|
||||
t.snapshot(result);
|
||||
|
||||
const result2 = decodeWithJson<{ id: number; name: string }>('');
|
||||
t.is(result2, null);
|
||||
});
|
||||
22
packages/backend/server/src/base/graphql/config.ts
Normal file
22
packages/backend/server/src/base/graphql/config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
|
||||
import { defineModuleConfig } from '../config';
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
graphql: {
|
||||
apolloDriverConfig: ConfigItem<ApolloDriverConfig>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('graphql', {
|
||||
apolloDriverConfig: {
|
||||
desc: 'The config for underlying nestjs GraphQL and apollo driver engine.',
|
||||
default: {
|
||||
// @TODO(@forehalo): need a flag to tell user `Restart Required` configs
|
||||
introspection: true,
|
||||
},
|
||||
link: 'https://docs.nestjs.com/graphql/quick-start',
|
||||
},
|
||||
});
|
||||
77
packages/backend/server/src/base/graphql/index.ts
Normal file
77
packages/backend/server/src/base/graphql/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import './config';
|
||||
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { ApolloDriver } from '@nestjs/apollo';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { NodeEnv } from '../../env';
|
||||
import { Config } from '../config';
|
||||
import { mapAnyError } from '../nestjs/exception';
|
||||
import { GQLLoggerPlugin } from './logger-plugin';
|
||||
|
||||
export type GraphqlContext = {
|
||||
req: Request;
|
||||
res: Response;
|
||||
isAdminQuery: boolean;
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
useFactory: (config: Config) => {
|
||||
return {
|
||||
...config.graphql.apolloDriverConfig,
|
||||
buildSchemaOptions: {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
useGlobalPrefix: true,
|
||||
graphiql: env.NODE_ENV === NodeEnv.Development,
|
||||
sortSchema: true,
|
||||
autoSchemaFile: join(
|
||||
env.projectRoot,
|
||||
env.testing
|
||||
? './node_modules/.cache/schema.gql'
|
||||
: './src/schema.gql'
|
||||
),
|
||||
path: '/graphql',
|
||||
csrfPrevention: {
|
||||
requestHeaders: ['content-type'],
|
||||
},
|
||||
context: ({
|
||||
req,
|
||||
res,
|
||||
}: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
}): GraphqlContext => ({
|
||||
req,
|
||||
res,
|
||||
isAdminQuery: false,
|
||||
}),
|
||||
plugins: [new GQLLoggerPlugin()],
|
||||
formatError: (formattedError, error) => {
|
||||
let ufe = mapAnyError(error);
|
||||
|
||||
// @ts-expect-error allow assign
|
||||
formattedError.extensions = ufe.toJSON();
|
||||
if (env.namespaces.canary) {
|
||||
formattedError.extensions.stacktrace = ufe.stacktrace;
|
||||
}
|
||||
return formattedError;
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [Config],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class GqlModule {}
|
||||
|
||||
export * from './pagination';
|
||||
export { registerObjectType } from './register';
|
||||
75
packages/backend/server/src/base/graphql/logger-plugin.ts
Normal file
75
packages/backend/server/src/base/graphql/logger-plugin.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
ApolloServerPlugin,
|
||||
GraphQLRequestContext,
|
||||
GraphQLRequestListener,
|
||||
} from '@apollo/server';
|
||||
import { Plugin } from '@nestjs/apollo';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { metrics } from '../metrics/metrics';
|
||||
import { mapAnyError } from '../nestjs';
|
||||
|
||||
export interface RequestContext {
|
||||
req: Express.Request & {
|
||||
res: Express.Response;
|
||||
};
|
||||
}
|
||||
|
||||
@Plugin()
|
||||
export class GQLLoggerPlugin implements ApolloServerPlugin {
|
||||
private readonly logger = new Logger(GQLLoggerPlugin.name);
|
||||
|
||||
requestDidStart(
|
||||
ctx: GraphQLRequestContext<RequestContext>
|
||||
): Promise<GraphQLRequestListener<GraphQLRequestContext<RequestContext>>> {
|
||||
const res = ctx.contextValue.req.res as Response;
|
||||
const headers = ctx.request.http?.headers;
|
||||
|
||||
const info = {
|
||||
operation: ctx.request.operationName ?? headers?.get('x-operation-name'),
|
||||
clientVersion: headers?.get('x-affine-version'),
|
||||
};
|
||||
|
||||
if (!info.operation) {
|
||||
this.logger.warn(
|
||||
`GraphQL operation name is not provided (${JSON.stringify({
|
||||
userAgent: headers?.get('user-agent'),
|
||||
clientVersion: info.clientVersion,
|
||||
rayId: headers?.get('cf-ray'),
|
||||
country: headers?.get('cf-ipcountry'),
|
||||
})})`
|
||||
);
|
||||
}
|
||||
|
||||
metrics.gql.counter('query_counter').add(1, info);
|
||||
const start = Date.now();
|
||||
function endTimer() {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
willSendResponse: () => {
|
||||
const time = endTimer();
|
||||
res.setHeader('Server-Timing', `gql;dur=${time};desc="GraphQL"`);
|
||||
metrics.gql.histogram('query_duration').record(time, info);
|
||||
return Promise.resolve();
|
||||
},
|
||||
didEncounterErrors: ctx => {
|
||||
ctx.errors.forEach(gqlErr => {
|
||||
const error = mapAnyError(gqlErr);
|
||||
error.log('GraphQL');
|
||||
|
||||
metrics.gql.counter('query_error_counter').add(1, {
|
||||
...info,
|
||||
code: error.status,
|
||||
type: error.type,
|
||||
error: error.name,
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
190
packages/backend/server/src/base/graphql/pagination.ts
Normal file
190
packages/backend/server/src/base/graphql/pagination.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { PipeTransform, Type } from '@nestjs/common';
|
||||
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BadRequest } from '../error';
|
||||
|
||||
@InputType()
|
||||
export class PaginationInput {
|
||||
/**
|
||||
* Because there is no resolver for GraphQL's InputTypes, we can't automatically decode the cursor input from base64 values.
|
||||
* Use this helper as `PipeTransform` to transform input args
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* paginate(@Args('input', PaginationInput.decode) PaginationInput) {}
|
||||
*/
|
||||
static decode: PipeTransform<PaginationInput, PaginationInput> = {
|
||||
transform: value => {
|
||||
const input = {
|
||||
...value,
|
||||
first: Math.min(Math.max(value?.first ?? 10, 1), 100),
|
||||
offset: Math.max(value?.offset ?? 0, 0),
|
||||
after: decode(value?.after),
|
||||
// before: decode(value.before),
|
||||
};
|
||||
assertPaginationInput(input);
|
||||
return input;
|
||||
},
|
||||
};
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'returns the first n elements from the list.',
|
||||
defaultValue: 10,
|
||||
})
|
||||
first!: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'ignore the first n elements from the list.',
|
||||
defaultValue: 0,
|
||||
})
|
||||
offset!: number;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description:
|
||||
'returns the elements in the list that come after the specified cursor.',
|
||||
})
|
||||
after?: string | null;
|
||||
|
||||
// NOT IMPLEMENTED YET
|
||||
// @Field(() => String, {
|
||||
// nullable: true,
|
||||
// description:
|
||||
// 'returns the elements in the list that come before the specified cursor.',
|
||||
// })
|
||||
// before?: string | null;
|
||||
}
|
||||
|
||||
export function assertPaginationInput(paginationInput?: PaginationInput) {
|
||||
if (!paginationInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (paginationInput.after && paginationInput.offset > 0) {
|
||||
throw new BadRequest(
|
||||
'pagination.after and pagination.offset cannot be used together'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const encode = (input: unknown) => {
|
||||
let inputStr: string;
|
||||
if (input instanceof Date) {
|
||||
inputStr = input.toISOString();
|
||||
} else if (typeof input === 'string') {
|
||||
inputStr = input;
|
||||
} else {
|
||||
inputStr = String(input);
|
||||
}
|
||||
return Buffer.from(inputStr).toString('base64');
|
||||
};
|
||||
const decode = (base64String?: string | null) =>
|
||||
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
|
||||
|
||||
export function encodeWithJson(input: unknown) {
|
||||
return encode(JSON.stringify(input ?? null));
|
||||
}
|
||||
|
||||
export function decodeWithJson<T>(base64String?: string | null): T | null {
|
||||
const str = decode(base64String);
|
||||
return str ? (JSON.parse(str) as T) : null;
|
||||
}
|
||||
|
||||
export function paginate<T>(
|
||||
list: T[],
|
||||
cursorField: keyof T,
|
||||
paginationInput: PaginationInput,
|
||||
total: number
|
||||
): PaginatedType<T> {
|
||||
const edges = list.map(item => ({
|
||||
node: item,
|
||||
cursor: encode(item[cursorField]),
|
||||
}));
|
||||
|
||||
return {
|
||||
totalCount: total,
|
||||
edges,
|
||||
pageInfo: {
|
||||
hasNextPage: edges.length >= paginationInput.first,
|
||||
hasPreviousPage: !!paginationInput.after || paginationInput.offset > 0,
|
||||
endCursor: edges.length ? edges[edges.length - 1].cursor : null,
|
||||
startCursor: edges.length ? edges[0].cursor : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function paginateWithCustomCursor<T>(
|
||||
list: T[],
|
||||
total: number,
|
||||
startCursor: unknown,
|
||||
endCursor: unknown,
|
||||
hasPreviousPage = false
|
||||
): PaginatedType<T> {
|
||||
const edges = list.map(item => ({
|
||||
node: item,
|
||||
// set cursor to empty string for ignore it
|
||||
cursor: '',
|
||||
}));
|
||||
|
||||
return {
|
||||
totalCount: total,
|
||||
edges,
|
||||
pageInfo: {
|
||||
hasNextPage: list.length > 0,
|
||||
hasPreviousPage,
|
||||
endCursor: encodeWithJson(endCursor),
|
||||
startCursor: encodeWithJson(startCursor),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginatedType<T> {
|
||||
totalCount: number;
|
||||
edges: {
|
||||
cursor: string;
|
||||
node: T;
|
||||
}[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class PageInfo {
|
||||
@Field(() => String, { nullable: true })
|
||||
startCursor?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
endCursor?: string | null;
|
||||
|
||||
@Field()
|
||||
hasNextPage!: boolean;
|
||||
|
||||
@Field()
|
||||
hasPreviousPage!: boolean;
|
||||
}
|
||||
|
||||
export function Paginated<T>(classRef: Type<T>) {
|
||||
@ObjectType(`${classRef.name}Edge`)
|
||||
abstract class EdgeType {
|
||||
@Field(() => String)
|
||||
cursor!: string;
|
||||
|
||||
@Field(() => classRef)
|
||||
node!: T;
|
||||
}
|
||||
|
||||
@ObjectType({ isAbstract: true })
|
||||
abstract class PaginatedType {
|
||||
@Field(() => Int)
|
||||
totalCount!: number;
|
||||
|
||||
@Field(() => [EdgeType])
|
||||
edges!: EdgeType[];
|
||||
|
||||
@Field(() => PageInfo)
|
||||
pageInfo!: PageInfo;
|
||||
}
|
||||
|
||||
return PaginatedType;
|
||||
}
|
||||
25
packages/backend/server/src/base/graphql/register.ts
Normal file
25
packages/backend/server/src/base/graphql/register.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Field, FieldOptions, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { ApplyType } from '../utils/types';
|
||||
|
||||
export function registerObjectType<T>(
|
||||
fields: Record<
|
||||
string,
|
||||
{
|
||||
type: () => any;
|
||||
options?: FieldOptions;
|
||||
}
|
||||
>,
|
||||
options: {
|
||||
name: string;
|
||||
}
|
||||
) {
|
||||
const Inner = ApplyType<T>();
|
||||
for (const [key, { type, options }] of Object.entries(fields)) {
|
||||
Field(type, options)(Inner.prototype, key);
|
||||
}
|
||||
|
||||
ObjectType(options.name)(Inner);
|
||||
|
||||
return Inner;
|
||||
}
|
||||
55
packages/backend/server/src/base/guard/guard.ts
Normal file
55
packages/backend/server/src/base/guard/guard.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
applyDecorators,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import { GUARD_PROVIDER, NamedGuards } from './provider';
|
||||
|
||||
const BasicGuardSymbol = Symbol('BasicGuard');
|
||||
|
||||
@Injectable()
|
||||
export class BasicGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
// get registered guard name
|
||||
const providerName = this.reflector.get<string[]>(
|
||||
BasicGuardSymbol,
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
if (Array.isArray(providerName) && providerName.length > 0) {
|
||||
for (const name of providerName) {
|
||||
const provider = GUARD_PROVIDER[name as NamedGuards];
|
||||
if (provider) {
|
||||
const ret = await provider.canActivate(context);
|
||||
if (!ret) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This guard is used to protect routes/queries/mutations that use a registered guard
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* \@UseNamedGuard('captcha') // use captcha guard
|
||||
* \@Auth()
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user: CurrentUser) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const UseNamedGuard = (...name: NamedGuards[]) =>
|
||||
applyDecorators(UseGuards(BasicGuard), SetMetadata(BasicGuardSymbol, name));
|
||||
2
packages/backend/server/src/base/guard/index.ts
Normal file
2
packages/backend/server/src/base/guard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UseNamedGuard } from './guard';
|
||||
export { GuardProvider, type RegisterGuardName } from './provider';
|
||||
26
packages/backend/server/src/base/guard/provider.ts
Normal file
26
packages/backend/server/src/base/guard/provider.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
|
||||
export interface RegisterGuardName {}
|
||||
|
||||
export type NamedGuards = keyof RegisterGuardName;
|
||||
|
||||
export const GUARD_PROVIDER: Partial<Record<NamedGuards, GuardProvider>> = {};
|
||||
|
||||
@Injectable()
|
||||
export abstract class GuardProvider implements OnModuleInit, CanActivate {
|
||||
private readonly logger = new Logger(GuardProvider.name);
|
||||
abstract name: NamedGuards;
|
||||
|
||||
onModuleInit() {
|
||||
GUARD_PROVIDER[this.name] = this;
|
||||
this.logger.log(`Guard provider [${this.name}] registered`);
|
||||
}
|
||||
|
||||
abstract canActivate(context: ExecutionContext): boolean | Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { CryptoHelper } from '../crypto';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
crypto: CryptoHelper;
|
||||
}>;
|
||||
|
||||
function generateTestPrivateKey(): string {
|
||||
const { privateKey } = generateKeyPairSync('ec', {
|
||||
namedCurve: 'prime256v1',
|
||||
});
|
||||
return privateKey
|
||||
.export({
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
})
|
||||
.toString();
|
||||
}
|
||||
|
||||
const privateKey = generateTestPrivateKey();
|
||||
const privateKey2 = generateTestPrivateKey();
|
||||
|
||||
test.beforeEach(async t => {
|
||||
t.context.crypto = new CryptoHelper({
|
||||
crypto: {
|
||||
privateKey,
|
||||
},
|
||||
} as any);
|
||||
t.context.crypto.onConfigInit();
|
||||
});
|
||||
|
||||
test('should be able to sign and verify', t => {
|
||||
const data = 'hello world';
|
||||
const signature = t.context.crypto.sign(data);
|
||||
t.true(t.context.crypto.verify(signature));
|
||||
t.false(t.context.crypto.verify('fake-signature'));
|
||||
t.false(t.context.crypto.verify(`${data},fake-signature`));
|
||||
});
|
||||
|
||||
test('should verify signatures across key rotation', t => {
|
||||
const data = 'hello world';
|
||||
const signatureV1 = t.context.crypto.sign(data);
|
||||
t.true(t.context.crypto.verify(signatureV1));
|
||||
|
||||
(t.context.crypto as any).config.crypto.privateKey = privateKey2;
|
||||
t.context.crypto.onConfigChanged({
|
||||
updates: { crypto: { privateKey: privateKey2 } },
|
||||
} as any);
|
||||
|
||||
const signatureV2 = t.context.crypto.sign(data);
|
||||
t.true(t.context.crypto.verify(signatureV1));
|
||||
t.true(t.context.crypto.verify(signatureV2));
|
||||
});
|
||||
|
||||
test('should same data should get different signature', t => {
|
||||
const data = 'hello world';
|
||||
const signature = t.context.crypto.sign(data);
|
||||
const signature2 = t.context.crypto.sign(data);
|
||||
t.not(signature2, signature);
|
||||
t.true(t.context.crypto.verify(signature));
|
||||
t.true(t.context.crypto.verify(signature2));
|
||||
});
|
||||
|
||||
test('should be able to encrypt and decrypt', t => {
|
||||
const data = 'top secret';
|
||||
const stub = Sinon.stub(t.context.crypto, 'randomBytes').returns(
|
||||
Buffer.alloc(12, 0)
|
||||
);
|
||||
|
||||
const encrypted = t.context.crypto.encrypt(data);
|
||||
const encrypted2 = t.context.crypto.encrypt(data);
|
||||
const decrypted = t.context.crypto.decrypt(encrypted);
|
||||
|
||||
// we are using a stub to make sure the iv is always 0,
|
||||
// the encrypted result will always be the same for the same key+data
|
||||
t.is(encrypted2, encrypted);
|
||||
t.is(decrypted, data);
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('should be able to get random bytes', t => {
|
||||
const bytes = t.context.crypto.randomBytes();
|
||||
t.is(bytes.length, 12);
|
||||
const bytes2 = t.context.crypto.randomBytes();
|
||||
|
||||
t.notDeepEqual(bytes, bytes2);
|
||||
});
|
||||
|
||||
test('should be able to digest', t => {
|
||||
const data = 'hello world';
|
||||
const hash = t.context.crypto.sha256(data).toString('base64');
|
||||
t.is(hash, 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=');
|
||||
});
|
||||
|
||||
test('uses the native verifier public key', t => {
|
||||
const deployment = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates deployment mode before the lifecycle hook
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
const loadKey = Sinon.stub(
|
||||
t.context.crypto as unknown as {
|
||||
loadAFFiNEProPublicKey(): Buffer | null;
|
||||
},
|
||||
'loadAFFiNEProPublicKey'
|
||||
);
|
||||
loadKey.onFirstCall().returns(null);
|
||||
loadKey.onSecondCall().returns(Buffer.from('public-key'));
|
||||
try {
|
||||
t.throws(() => t.context.crypto.onModuleInit(), {
|
||||
message:
|
||||
'AFFINE_PRO_PUBLIC_KEY must be embedded in self-hosted server-native builds.',
|
||||
});
|
||||
t.context.crypto.onModuleInit();
|
||||
t.is(t.context.crypto.AFFiNEProPublicKey?.toString(), 'public-key');
|
||||
} finally {
|
||||
// @ts-expect-error test restores deployment mode after the lifecycle hook
|
||||
globalThis.env.DEPLOYMENT_TYPE = deployment;
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to safe compare', t => {
|
||||
t.true(t.context.crypto.compare('abc', 'abc'));
|
||||
t.false(t.context.crypto.compare('abc', 'def'));
|
||||
});
|
||||
|
||||
test('should be able to hash password for native verification', async t => {
|
||||
const password = 'mySecurePassword';
|
||||
const hash = await t.context.crypto.encryptPassword(password);
|
||||
t.true(hash.startsWith('$argon2id$'));
|
||||
t.not(hash, password);
|
||||
});
|
||||
222
packages/backend/server/src/base/helpers/__tests__/url.spec.ts
Normal file
222
packages/backend/server/src/base/helpers/__tests__/url.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { buildCorsAllowedOrigins, isCorsOriginAllowed } from '../../cors';
|
||||
import { ActionForbidden } from '../../error';
|
||||
import { URLHelper } from '../url';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
url: URLHelper;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
t.context.url = new URLHelper({
|
||||
server: {
|
||||
externalUrl: '',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '',
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
test('can factor base url correctly without specified external url', t => {
|
||||
t.is(t.context.url.baseUrl, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can factor base url correctly with specified external url', t => {
|
||||
const url = new URLHelper({
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '/ignored',
|
||||
},
|
||||
} as any);
|
||||
|
||||
t.is(url.baseUrl, 'https://external.domain.com');
|
||||
});
|
||||
|
||||
test('can factor base url correctly with specified external url and path', t => {
|
||||
const url = new URLHelper({
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com/anything',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '/ignored',
|
||||
},
|
||||
} as any);
|
||||
|
||||
t.is(url.baseUrl, 'https://external.domain.com/anything');
|
||||
});
|
||||
|
||||
test('can factor base url correctly with specified external url with port', t => {
|
||||
const url = new URLHelper({
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com:123',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
t.is(url.baseUrl, 'https://external.domain.com:123');
|
||||
});
|
||||
|
||||
test('can stringify query', t => {
|
||||
t.is(t.context.url.stringify({ a: 1, b: 2 }), 'a=1&b=2');
|
||||
t.is(t.context.url.stringify({ a: 1, b: '/path' }), 'a=1&b=%2Fpath');
|
||||
});
|
||||
|
||||
test('can create link', t => {
|
||||
t.is(t.context.url.link('/path'), 'https://app.affine.local/path');
|
||||
t.is(
|
||||
t.context.url.link('/path', { a: 1, b: 2 }),
|
||||
'https://app.affine.local/path?a=1&b=2'
|
||||
);
|
||||
t.is(
|
||||
t.context.url.link('/path', { a: 1, b: '/path' }),
|
||||
'https://app.affine.local/path?a=1&b=%2Fpath'
|
||||
);
|
||||
});
|
||||
|
||||
test('can create safe link', t => {
|
||||
t.is(
|
||||
t.context.url.safeLink('/path?existing=1&token=old', {
|
||||
redirect_uri: '/next?a=1',
|
||||
token: 'a b',
|
||||
}),
|
||||
'https://app.affine.local/path?existing=1&redirect_uri=%2Fnext%3Fa%3D1&token=a+b'
|
||||
);
|
||||
t.is(t.context.url.safeLink('/%5Cevil'), 'https://app.affine.local/%5Cevil');
|
||||
for (const input of [
|
||||
'/\\\\evil.example/path',
|
||||
'\\\\evil.example/path',
|
||||
'https://user@app.affine.local/path',
|
||||
'javascript:alert(1)',
|
||||
'https://evil.example/path',
|
||||
]) {
|
||||
t.throws(() => t.context.url.safeLink(input), {
|
||||
instanceOf: ActionForbidden,
|
||||
});
|
||||
}
|
||||
t.is(
|
||||
t.context.url.canonicalRedirectUri('https://github.com/path?existing=1', {
|
||||
error: 'a b',
|
||||
}),
|
||||
'https://github.com/path?existing=1&error=a+b'
|
||||
);
|
||||
});
|
||||
|
||||
test('can canonicalize redirect_uri', t => {
|
||||
for (const [input, expected] of [
|
||||
['/redirect-proxy', 'https://app.affine.local/redirect-proxy'],
|
||||
['https://github.com', 'https://github.com/'],
|
||||
['https://sub.github.com/path', 'https://sub.github.com/path'],
|
||||
['https://github.com.:8443/path', 'https://github.com.:8443/path'],
|
||||
]) {
|
||||
t.is(t.context.url.canonicalRedirectUri(input), expected);
|
||||
}
|
||||
for (const input of [
|
||||
'/\\\\evil.example/path',
|
||||
'https://app.affine.local:444/path',
|
||||
'https://evilgithub.com',
|
||||
'https://github.com.evil.example',
|
||||
'https://user@github.com',
|
||||
'javascript:alert(1)',
|
||||
]) {
|
||||
t.throws(() => t.context.url.canonicalRedirectUri(input), {
|
||||
instanceOf: ActionForbidden,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('can safe redirect', t => {
|
||||
const res = {
|
||||
redirect: (to: string) => to,
|
||||
} as any;
|
||||
|
||||
const spy = Sinon.spy(res, 'redirect');
|
||||
function allow(to: string, canonical: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(canonical));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
function deny(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(t.context.url.baseUrl));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
allow('https://app.affine.local', 'https://app.affine.local/');
|
||||
allow('/path?query=1', 'https://app.affine.local/path?query=1');
|
||||
allow('/%5Cevil', 'https://app.affine.local/%5Cevil');
|
||||
[
|
||||
'https://other.domain.com',
|
||||
'a://invalid.uri',
|
||||
'/\\\\other.domain.com',
|
||||
].forEach(deny);
|
||||
|
||||
t.context.url.redirectAllowHosts = ['https://app.affine.local/base'];
|
||||
allow('/base/child', 'https://app.affine.local/base/child');
|
||||
['/base-sibling', '/other'].forEach(deny);
|
||||
});
|
||||
|
||||
test('can get request origin', t => {
|
||||
t.is(t.context.url.requestOrigin, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can get request base url', t => {
|
||||
t.is(t.context.url.requestBaseUrl, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can get request base url with multiple hosts', t => {
|
||||
// mock cls
|
||||
const cls = new Map<string, string>();
|
||||
const url = new URLHelper(
|
||||
{
|
||||
server: {
|
||||
externalUrl: '',
|
||||
host: 'app.affine.local1',
|
||||
hosts: ['app.affine.local1', 'app.affine.local2'],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '',
|
||||
},
|
||||
} as any,
|
||||
cls as any
|
||||
);
|
||||
|
||||
// no cls, use default origin
|
||||
t.is(url.requestOrigin, 'https://app.affine.local1');
|
||||
t.is(url.requestBaseUrl, 'https://app.affine.local1');
|
||||
|
||||
// set cls
|
||||
cls.set(CLS_REQUEST_HOST, 'app.affine.local2');
|
||||
t.is(url.requestOrigin, 'https://app.affine.local2');
|
||||
t.is(url.requestBaseUrl, 'https://app.affine.local2');
|
||||
});
|
||||
|
||||
test('should allow websocket secure origin by normalizing wss to https', t => {
|
||||
const allowedOrigins = buildCorsAllowedOrigins({
|
||||
allowedOrigins: ['https://app.affine.pro'],
|
||||
} as any);
|
||||
|
||||
t.true(isCorsOriginAllowed('wss://app.affine.pro', allowedOrigins));
|
||||
});
|
||||
|
||||
test('should allow desktop file origin', t => {
|
||||
const allowedOrigins = buildCorsAllowedOrigins({
|
||||
allowedOrigins: [],
|
||||
} as any);
|
||||
|
||||
t.true(isCorsOriginAllowed('file://', allowedOrigins));
|
||||
});
|
||||
18
packages/backend/server/src/base/helpers/config.ts
Normal file
18
packages/backend/server/src/base/helpers/config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineModuleConfig } from '../config';
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
crypto: {
|
||||
privateKey: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('crypto', {
|
||||
privateKey: {
|
||||
desc: 'The private key for used by the crypto module to create signed tokens or encrypt data.',
|
||||
env: 'AFFINE_PRIVATE_KEY',
|
||||
default: '',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
});
|
||||
245
packages/backend/server/src/base/helpers/crypto.ts
Normal file
245
packages/backend/server/src/base/helpers/crypto.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
createSign,
|
||||
createVerify,
|
||||
generateKeyPairSync,
|
||||
type KeyObject,
|
||||
randomBytes,
|
||||
randomInt,
|
||||
sign,
|
||||
timingSafeEqual,
|
||||
verify,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { hash as hashPassword } from '@node-rs/argon2';
|
||||
|
||||
import { AFFINE_PRO_PUBLIC_KEY } from '../../native';
|
||||
import { Config } from '../config';
|
||||
import { OnEvent } from '../event';
|
||||
|
||||
const NONCE_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 12;
|
||||
|
||||
function generatePrivateKey(): string {
|
||||
const { privateKey } = generateKeyPairSync('ec', {
|
||||
namedCurve: 'prime256v1',
|
||||
});
|
||||
|
||||
// Export EC private key as PKCS#8 PEM. This avoids OpenSSL 3.x decoder issues
|
||||
// in Node.js 22 when later deriving the public key via createPublicKey.
|
||||
const key = privateKey.export({
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
});
|
||||
|
||||
return key.toString('utf8');
|
||||
}
|
||||
|
||||
function parseKey(privateKey: string) {
|
||||
const keyBuf = Buffer.from(privateKey);
|
||||
let priv: KeyObject;
|
||||
try {
|
||||
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'pkcs8' });
|
||||
} catch {
|
||||
try {
|
||||
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'sec1' });
|
||||
} catch {
|
||||
// As a last resort rely on auto-detection
|
||||
priv = createPrivateKey(keyBuf);
|
||||
}
|
||||
}
|
||||
const pub = createPublicKey(priv);
|
||||
return { priv, pub };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CryptoHelper implements OnModuleInit {
|
||||
keyPair!: {
|
||||
publicKey: KeyObject;
|
||||
privateKey: KeyObject;
|
||||
sha256: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
};
|
||||
};
|
||||
|
||||
private previousPublicKeys: KeyObject[] = [];
|
||||
|
||||
AFFiNEProPublicKey: Buffer | null = null;
|
||||
|
||||
onModuleInit() {
|
||||
if (env.selfhosted) {
|
||||
this.AFFiNEProPublicKey = this.loadAFFiNEProPublicKey();
|
||||
if (!this.AFFiNEProPublicKey) {
|
||||
throw new Error(
|
||||
'AFFINE_PRO_PUBLIC_KEY must be embedded in self-hosted server-native builds.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@OnEvent('config.init')
|
||||
onConfigInit() {
|
||||
this.setup();
|
||||
}
|
||||
|
||||
@OnEvent('config.changed')
|
||||
onConfigChanged(event: Events['config.changed']) {
|
||||
if (event.updates.crypto?.privateKey) {
|
||||
this.setup();
|
||||
}
|
||||
}
|
||||
|
||||
private setup() {
|
||||
const prevPublicKey = this.keyPair?.publicKey;
|
||||
const privateKey = this.config.crypto.privateKey || generatePrivateKey();
|
||||
const { priv, pub } = parseKey(privateKey);
|
||||
const publicKey = pub
|
||||
.export({ format: 'pem', type: 'spki' })
|
||||
.toString('utf8');
|
||||
|
||||
if (prevPublicKey) {
|
||||
const prevPem = prevPublicKey
|
||||
.export({ format: 'pem', type: 'spki' })
|
||||
.toString('utf8');
|
||||
if (prevPem !== publicKey) {
|
||||
this.previousPublicKeys.unshift(prevPublicKey);
|
||||
this.previousPublicKeys = this.previousPublicKeys.slice(0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
this.keyPair = {
|
||||
publicKey: pub,
|
||||
privateKey: priv,
|
||||
sha256: {
|
||||
publicKey: this.sha256(publicKey),
|
||||
privateKey: this.sha256(privateKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private get keyType() {
|
||||
return (this.keyPair.privateKey.asymmetricKeyType as string) || 'ec';
|
||||
}
|
||||
|
||||
sign(data: string) {
|
||||
const input = Buffer.from(data, 'utf-8');
|
||||
if (this.keyType === 'ed25519') {
|
||||
// Ed25519 signs the message directly (no pre-hash)
|
||||
const sig = sign(null, input, this.keyPair.privateKey);
|
||||
return `${data},${sig.toString('base64')}`;
|
||||
} else {
|
||||
// ECDSA with SHA-256 for EC keys
|
||||
const sign = createSign('sha256');
|
||||
sign.update(input);
|
||||
sign.end();
|
||||
return `${data},${sign.sign(this.keyPair.privateKey, 'base64')}`;
|
||||
}
|
||||
}
|
||||
|
||||
verify(signatureWithData: string) {
|
||||
const [data, signature] = signatureWithData.split(',');
|
||||
if (!signature) {
|
||||
return false;
|
||||
}
|
||||
const input = Buffer.from(data, 'utf-8');
|
||||
const sigBuf = Buffer.from(signature, 'base64');
|
||||
|
||||
const keys = [this.keyPair.publicKey, ...this.previousPublicKeys];
|
||||
return keys.some(publicKey => {
|
||||
const keyType = (publicKey.asymmetricKeyType as string) || 'ec';
|
||||
if (keyType === 'ed25519') {
|
||||
// Ed25519 verifies the message directly
|
||||
return verify(null, input, publicKey, sigBuf);
|
||||
} else {
|
||||
// ECDSA with SHA-256
|
||||
const verifier = createVerify('sha256');
|
||||
verifier.update(input);
|
||||
verifier.end();
|
||||
return verifier.verify(publicKey, sigBuf);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
encrypt(data: string) {
|
||||
const iv = this.randomBytes();
|
||||
const cipher = createCipheriv(
|
||||
'aes-256-gcm',
|
||||
this.keyPair.sha256.privateKey,
|
||||
iv,
|
||||
{
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
}
|
||||
);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(data, 'utf-8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
decrypt(encrypted: string) {
|
||||
const buf = Buffer.from(encrypted, 'base64');
|
||||
const iv = buf.subarray(0, NONCE_LENGTH);
|
||||
const authTag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const encryptedToken = buf.subarray(NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.keyPair.sha256.privateKey,
|
||||
iv,
|
||||
{ authTagLength: AUTH_TAG_LENGTH }
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrepted = decipher.update(encryptedToken, void 0, 'utf8');
|
||||
return decrepted + decipher.final('utf8');
|
||||
}
|
||||
|
||||
encryptPassword(password: string) {
|
||||
return hashPassword(password);
|
||||
}
|
||||
|
||||
compare(lhs: string, rhs: string) {
|
||||
if (lhs.length !== rhs.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(Buffer.from(lhs), Buffer.from(rhs));
|
||||
}
|
||||
|
||||
randomBytes(length = NONCE_LENGTH) {
|
||||
return randomBytes(length);
|
||||
}
|
||||
|
||||
randomInt(min: number, max: number) {
|
||||
return randomInt(min, max);
|
||||
}
|
||||
|
||||
otp(length = 6) {
|
||||
let otp = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
otp += this.randomInt(0, 10).toString();
|
||||
}
|
||||
|
||||
return otp;
|
||||
}
|
||||
|
||||
sha256(data: string) {
|
||||
return createHash('sha256').update(data).digest();
|
||||
}
|
||||
|
||||
private loadAFFiNEProPublicKey() {
|
||||
if (AFFINE_PRO_PUBLIC_KEY) {
|
||||
return Buffer.from(AFFINE_PRO_PUBLIC_KEY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
15
packages/backend/server/src/base/helpers/index.ts
Normal file
15
packages/backend/server/src/base/helpers/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import './config';
|
||||
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { CryptoHelper } from './crypto';
|
||||
import { URLHelper } from './url';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [URLHelper, CryptoHelper],
|
||||
exports: [URLHelper, CryptoHelper],
|
||||
})
|
||||
export class HelpersModule {}
|
||||
|
||||
export { CryptoHelper, URLHelper };
|
||||
189
packages/backend/server/src/base/helpers/url.ts
Normal file
189
packages/backend/server/src/base/helpers/url.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
import {
|
||||
buildSafeCallbackUrl,
|
||||
evaluateLocalRedirect,
|
||||
evaluateRedirectUri,
|
||||
} from '../../native';
|
||||
import { Config } from '../config';
|
||||
import { ActionForbidden } from '../error';
|
||||
import { OnEvent } from '../event';
|
||||
|
||||
// Keep in sync with frontend /redirect-proxy allowlist.
|
||||
const TRUSTED_REDIRECT_DOMAINS = [
|
||||
'google.com',
|
||||
'stripe.com',
|
||||
'github.com',
|
||||
'twitter.com',
|
||||
'discord.gg',
|
||||
'youtube.com',
|
||||
't.me',
|
||||
'reddit.com',
|
||||
'affine.pro',
|
||||
].map(d => d.toLowerCase());
|
||||
|
||||
@Injectable()
|
||||
export class URLHelper {
|
||||
redirectAllowHosts!: string[];
|
||||
|
||||
origin!: string;
|
||||
allowedOrigins!: string[];
|
||||
baseUrl!: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly cls?: ClsService
|
||||
) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
@OnEvent('config.changed')
|
||||
@OnEvent('config.init')
|
||||
init() {
|
||||
if (this.config.server.externalUrl) {
|
||||
if (!this.verify(this.config.server.externalUrl)) {
|
||||
throw new Error(
|
||||
'Invalid `server.externalUrl` configured. It must be a valid url.'
|
||||
);
|
||||
}
|
||||
|
||||
const externalUrl = new URL(this.config.server.externalUrl);
|
||||
|
||||
this.origin = externalUrl.origin;
|
||||
this.baseUrl =
|
||||
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
|
||||
} else {
|
||||
this.origin = this.convertHostToOrigin(this.config.server.host);
|
||||
this.baseUrl = this.origin + this.config.server.path;
|
||||
}
|
||||
|
||||
this.redirectAllowHosts = [this.baseUrl];
|
||||
|
||||
this.allowedOrigins = [this.origin];
|
||||
if (this.config.server.hosts.length > 0) {
|
||||
for (const host of this.config.server.hosts) {
|
||||
this.allowedOrigins.push(this.convertHostToOrigin(host));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get requestOrigin() {
|
||||
if (this.config.server.hosts.length === 0) {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
// support multiple hosts
|
||||
const requestHost = this.cls?.get<string | undefined>(CLS_REQUEST_HOST);
|
||||
if (!requestHost || !this.config.server.hosts.includes(requestHost)) {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
return this.convertHostToOrigin(requestHost);
|
||||
}
|
||||
|
||||
get requestBaseUrl() {
|
||||
if (this.config.server.hosts.length === 0) {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
return this.requestOrigin + this.config.server.path;
|
||||
}
|
||||
|
||||
stringify(query: Record<string, any>) {
|
||||
return new URLSearchParams(query).toString();
|
||||
}
|
||||
|
||||
url(path: string, query: Record<string, any> = {}) {
|
||||
const url = new URL(path, this.requestOrigin);
|
||||
|
||||
for (const key in query) {
|
||||
url.searchParams.set(key, query[key]);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
link(path: string, query: Record<string, any> = {}) {
|
||||
return this.url(path, query).toString();
|
||||
}
|
||||
|
||||
safeLink(path: string, query: Record<string, any> = {}) {
|
||||
try {
|
||||
return buildSafeCallbackUrl(
|
||||
path,
|
||||
this.requestOrigin,
|
||||
this.allowedOrigins,
|
||||
Object.entries(query).map(([name, value]) => ({
|
||||
name,
|
||||
value: String(value),
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
safeRedirect(res: Response, to: string) {
|
||||
try {
|
||||
const canonical = evaluateLocalRedirect(
|
||||
to,
|
||||
this.requestBaseUrl,
|
||||
this.redirectAllowHosts
|
||||
);
|
||||
return res.redirect(canonical);
|
||||
} catch {
|
||||
return res.redirect(this.baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
canonicalRedirectUri(redirectUri: string, query: Record<string, any> = {}) {
|
||||
try {
|
||||
return evaluateRedirectUri(
|
||||
redirectUri,
|
||||
this.requestOrigin,
|
||||
this.allowedOrigins,
|
||||
TRUSTED_REDIRECT_DOMAINS,
|
||||
Object.entries(query).map(([name, value]) => ({
|
||||
name,
|
||||
value: String(value),
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
redirectPolicy() {
|
||||
return {
|
||||
redirectBaseUrl: this.requestOrigin,
|
||||
redirectAllowedOrigins: this.allowedOrigins,
|
||||
redirectTrustedDomains: TRUSTED_REDIRECT_DOMAINS,
|
||||
};
|
||||
}
|
||||
|
||||
verify(url: string | URL) {
|
||||
try {
|
||||
if (typeof url === 'string') {
|
||||
url = new URL(url);
|
||||
}
|
||||
if (!['http:', 'https:'].includes(url.protocol)) return false;
|
||||
if (!url.hostname) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private convertHostToOrigin(host: string) {
|
||||
return [
|
||||
this.config.server.https ? 'https' : 'http',
|
||||
'://',
|
||||
host,
|
||||
host === 'localhost' || isIP(host) ? `:${this.config.server.port}` : '',
|
||||
].join('');
|
||||
}
|
||||
}
|
||||
36
packages/backend/server/src/base/index.ts
Normal file
36
packages/backend/server/src/base/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export {
|
||||
Cache,
|
||||
CacheInterceptor,
|
||||
isValidCacheTtl,
|
||||
MakeCache,
|
||||
PreventCache,
|
||||
SessionCache,
|
||||
} from './cache';
|
||||
export {
|
||||
Config,
|
||||
ConfigFactory,
|
||||
defineModuleConfig,
|
||||
defineNativeModuleConfig,
|
||||
type JSONSchema,
|
||||
type NativeAppConfigDescriptor,
|
||||
} from './config';
|
||||
export * from './cors';
|
||||
export * from './error';
|
||||
export { EventBus, OnEvent } from './event';
|
||||
export {
|
||||
paginate,
|
||||
Paginated,
|
||||
PaginationInput,
|
||||
registerObjectType,
|
||||
} from './graphql';
|
||||
export * from './guard';
|
||||
export { CryptoHelper, URLHelper } from './helpers';
|
||||
export { AFFiNELogger } from './logger';
|
||||
export { CallMetric, metrics } from './metrics';
|
||||
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
|
||||
export * from './nestjs';
|
||||
export { type PrismaTransaction } from './prisma';
|
||||
export * from './storage';
|
||||
export { type StorageProviderConfig } from './storage';
|
||||
export { CloudThrottlerGuard, SkipThrottle, Throttle } from './throttler';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { createTestingModule } from '../../../__tests__/utils';
|
||||
import { AFFiNELogger } from '../service';
|
||||
|
||||
export const test = ava as TestFn<{
|
||||
module: TestingModule;
|
||||
logger: AFFiNELogger;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const m = await createTestingModule({
|
||||
providers: [AFFiNELogger],
|
||||
});
|
||||
const logger = m.get(AFFiNELogger);
|
||||
t.context.module = m;
|
||||
t.context.logger = logger;
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should auto print error stack when stack argument is an error instance', t => {
|
||||
const logger = t.context.logger;
|
||||
const error = new Error('test error');
|
||||
// @ts-expect-error private method
|
||||
const fake = mock.method(logger, 'printMessages', () => {
|
||||
return;
|
||||
});
|
||||
logger.error('test message', error);
|
||||
t.is(fake.mock.callCount(), 1);
|
||||
const printStackTraceArgs: any = fake.mock.calls[0].arguments[0];
|
||||
t.is(printStackTraceArgs[0], 'test message');
|
||||
t.is(printStackTraceArgs[1], error.stack);
|
||||
});
|
||||
|
||||
test('should auto print error stack when stack argument is a string', t => {
|
||||
const logger = t.context.logger;
|
||||
const error = new Error('test error');
|
||||
// @ts-expect-error private method
|
||||
const fake = mock.method(logger, 'printMessages', () => {
|
||||
return;
|
||||
});
|
||||
logger.error('test message', error.stack);
|
||||
t.is(fake.mock.callCount(), 1);
|
||||
const printStackTraceArgs: any = fake.mock.calls[0].arguments[0];
|
||||
t.is(printStackTraceArgs[0], 'test message');
|
||||
t.is(printStackTraceArgs[1], error.stack);
|
||||
});
|
||||
|
||||
test('should print error stack with cause', t => {
|
||||
const logger = t.context.logger;
|
||||
const error = new Error('test error');
|
||||
error.cause = new Error('cause error');
|
||||
// @ts-expect-error private method
|
||||
const fake = mock.method(logger, 'printMessages', () => {
|
||||
return;
|
||||
});
|
||||
logger.error('test message', error);
|
||||
t.is(fake.mock.callCount(), 1);
|
||||
const printStackTraceArgs: any = fake.mock.calls[0].arguments[0];
|
||||
t.is(printStackTraceArgs[0], 'test message');
|
||||
t.is(
|
||||
printStackTraceArgs[1],
|
||||
`${error.stack}\n\nCaused by:\n\n${(error.cause as any).stack}`
|
||||
);
|
||||
});
|
||||
14
packages/backend/server/src/base/logger/index.ts
Normal file
14
packages/backend/server/src/base/logger/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { ConfigModule } from '../config';
|
||||
import { AFFiNELogger } from './service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [AFFiNELogger],
|
||||
exports: [AFFiNELogger],
|
||||
})
|
||||
export class LoggerModule {}
|
||||
|
||||
export { AFFiNELogger } from './service';
|
||||
58
packages/backend/server/src/base/logger/service.ts
Normal file
58
packages/backend/server/src/base/logger/service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ConsoleLogger, Injectable, type LogLevel } from '@nestjs/common';
|
||||
import { ClsServiceManager } from 'nestjs-cls';
|
||||
|
||||
import { UserFriendlyError } from '../error';
|
||||
|
||||
// DO NOT use this Logger directly
|
||||
// Use it via this way: `private readonly logger = new Logger(MyService.name)`
|
||||
@Injectable()
|
||||
export class AFFiNELogger extends ConsoleLogger {
|
||||
override stringifyMessage(message: unknown, logLevel: LogLevel) {
|
||||
const messageString = super.stringifyMessage(message, logLevel);
|
||||
const requestId = AFFiNELogger.getRequestId();
|
||||
if (!requestId) {
|
||||
return messageString;
|
||||
}
|
||||
return `<${requestId}> ${messageString}`;
|
||||
}
|
||||
|
||||
static getRequestId(): string | undefined {
|
||||
return ClsServiceManager.getClsService()?.getId();
|
||||
}
|
||||
|
||||
static formatStack(stackOrError?: Error | string | unknown) {
|
||||
if (stackOrError instanceof Error) {
|
||||
let err = stackOrError;
|
||||
|
||||
// most of the internal error are caught and created by `GlobalExceptionFilter`,
|
||||
// and their error stack is helpless
|
||||
if (err instanceof UserFriendlyError) {
|
||||
return err.stacktrace;
|
||||
}
|
||||
|
||||
let stack = err.stack ?? '';
|
||||
if (err.cause instanceof Error && err.cause.stack) {
|
||||
stack += `\n\nCaused by:\n\n${err.cause.stack}`;
|
||||
}
|
||||
return stack;
|
||||
}
|
||||
return stackOrError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nestjs ConsoleLogger.error() will not print the stack trace if the error is an instance of Error
|
||||
* This method is a workaround to print the stack trace
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* this.logger.error('some error happens', errInstance);
|
||||
* ```
|
||||
*/
|
||||
override error(
|
||||
message: any,
|
||||
stackOrError?: Error | string | unknown,
|
||||
context?: string
|
||||
) {
|
||||
super.error(message, AFFiNELogger.formatStack(stackOrError), context);
|
||||
}
|
||||
}
|
||||
16
packages/backend/server/src/base/metrics/config.ts
Normal file
16
packages/backend/server/src/base/metrics/config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineModuleConfig } from '../config';
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
metrics: {
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('metrics', {
|
||||
enabled: {
|
||||
desc: 'Enable metric and tracing collection',
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
19
packages/backend/server/src/base/metrics/index.ts
Normal file
19
packages/backend/server/src/base/metrics/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import './config';
|
||||
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
OpentelemetryOptionsFactory,
|
||||
OpentelemetryProvider,
|
||||
} from './opentelemetry';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [OpentelemetryOptionsFactory, OpentelemetryProvider],
|
||||
exports: [OpentelemetryOptionsFactory],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
|
||||
export * from './metrics';
|
||||
export * from './utils';
|
||||
export { OpentelemetryOptionsFactory };
|
||||
149
packages/backend/server/src/base/metrics/metrics.ts
Normal file
149
packages/backend/server/src/base/metrics/metrics.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Gauge,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
MetricOptions,
|
||||
metrics as otelMetrics,
|
||||
UpDownCounter,
|
||||
} from '@opentelemetry/api';
|
||||
import { HostMetrics } from '@opentelemetry/host-metrics';
|
||||
|
||||
function getMeterProvider() {
|
||||
return otelMetrics.getMeterProvider();
|
||||
}
|
||||
|
||||
export function registerCustomMetrics() {
|
||||
const hostMetricsMonitoring = new HostMetrics({
|
||||
name: 'instance-host-metrics',
|
||||
meterProvider: getMeterProvider() as MeterProvider,
|
||||
});
|
||||
hostMetricsMonitoring.start();
|
||||
}
|
||||
|
||||
export function getMeter(name = 'business') {
|
||||
return getMeterProvider().getMeter(name);
|
||||
}
|
||||
|
||||
type MetricType = 'counter' | 'gauge' | 'histogram';
|
||||
type Metric<T extends MetricType> = T extends 'counter'
|
||||
? UpDownCounter
|
||||
: T extends 'gauge'
|
||||
? Gauge
|
||||
: T extends 'histogram'
|
||||
? Histogram
|
||||
: never;
|
||||
|
||||
export type ScopedMetrics = {
|
||||
counter: (name: string, opts?: MetricOptions) => UpDownCounter;
|
||||
gauge: (name: string, opts?: MetricOptions) => Gauge;
|
||||
histogram: (name: string, opts?: MetricOptions) => Histogram;
|
||||
};
|
||||
|
||||
type MetricCreators = {
|
||||
[T in MetricType]: (
|
||||
meter: Meter,
|
||||
name: string,
|
||||
opts?: MetricOptions
|
||||
) => Metric<T>;
|
||||
};
|
||||
|
||||
export type KnownMetricScopes =
|
||||
| 'socketio'
|
||||
| 'gql'
|
||||
| 'jwst'
|
||||
| 'auth'
|
||||
| 'controllers'
|
||||
| 'doc'
|
||||
| 'sse'
|
||||
| 'mail'
|
||||
| 'ai'
|
||||
| 'event'
|
||||
| 'queue'
|
||||
| 'storage'
|
||||
| 'process'
|
||||
| 'permission'
|
||||
| 'quota'
|
||||
| 'license'
|
||||
| 'invalidation'
|
||||
| 'search'
|
||||
| 'workspace';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
return meter.createCounter(name, opts);
|
||||
},
|
||||
gauge(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
return meter.createGauge(name, opts);
|
||||
},
|
||||
histogram(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
return meter.createHistogram(name, opts);
|
||||
},
|
||||
};
|
||||
|
||||
const scopes = new Map<string, ScopedMetrics>();
|
||||
|
||||
function make(scope: string) {
|
||||
const meter = getMeter();
|
||||
const metrics = new Map<string, { type: MetricType; metric: any }>();
|
||||
const prefix = scope + '/';
|
||||
|
||||
function getOrCreate<T extends MetricType>(
|
||||
type: T,
|
||||
name: string,
|
||||
opts?: MetricOptions
|
||||
): Metric<T> {
|
||||
name = prefix + name;
|
||||
const metric = metrics.get(name);
|
||||
if (metric) {
|
||||
if (type !== metric.type) {
|
||||
throw new Error(
|
||||
`Metric ${name} has already been registered as ${metric.type} mode, but get as ${type} again.`
|
||||
);
|
||||
}
|
||||
|
||||
return metric.metric;
|
||||
} else {
|
||||
const metric = metricCreators[type](meter, name, opts);
|
||||
metrics.set(name, { type, metric });
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counter(name, opts) {
|
||||
return getOrCreate('counter', name, opts);
|
||||
},
|
||||
gauge(name, opts) {
|
||||
return getOrCreate('gauge', name, opts);
|
||||
},
|
||||
histogram(name, opts) {
|
||||
return getOrCreate('histogram', name, opts);
|
||||
},
|
||||
} satisfies ScopedMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
*
|
||||
* ```
|
||||
* metrics.scope.counter('example_count').add(1, {
|
||||
* attr1: 'example-event'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const metrics = new Proxy<Record<KnownMetricScopes, ScopedMetrics>>(
|
||||
// @ts-expect-error proxied
|
||||
{},
|
||||
{
|
||||
get(_, scopeName: string) {
|
||||
let scope = scopes.get(scopeName);
|
||||
if (!scope) {
|
||||
scope = make(scopeName);
|
||||
scopes.set(scopeName, scope);
|
||||
}
|
||||
|
||||
return scope;
|
||||
},
|
||||
}
|
||||
);
|
||||
145
packages/backend/server/src/base/metrics/opentelemetry.ts
Normal file
145
packages/backend/server/src/base/metrics/opentelemetry.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import {
|
||||
CompositePropagator,
|
||||
W3CBaggagePropagator,
|
||||
W3CTraceContextPropagator,
|
||||
} from '@opentelemetry/core';
|
||||
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
|
||||
import { ZipkinExporter } from '@opentelemetry/exporter-zipkin';
|
||||
import { Instrumentation } from '@opentelemetry/instrumentation';
|
||||
import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql';
|
||||
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
|
||||
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
|
||||
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
|
||||
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { IMetricReader, MetricProducer } from '@opentelemetry/sdk-metrics';
|
||||
import { NodeSDK, NodeSDKConfiguration } from '@opentelemetry/sdk-node';
|
||||
import {
|
||||
BatchSpanProcessor,
|
||||
SpanExporter,
|
||||
TraceIdRatioBasedSampler,
|
||||
} from '@opentelemetry/sdk-trace-node';
|
||||
import {
|
||||
ATTR_K8S_NAMESPACE_NAME,
|
||||
ATTR_SERVICE_NAME,
|
||||
ATTR_SERVICE_VERSION,
|
||||
} from '@opentelemetry/semantic-conventions/incubating';
|
||||
import { PrismaInstrumentation } from '@prisma/instrumentation';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { OnEvent } from '../event/def';
|
||||
import { registerCustomMetrics } from './metrics';
|
||||
import { PrismaMetricProducer } from './prisma';
|
||||
|
||||
export abstract class BaseOpentelemetryOptionsFactory {
|
||||
abstract getMetricReader(): IMetricReader;
|
||||
abstract getSpanExporter(): SpanExporter;
|
||||
|
||||
getInstractions(): Instrumentation[] {
|
||||
return [
|
||||
new NestInstrumentation(),
|
||||
new IORedisInstrumentation(),
|
||||
new SocketIoInstrumentation({ traceReserved: true }),
|
||||
new GraphQLInstrumentation({ mergeItems: true }),
|
||||
new HttpInstrumentation(),
|
||||
new PrismaInstrumentation(),
|
||||
];
|
||||
}
|
||||
|
||||
getMetricsProducers(): MetricProducer[] {
|
||||
return [new PrismaMetricProducer()];
|
||||
}
|
||||
|
||||
getResource() {
|
||||
return resourceFromAttributes({
|
||||
[ATTR_K8S_NAMESPACE_NAME]: env.NAMESPACE,
|
||||
[ATTR_SERVICE_NAME]: env.FLAVOR,
|
||||
[ATTR_SERVICE_VERSION]: env.version,
|
||||
});
|
||||
}
|
||||
|
||||
create(): Partial<NodeSDKConfiguration> {
|
||||
const traceExporter = this.getSpanExporter();
|
||||
return {
|
||||
resource: this.getResource(),
|
||||
sampler: new TraceIdRatioBasedSampler(0.1),
|
||||
traceExporter,
|
||||
metricReader: this.getMetricReader(),
|
||||
spanProcessor: new BatchSpanProcessor(traceExporter),
|
||||
textMapPropagator: new CompositePropagator({
|
||||
propagators: [
|
||||
new W3CBaggagePropagator(),
|
||||
new W3CTraceContextPropagator(),
|
||||
],
|
||||
}),
|
||||
instrumentations: this.getInstractions(),
|
||||
serviceName: 'affine-cloud',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpentelemetryOptionsFactory extends BaseOpentelemetryOptionsFactory {
|
||||
override getMetricReader(): IMetricReader {
|
||||
return new PrometheusExporter({
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
}
|
||||
|
||||
override getSpanExporter(): SpanExporter {
|
||||
return new ZipkinExporter();
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpentelemetryProvider {
|
||||
readonly #logger = new Logger(OpentelemetryProvider.name);
|
||||
#sdk: NodeSDK | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly ref: ModuleRef
|
||||
) {}
|
||||
|
||||
@OnEvent('config.init')
|
||||
async init(event: Events['config.init']) {
|
||||
if (env.flavors.script) {
|
||||
return;
|
||||
}
|
||||
if (event.config.metrics.enabled) {
|
||||
await this.setup();
|
||||
registerCustomMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('config.changed')
|
||||
async onConfigChanged(event: Events['config.changed']) {
|
||||
if ('metrics' in event.updates) {
|
||||
await this.setup();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.#sdk?.shutdown();
|
||||
}
|
||||
|
||||
private async setup() {
|
||||
if (this.config.metrics.enabled) {
|
||||
if (!this.#sdk) {
|
||||
const factory = this.ref.get(OpentelemetryOptionsFactory, {
|
||||
strict: false,
|
||||
});
|
||||
this.#sdk = new NodeSDK(factory.create());
|
||||
}
|
||||
|
||||
this.#sdk.start();
|
||||
this.#logger.log('OpenTelemetry SDK started');
|
||||
} else {
|
||||
await this.#sdk?.shutdown();
|
||||
this.#sdk = null;
|
||||
this.#logger.log('OpenTelemetry SDK stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
128
packages/backend/server/src/base/metrics/prisma.ts
Normal file
128
packages/backend/server/src/base/metrics/prisma.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { HrTime, ValueType } from '@opentelemetry/api';
|
||||
import { hrTime } from '@opentelemetry/core';
|
||||
import { emptyResource } from '@opentelemetry/resources';
|
||||
import {
|
||||
AggregationTemporality,
|
||||
CollectionResult,
|
||||
DataPointType,
|
||||
MetricProducer,
|
||||
ScopeMetrics,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
|
||||
import { PrismaFactory } from '../prisma/factory';
|
||||
|
||||
function transformPrismaKey(key: string) {
|
||||
// replace first '_' to '/' as a scope prefix
|
||||
// example: prisma_client_query_duration_seconds_sum -> prisma/client_query_duration_seconds_sum
|
||||
return key.replace(/_/, '/');
|
||||
}
|
||||
|
||||
export class PrismaMetricProducer implements MetricProducer {
|
||||
private readonly startTime: HrTime = hrTime();
|
||||
|
||||
async collect(): Promise<CollectionResult> {
|
||||
const result: CollectionResult = {
|
||||
resourceMetrics: {
|
||||
resource: emptyResource(),
|
||||
scopeMetrics: [],
|
||||
},
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (!PrismaFactory.INSTANCE) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const prisma = PrismaFactory.INSTANCE;
|
||||
|
||||
const endTime = hrTime();
|
||||
|
||||
const metrics = await prisma.$metrics.json();
|
||||
const scopeMetrics: ScopeMetrics = {
|
||||
scope: {
|
||||
name: '',
|
||||
},
|
||||
metrics: [],
|
||||
};
|
||||
for (const counter of metrics.counters) {
|
||||
scopeMetrics.metrics.push({
|
||||
descriptor: {
|
||||
name: transformPrismaKey(counter.key),
|
||||
description: counter.description,
|
||||
unit: '1',
|
||||
valueType: ValueType.INT,
|
||||
},
|
||||
dataPointType: DataPointType.SUM,
|
||||
aggregationTemporality: AggregationTemporality.CUMULATIVE,
|
||||
dataPoints: [
|
||||
{
|
||||
startTime: this.startTime,
|
||||
endTime: endTime,
|
||||
value: counter.value,
|
||||
attributes: counter.labels,
|
||||
},
|
||||
],
|
||||
isMonotonic: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const gauge of metrics.gauges) {
|
||||
scopeMetrics.metrics.push({
|
||||
descriptor: {
|
||||
name: transformPrismaKey(gauge.key),
|
||||
description: gauge.description,
|
||||
unit: '1',
|
||||
valueType: ValueType.INT,
|
||||
},
|
||||
dataPointType: DataPointType.GAUGE,
|
||||
aggregationTemporality: AggregationTemporality.CUMULATIVE,
|
||||
dataPoints: [
|
||||
{
|
||||
startTime: this.startTime,
|
||||
endTime: endTime,
|
||||
value: gauge.value,
|
||||
attributes: gauge.labels,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
for (const histogram of metrics.histograms) {
|
||||
const boundaries = [];
|
||||
const counts = [];
|
||||
for (const [boundary, count] of histogram.value.buckets) {
|
||||
boundaries.push(boundary);
|
||||
counts.push(count);
|
||||
}
|
||||
scopeMetrics.metrics.push({
|
||||
descriptor: {
|
||||
name: transformPrismaKey(histogram.key),
|
||||
description: histogram.description,
|
||||
unit: 'ms',
|
||||
valueType: ValueType.DOUBLE,
|
||||
},
|
||||
dataPointType: DataPointType.HISTOGRAM,
|
||||
aggregationTemporality: AggregationTemporality.CUMULATIVE,
|
||||
dataPoints: [
|
||||
{
|
||||
startTime: this.startTime,
|
||||
endTime: endTime,
|
||||
value: {
|
||||
buckets: {
|
||||
boundaries,
|
||||
counts,
|
||||
},
|
||||
count: histogram.value.count,
|
||||
sum: histogram.value.sum,
|
||||
},
|
||||
attributes: histogram.labels,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
result.resourceMetrics.scopeMetrics.push(scopeMetrics);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
50
packages/backend/server/src/base/metrics/utils.ts
Normal file
50
packages/backend/server/src/base/metrics/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Attributes } from '@opentelemetry/api';
|
||||
|
||||
import { makeMethodDecorator } from '../nestjs/decorator';
|
||||
import { type KnownMetricScopes, metrics } from './metrics';
|
||||
|
||||
/**
|
||||
* Decorator for measuring the call time, record call count and if is throw of a function call
|
||||
* @param scope metric scope
|
||||
* @param name metric event name
|
||||
* @param attrs attributes
|
||||
* @returns
|
||||
*/
|
||||
export const CallMetric = makeMethodDecorator(
|
||||
(scope: KnownMetricScopes, name: string, attrs?: Attributes) => {
|
||||
return (_target, _key, fn) => {
|
||||
return wrapCallMetric(fn, scope, name, attrs);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export function wrapCallMetric<Fn extends (...args: any[]) => any>(
|
||||
fn: Fn,
|
||||
scope: KnownMetricScopes,
|
||||
name: string,
|
||||
attrs?: Attributes
|
||||
) {
|
||||
return async function (this: any, ...args: any[]) {
|
||||
const start = Date.now();
|
||||
let error = false;
|
||||
|
||||
try {
|
||||
return await fn.call(this, ...args);
|
||||
} catch (err) {
|
||||
error = true;
|
||||
throw err;
|
||||
} finally {
|
||||
const count = metrics[scope].counter('function_calls', {
|
||||
description: 'function call counter',
|
||||
});
|
||||
|
||||
const timer = metrics[scope].histogram('function_timer', {
|
||||
description: 'function call time costs',
|
||||
unit: 'ms',
|
||||
});
|
||||
|
||||
count.add(1, { ...attrs, name, error });
|
||||
timer.record(Date.now() - start, { ...attrs, name, error });
|
||||
}
|
||||
};
|
||||
}
|
||||
14
packages/backend/server/src/base/mutex/index.ts
Normal file
14
packages/backend/server/src/base/mutex/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { Locker } from './locker';
|
||||
import { Mutex, RequestMutex } from './mutex';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [Mutex, RequestMutex, Locker],
|
||||
exports: [Mutex, RequestMutex],
|
||||
})
|
||||
export class MutexModule {}
|
||||
|
||||
export { Locker, Mutex, RequestMutex };
|
||||
export { Lock } from './lock';
|
||||
19
packages/backend/server/src/base/mutex/lock.ts
Normal file
19
packages/backend/server/src/base/mutex/lock.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { retryable } from '../utils/promise';
|
||||
|
||||
export class Lock implements AsyncDisposable {
|
||||
private readonly logger = new Logger(Lock.name);
|
||||
|
||||
constructor(private readonly dispose: () => Promise<void>) {}
|
||||
|
||||
async release() {
|
||||
await retryable(() => this.dispose()).catch(e => {
|
||||
this.logger.error('Failed to release lock', e);
|
||||
});
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose]() {
|
||||
await this.release();
|
||||
}
|
||||
}
|
||||
67
packages/backend/server/src/base/mutex/locker.ts
Normal file
67
packages/backend/server/src/base/mutex/locker.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Command } from 'ioredis';
|
||||
|
||||
import { SessionRedis } from '../redis';
|
||||
import { Lock } from './lock';
|
||||
|
||||
// === atomic mutex lock ===
|
||||
// acquire lock
|
||||
// return 1 if lock is acquired
|
||||
// return 0 if lock is not acquired
|
||||
const lockScript = `local key = KEYS[1]
|
||||
local owner = ARGV[1]
|
||||
|
||||
-- if lock is not exists then set lock to the owner and return 1, otherwise return 0
|
||||
-- if the lock is not released correctly due to unexpected reasons
|
||||
-- lock will be released after 60 seconds
|
||||
if redis.call("get", key) == owner then
|
||||
return 0
|
||||
elseif redis.call("set", key, owner, "NX", "EX", 60) then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end`;
|
||||
// release lock
|
||||
// return 1 if lock is released or lock is not exists
|
||||
// return 0 if lock is not owned by the owner
|
||||
const unlockScript = `local key = KEYS[1]
|
||||
local owner = ARGV[1]
|
||||
|
||||
local value = redis.call("get", key)
|
||||
if value == owner then
|
||||
return redis.call("del", key)
|
||||
elseif value == nil then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end`;
|
||||
|
||||
@Injectable()
|
||||
export class Locker {
|
||||
private readonly logger = new Logger(Locker.name);
|
||||
|
||||
constructor(private readonly redis: SessionRedis) {}
|
||||
|
||||
async lock(owner: string, key: string): Promise<Lock> {
|
||||
const lockKey = `MutexLock:${key}`;
|
||||
this.logger.verbose(`Client ${owner} is trying to lock resource ${key}`);
|
||||
|
||||
const success = await this.redis.sendCommand(
|
||||
new Command('EVAL', [lockScript, '1', lockKey, owner])
|
||||
);
|
||||
|
||||
if (success === 1) {
|
||||
return new Lock(async () => {
|
||||
const result = await this.redis.sendCommand(
|
||||
new Command('EVAL', [unlockScript, '1', lockKey, owner])
|
||||
);
|
||||
|
||||
if (result === 0) {
|
||||
throw new Error(`Failed to release lock ${key}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Failed to acquire lock for resource [${key}]`);
|
||||
}
|
||||
}
|
||||
94
packages/backend/server/src/base/mutex/mutex.ts
Normal file
94
packages/backend/server/src/base/mutex/mutex.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Inject, Injectable, Logger, Scope } from '@nestjs/common';
|
||||
import { ModuleRef, REQUEST } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { GraphqlContext } from '../graphql';
|
||||
import { retryable } from '../utils/promise';
|
||||
import { Locker } from './locker';
|
||||
|
||||
export const MUTEX_RETRY = 5;
|
||||
export const MUTEX_WAIT = 100;
|
||||
|
||||
@Injectable()
|
||||
export class Mutex {
|
||||
protected logger = new Logger(Mutex.name);
|
||||
private readonly clusterIdentifier = `cluster:${nanoid()}`;
|
||||
|
||||
constructor(protected readonly locker: Locker) {}
|
||||
|
||||
/**
|
||||
* lock an resource and return a lock guard, which will release the lock when disposed
|
||||
*
|
||||
* if the lock is not available, it will retry for [MUTEX_RETRY] times
|
||||
*
|
||||
* usage:
|
||||
* ```typescript
|
||||
* {
|
||||
* // lock is acquired here
|
||||
* await using lock = await mutex.acquire('resource-key');
|
||||
* if (lock) {
|
||||
* // do something
|
||||
* } else {
|
||||
* // failed to lock
|
||||
* }
|
||||
* }
|
||||
* // lock is released here
|
||||
* ```
|
||||
* @param key resource key
|
||||
* @returns LockGuard
|
||||
*/
|
||||
async acquire(
|
||||
key: string,
|
||||
owner: string = `${this.clusterIdentifier}:${nanoid()}`
|
||||
) {
|
||||
try {
|
||||
return await retryable(
|
||||
() => this.locker.lock(owner, key),
|
||||
MUTEX_RETRY,
|
||||
MUTEX_WAIT
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to lock resource [${key}] after retry ${MUTEX_RETRY} times`,
|
||||
e
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class RequestMutex extends Mutex {
|
||||
constructor(
|
||||
@Inject(REQUEST) private readonly request: Request | GraphqlContext,
|
||||
ref: ModuleRef
|
||||
) {
|
||||
// nestjs will always find and injecting the locker from local module
|
||||
// so the RedisLocker implemented by the plugin mechanism will not be able to overwrite the internal locker
|
||||
// we need to use find and get the locker from the `ModuleRef` manually
|
||||
//
|
||||
// NOTE: when a `constructor` execute in normal service, the Locker module we expect may not have been initialized
|
||||
// but in the Service with `Scope.REQUEST`, we will create a separate Service instance for each request
|
||||
// at this time, all modules have been initialized, so we able to get the correct Locker instance in `constructor`
|
||||
super(ref.get(Locker));
|
||||
}
|
||||
|
||||
protected getId() {
|
||||
const req = 'req' in this.request ? this.request.req : this.request;
|
||||
let id = req.headers['x-transaction-id'] as string;
|
||||
|
||||
if (!id) {
|
||||
id = randomUUID();
|
||||
req.headers['x-transaction-id'] = id;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
override acquire(key: string) {
|
||||
return super.acquire(key, this.getId());
|
||||
}
|
||||
}
|
||||
45
packages/backend/server/src/base/nestjs/decorator.ts
Normal file
45
packages/backend/server/src/base/nestjs/decorator.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export function makeMethodDecorator<
|
||||
T extends any[],
|
||||
Fn extends (...args: any[]) => any,
|
||||
>(
|
||||
decorator: (...args: T) => (target: any, key: string | symbol, fn: Fn) => Fn
|
||||
) {
|
||||
return (...args: T) => {
|
||||
return (
|
||||
target: any,
|
||||
key: string | symbol,
|
||||
desc: TypedPropertyDescriptor<any>
|
||||
) => {
|
||||
const originalFn = desc.value;
|
||||
if (!originalFn || typeof originalFn !== 'function') {
|
||||
throw new Error(
|
||||
`MethodDecorator must be applied to a function but got ${typeof originalFn}`
|
||||
);
|
||||
}
|
||||
|
||||
const decoratedFn = decorator(...args)(target, key, originalFn);
|
||||
desc.value = decoratedFn;
|
||||
return desc;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function PushMetadata<T>(key: string | symbol, value: T) {
|
||||
const decorator: ClassDecorator | MethodDecorator = (
|
||||
target,
|
||||
_,
|
||||
descriptor
|
||||
) => {
|
||||
const metadataTarget = descriptor?.value ?? target;
|
||||
|
||||
const metadataArray = Reflect.getMetadata(key, metadataTarget) || [];
|
||||
metadataArray.push(value);
|
||||
Reflect.defineMetadata(key, metadataArray, metadataTarget);
|
||||
};
|
||||
|
||||
return decorator;
|
||||
}
|
||||
|
||||
export function sliceMetadata<T>(key: string | symbol, target: any): T[] {
|
||||
return Reflect.getMetadata(key, target) || [];
|
||||
}
|
||||
186
packages/backend/server/src/base/nestjs/exception.ts
Normal file
186
packages/backend/server/src/base/nestjs/exception.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ApolloServerErrorCode } from '@apollo/server/errors';
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { BaseExceptionFilter } from '@nestjs/core';
|
||||
import { GqlContextType } from '@nestjs/graphql';
|
||||
import { ThrottlerException } from '@nestjs/throttler';
|
||||
import { BaseWsExceptionFilter } from '@nestjs/websockets';
|
||||
import { Response } from 'express';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { HttpError } from 'http-errors';
|
||||
import { of } from 'rxjs';
|
||||
import { Socket } from 'socket.io';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import {
|
||||
GraphqlBadRequest,
|
||||
HttpRequestError,
|
||||
InternalServerError,
|
||||
NotFound,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
ValidationError,
|
||||
} from '../error';
|
||||
import { metrics } from '../metrics';
|
||||
import { getRequestIdFromHost } from '../utils';
|
||||
|
||||
export function isGraphQLBadRequest(error: GraphQLError) {
|
||||
// https://www.apollographql.com/docs/apollo-server/data/errors
|
||||
const code = error.extensions?.code;
|
||||
return (
|
||||
code === ApolloServerErrorCode.GRAPHQL_PARSE_FAILED ||
|
||||
code === ApolloServerErrorCode.GRAPHQL_VALIDATION_FAILED ||
|
||||
code === ApolloServerErrorCode.BAD_USER_INPUT ||
|
||||
code === ApolloServerErrorCode.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
export function mapAnyError(error: any): UserFriendlyError {
|
||||
if (error instanceof GraphQLError) {
|
||||
if (isGraphQLBadRequest(error)) {
|
||||
return new GraphqlBadRequest({
|
||||
code: error.extensions.code as string,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
error = error.originalError ?? error;
|
||||
}
|
||||
if (error instanceof UserFriendlyError) {
|
||||
return error;
|
||||
} else if (error instanceof ThrottlerException) {
|
||||
return new TooManyRequest();
|
||||
} else if (error instanceof NotFoundException) {
|
||||
return new NotFound();
|
||||
} else if (error instanceof ZodError) {
|
||||
return new ValidationError({
|
||||
errors: error.message,
|
||||
});
|
||||
} else if (
|
||||
error instanceof HttpError &&
|
||||
error.status >= 400 &&
|
||||
error.status < 500
|
||||
) {
|
||||
const e = new HttpRequestError({
|
||||
message: error.message,
|
||||
});
|
||||
e.status = error.status;
|
||||
return e;
|
||||
} else {
|
||||
const e = new InternalServerError();
|
||||
e.cause = error;
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter extends BaseExceptionFilter {
|
||||
logger = new Logger('GlobalExceptionFilter');
|
||||
override catch(exception: Error, host: ArgumentsHost) {
|
||||
const error = mapAnyError(exception);
|
||||
// with useGlobalFilters, the context is always HTTP
|
||||
if (host.getType<GqlContextType>() === 'graphql') {
|
||||
// let Graphql LoggerPlugin handle it
|
||||
// see '../graphql/logger-plugin.ts'
|
||||
throw error;
|
||||
} else {
|
||||
error.log('HTTP', {
|
||||
requestId: error.requestId ?? getRequestIdFromHost(host),
|
||||
});
|
||||
metrics.controllers
|
||||
.counter('error')
|
||||
.add(1, { status: error.status, type: error.type, error: error.name });
|
||||
const res = host.switchToHttp().getResponse<Response>();
|
||||
const respondText = res.getHeader('content-type') === 'text/plain';
|
||||
|
||||
if (respondText) {
|
||||
res
|
||||
.setHeader('content-type', 'text/plain')
|
||||
.status(error.status)
|
||||
.send(error.toText());
|
||||
} else {
|
||||
res.status(error.status).send(error.toJSON());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GlobalWsExceptionFilter extends BaseWsExceptionFilter {
|
||||
// @ts-expect-error satisfies the override
|
||||
override handleError(client: Socket, exception: any): void {
|
||||
const error = mapAnyError(exception);
|
||||
error.log('Websocket');
|
||||
metrics.socketio
|
||||
.counter('unhandled_error')
|
||||
.add(1, { status: error.status });
|
||||
client.emit('error', {
|
||||
error: toWebsocketError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only exists for websocket error body backward compatibility
|
||||
*
|
||||
* relay on `code` field instead of `name`
|
||||
*
|
||||
* @TODO(@forehalo): remove
|
||||
*/
|
||||
function toWebsocketError(error: UserFriendlyError) {
|
||||
// should be `error.toJSON()` after backward compatibility removed
|
||||
return {
|
||||
status: error.status,
|
||||
code: error.name.toUpperCase(),
|
||||
type: error.type.toUpperCase(),
|
||||
name: error.name.toUpperCase(),
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
requestId: error.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export const GatewayErrorWrapper = (event: string): MethodDecorator => {
|
||||
// @ts-expect-error allow
|
||||
return (
|
||||
_target,
|
||||
_key,
|
||||
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
||||
) => {
|
||||
const originalMethod = desc.value;
|
||||
if (!originalMethod) {
|
||||
return desc;
|
||||
}
|
||||
|
||||
desc.value = async function (...args: any[]) {
|
||||
try {
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (error) {
|
||||
const mappedError = mapAnyError(error);
|
||||
mappedError.log('Websocket');
|
||||
metrics.socketio
|
||||
.counter('error')
|
||||
.add(1, { event, status: mappedError.status });
|
||||
|
||||
return {
|
||||
error: toWebsocketError(mappedError),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return desc;
|
||||
};
|
||||
};
|
||||
|
||||
export function mapSseError(originalError: any, info: object) {
|
||||
const error = mapAnyError(originalError);
|
||||
error.log('Sse', info);
|
||||
metrics.sse.counter('error').add(1, { status: error.status });
|
||||
return of({
|
||||
type: 'error' as const,
|
||||
data: error.toJSON(),
|
||||
});
|
||||
}
|
||||
3
packages/backend/server/src/base/nestjs/index.ts
Normal file
3
packages/backend/server/src/base/nestjs/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './decorator';
|
||||
export * from './exception';
|
||||
export * from './scanner';
|
||||
60
packages/backend/server/src/base/nestjs/scanner.ts
Normal file
60
packages/backend/server/src/base/nestjs/scanner.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Global, Injectable, Module } from '@nestjs/common';
|
||||
import {
|
||||
DiscoveryModule,
|
||||
DiscoveryService,
|
||||
MetadataScanner,
|
||||
} from '@nestjs/core';
|
||||
import { RESOLVER_TYPE_METADATA } from '@nestjs/graphql';
|
||||
|
||||
@Injectable()
|
||||
export class ModuleScanner {
|
||||
constructor(
|
||||
private readonly discovery: DiscoveryService,
|
||||
private readonly scanner: MetadataScanner
|
||||
) {}
|
||||
|
||||
getClassProviders() {
|
||||
return this.discovery
|
||||
.getProviders()
|
||||
.filter(
|
||||
wrapper =>
|
||||
wrapper.instance && !wrapper.isAlias && !wrapper.isNotMetatype
|
||||
);
|
||||
}
|
||||
|
||||
getAtInjectables() {
|
||||
return this.getClassProviders().filter(
|
||||
wrapper => !this.isResolver(wrapper.instance)
|
||||
);
|
||||
}
|
||||
|
||||
getControllers() {
|
||||
return this.discovery.getControllers();
|
||||
}
|
||||
|
||||
getResolvers() {
|
||||
return this.getClassProviders().filter(wrapper =>
|
||||
this.isResolver(wrapper.instance)
|
||||
);
|
||||
}
|
||||
|
||||
getAllMethodNames(instance: any) {
|
||||
return this.scanner.getAllMethodNames(Object.getPrototypeOf(instance));
|
||||
}
|
||||
|
||||
isResolver(instance: any) {
|
||||
if (typeof instance !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const metadata = Reflect.getMetadata(RESOLVER_TYPE_METADATA, instance);
|
||||
return metadata !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [DiscoveryModule],
|
||||
providers: [ModuleScanner],
|
||||
exports: [ModuleScanner],
|
||||
})
|
||||
export class ScannerModule {}
|
||||
27
packages/backend/server/src/base/prisma/config.ts
Normal file
27
packages/backend/server/src/base/prisma/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModuleConfig } from '../config';
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
db: {
|
||||
datasourceUrl: string;
|
||||
prisma: ConfigItem<Prisma.PrismaClientOptions>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('db', {
|
||||
datasourceUrl: {
|
||||
desc: 'The datasource url for the prisma client.',
|
||||
default: 'postgresql://localhost:5432/affine',
|
||||
env: 'DATABASE_URL',
|
||||
shape: z.string().url(),
|
||||
},
|
||||
prisma: {
|
||||
desc: 'The config for the prisma client.',
|
||||
default: {},
|
||||
link: 'https://www.prisma.io/docs/reference/api-reference/prisma-client-reference',
|
||||
},
|
||||
});
|
||||
25
packages/backend/server/src/base/prisma/factory.ts
Normal file
25
packages/backend/server/src/base/prisma/factory.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { OnModuleDestroy } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaFactory implements OnModuleDestroy {
|
||||
static INSTANCE: PrismaClient | null = null;
|
||||
readonly #instance: PrismaClient;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.#instance = new PrismaClient(config.db.prisma);
|
||||
PrismaFactory.INSTANCE = this.#instance;
|
||||
}
|
||||
|
||||
get() {
|
||||
return this.#instance;
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await PrismaFactory.INSTANCE?.$disconnect();
|
||||
PrismaFactory.INSTANCE = null;
|
||||
}
|
||||
}
|
||||
27
packages/backend/server/src/base/prisma/index.ts
Normal file
27
packages/backend/server/src/base/prisma/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import './config';
|
||||
|
||||
import { Global, Module, Provider } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { PrismaFactory } from './factory';
|
||||
|
||||
// only `PrismaClient` can be injected
|
||||
const clientProvider: Provider = {
|
||||
provide: PrismaClient,
|
||||
useFactory: (factory: PrismaFactory) => {
|
||||
return factory.get();
|
||||
},
|
||||
inject: [PrismaFactory],
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaFactory, clientProvider],
|
||||
exports: [clientProvider],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
export { PrismaFactory };
|
||||
|
||||
export type PrismaTransaction = Parameters<
|
||||
Parameters<PrismaClient['$transaction']>[0]
|
||||
>[0];
|
||||
54
packages/backend/server/src/base/redis/config.ts
Normal file
54
packages/backend/server/src/base/redis/config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { RedisOptions } from 'ioredis';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModuleConfig } from '../config';
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
redis: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: number;
|
||||
username: string;
|
||||
password: string;
|
||||
ioredis: ConfigItem<
|
||||
Omit<RedisOptions, 'host' | 'port' | 'db' | 'username' | 'password'>
|
||||
>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('redis', {
|
||||
db: {
|
||||
desc: 'The database index of redis server to be used(Must be less than 10).',
|
||||
default: 0,
|
||||
env: ['REDIS_SERVER_DATABASE', 'integer'],
|
||||
shape: z.number().int().nonnegative().max(10),
|
||||
},
|
||||
host: {
|
||||
desc: 'The host of the redis server.',
|
||||
default: 'localhost',
|
||||
env: ['REDIS_SERVER_HOST', 'string'],
|
||||
},
|
||||
port: {
|
||||
desc: 'The port of the redis server.',
|
||||
default: 6379,
|
||||
env: ['REDIS_SERVER_PORT', 'integer'],
|
||||
shape: z.number().positive(),
|
||||
},
|
||||
username: {
|
||||
desc: 'The username of the redis server.',
|
||||
default: '',
|
||||
env: ['REDIS_SERVER_USERNAME', 'string'],
|
||||
},
|
||||
password: {
|
||||
desc: 'The password of the redis server.',
|
||||
default: '',
|
||||
env: ['REDIS_SERVER_PASSWORD', 'string'],
|
||||
},
|
||||
ioredis: {
|
||||
desc: 'The config for the ioredis client.',
|
||||
default: {},
|
||||
link: 'https://github.com/luin/ioredis',
|
||||
},
|
||||
});
|
||||
14
packages/backend/server/src/base/redis/index.ts
Normal file
14
packages/backend/server/src/base/redis/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import './config';
|
||||
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { CacheRedis, SessionRedis, SocketIoRedis } from './instances';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CacheRedis, SessionRedis, SocketIoRedis],
|
||||
exports: [CacheRedis, SessionRedis, SocketIoRedis],
|
||||
})
|
||||
export class RedisModule {}
|
||||
|
||||
export { CacheRedis, SessionRedis, SocketIoRedis };
|
||||
85
packages/backend/server/src/base/redis/instances.ts
Normal file
85
packages/backend/server/src/base/redis/instances.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Redis as IORedis, RedisOptions } from 'ioredis';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
function redisOptions(options: RedisOptions) {
|
||||
return {
|
||||
...(env.testing ? { lazyConnect: true } : {}),
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
class Redis extends IORedis implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
errorHandler = (err: Error) => {
|
||||
this.logger.error(err);
|
||||
};
|
||||
|
||||
onModuleInit() {
|
||||
this.on('error', this.errorHandler);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await this.quit();
|
||||
} catch {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
override duplicate(override?: Partial<RedisOptions>): IORedis {
|
||||
const client = super.duplicate(override);
|
||||
client.on('error', this.errorHandler);
|
||||
return client;
|
||||
}
|
||||
|
||||
assertValidDBIndex(db: number) {
|
||||
if (db && db > 15) {
|
||||
throw new Error(
|
||||
// Redis allows [0..16) by default
|
||||
// we separate the db for different usages by `this.options.db + [0..4]`
|
||||
`Invalid database index: ${db}, must be between 0 and 11`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CacheRedis extends Redis {
|
||||
constructor(config: Config) {
|
||||
super(redisOptions({ ...config.redis, ...config.redis.ioredis }));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionRedis extends Redis {
|
||||
constructor(config: Config) {
|
||||
super(
|
||||
redisOptions({
|
||||
...config.redis,
|
||||
...config.redis.ioredis,
|
||||
db: (config.redis.db ?? 0) + 2,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SocketIoRedis extends Redis {
|
||||
constructor(config: Config) {
|
||||
super(
|
||||
redisOptions({
|
||||
...config.redis,
|
||||
...config.redis.ioredis,
|
||||
db: (config.redis.db ?? 0) + 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
3
packages/backend/server/src/base/storage/index.ts
Normal file
3
packages/backend/server/src/base/storage/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './providers';
|
||||
export type * from './types';
|
||||
export * from './utils';
|
||||
250
packages/backend/server/src/base/storage/providers/index.ts
Normal file
250
packages/backend/server/src/base/storage/providers/index.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { JSONSchema } from '../../config';
|
||||
|
||||
export type StorageProviderName =
|
||||
| 'fs'
|
||||
| 'aws-s3'
|
||||
| 'cloudflare-r2'
|
||||
| 'assetpack';
|
||||
|
||||
export interface FsStorageConfig {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type AssetpackStorageConfig = FsStorageConfig;
|
||||
|
||||
export interface S3StorageConfig {
|
||||
endpoint?: string;
|
||||
region: string;
|
||||
credentials?: {
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
sessionToken?: string;
|
||||
};
|
||||
forcePathStyle?: boolean;
|
||||
requestTimeoutMs?: number;
|
||||
minPartSize?: number;
|
||||
presign?: {
|
||||
expiresInSeconds?: number;
|
||||
signContentTypeForPut?: boolean;
|
||||
};
|
||||
usePresignedURL?: {
|
||||
enabled: boolean;
|
||||
urlPrefix?: string;
|
||||
signKey?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const R2_JURISDICTIONS = ['default', 'eu'] as const;
|
||||
|
||||
export interface R2StorageConfig extends Omit<S3StorageConfig, 'endpoint'> {
|
||||
accountId: string;
|
||||
jurisdiction?: (typeof R2_JURISDICTIONS)[number];
|
||||
}
|
||||
|
||||
export type StorageProviderConfig = { bucket: string } & (
|
||||
| {
|
||||
provider: 'fs';
|
||||
config: FsStorageConfig;
|
||||
}
|
||||
| {
|
||||
provider: 'aws-s3';
|
||||
config: S3StorageConfig;
|
||||
}
|
||||
| {
|
||||
provider: 'cloudflare-r2';
|
||||
config: R2StorageConfig;
|
||||
}
|
||||
| {
|
||||
provider: 'assetpack';
|
||||
config: AssetpackStorageConfig;
|
||||
}
|
||||
);
|
||||
|
||||
const S3ConfigSchema: JSONSchema = {
|
||||
type: 'object',
|
||||
description: 'The config for the S3 compatible storage provider.',
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region.',
|
||||
},
|
||||
region: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The region for the storage provider. Example: "us-east-1" or "auto" for R2.',
|
||||
},
|
||||
forcePathStyle: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to use path-style bucket addressing.',
|
||||
},
|
||||
requestTimeoutMs: {
|
||||
type: 'number',
|
||||
description: 'Request timeout in milliseconds.',
|
||||
},
|
||||
minPartSize: {
|
||||
type: 'number',
|
||||
description: 'Minimum multipart part size in bytes.',
|
||||
},
|
||||
presign: {
|
||||
type: 'object',
|
||||
description: 'Presigned URL behavior configuration.',
|
||||
properties: {
|
||||
expiresInSeconds: {
|
||||
type: 'number',
|
||||
description: 'Expiration time in seconds for presigned URLs.',
|
||||
},
|
||||
signContentTypeForPut: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to sign Content-Type for presigned PUT.',
|
||||
},
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
type: 'object',
|
||||
description: 'The credentials for the s3 compatible storage provider.',
|
||||
properties: {
|
||||
accessKeyId: {
|
||||
type: 'string',
|
||||
},
|
||||
secretAccessKey: {
|
||||
type: 'string',
|
||||
},
|
||||
sessionToken: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
usePresignedURL: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.',
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether browser upload URLs are enabled.',
|
||||
},
|
||||
urlPrefix: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional base URL for proxied uploads and signed custom GET URLs. Provider presigned uploads require an origin-only value when signKey is not configured.',
|
||||
},
|
||||
signKey: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional HMAC key for signed upload and custom GET URLs. Custom GET URLs require both signKey and urlPrefix.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const S3ConfigPropertiesWithoutEndpoint = Object.fromEntries(
|
||||
Object.entries(
|
||||
(
|
||||
S3ConfigSchema as {
|
||||
type: 'object';
|
||||
properties?: Record<string, JSONSchema>;
|
||||
}
|
||||
).properties ?? {}
|
||||
).filter(([key]) => key !== 'endpoint')
|
||||
) as Record<string, JSONSchema>;
|
||||
|
||||
export const StorageJSONSchema: JSONSchema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: ['fs'],
|
||||
},
|
||||
bucket: {
|
||||
type: 'string',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: ['aws-s3'],
|
||||
},
|
||||
bucket: {
|
||||
type: 'string',
|
||||
},
|
||||
config: S3ConfigSchema,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: ['cloudflare-r2'],
|
||||
},
|
||||
bucket: {
|
||||
type: 'string',
|
||||
},
|
||||
config: {
|
||||
...S3ConfigSchema,
|
||||
properties: {
|
||||
...S3ConfigPropertiesWithoutEndpoint,
|
||||
accountId: {
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'The account id for the cloudflare r2 storage provider.',
|
||||
},
|
||||
jurisdiction: {
|
||||
type: 'string' as const,
|
||||
enum: [...R2_JURISDICTIONS],
|
||||
description:
|
||||
'Optional jurisdiction for the cloudflare r2 endpoint. Set to "eu" for EU buckets.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: ['assetpack'],
|
||||
},
|
||||
bucket: {
|
||||
type: 'string',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
required: ['provider', 'bucket', 'config'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export type * from '../types';
|
||||
export {
|
||||
applyAttachHeaders,
|
||||
PROXY_MULTIPART_PATH,
|
||||
PROXY_UPLOAD_PATH,
|
||||
sniffMime,
|
||||
STORAGE_PROXY_ROOT,
|
||||
toBuffer,
|
||||
} from '../utils';
|
||||
32
packages/backend/server/src/base/storage/types.ts
Normal file
32
packages/backend/server/src/base/storage/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
export interface GetObjectMetadata {
|
||||
/**
|
||||
* @default 'application/octet-stream'
|
||||
*/
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
lastModified: Date;
|
||||
checksumCRC32?: string;
|
||||
}
|
||||
|
||||
export interface PutObjectMetadata {
|
||||
contentType?: string;
|
||||
contentLength?: number;
|
||||
checksumCRC32?: string;
|
||||
}
|
||||
|
||||
export interface ListObjectsMetadata {
|
||||
key: string;
|
||||
lastModified: Date;
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
export type BlobInputType = Buffer | Readable | string;
|
||||
export type BlobOutputType = Readable;
|
||||
|
||||
export interface PresignedUpload {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
expiresAt: Date;
|
||||
}
|
||||
88
packages/backend/server/src/base/storage/utils.ts
Normal file
88
packages/backend/server/src/base/storage/utils.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { getMime } from '../../native';
|
||||
import type { BlobInputType } from './types';
|
||||
|
||||
export async function toBuffer(input: BlobInputType): Promise<Buffer> {
|
||||
return input instanceof Readable
|
||||
? await getStreamAsBuffer(input)
|
||||
: input instanceof Buffer
|
||||
? input
|
||||
: Buffer.from(input as string);
|
||||
}
|
||||
|
||||
const DANGEROUS_INLINE_MIME_PREFIXES = [
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
'image/svg+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/javascript',
|
||||
];
|
||||
|
||||
export function isDangerousInlineMime(mime: string | undefined) {
|
||||
if (!mime) return false;
|
||||
const lower = mime.toLowerCase();
|
||||
return DANGEROUS_INLINE_MIME_PREFIXES.some(p => lower.startsWith(p));
|
||||
}
|
||||
|
||||
export function applyAttachHeaders(
|
||||
res: Response,
|
||||
options: { filename?: string; buffer?: Buffer; contentType?: string }
|
||||
) {
|
||||
let { filename, buffer, contentType } = options;
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (!contentType && buffer) contentType = sniffMime(buffer);
|
||||
if (contentType && isDangerousInlineMime(contentType)) {
|
||||
const safeName = (filename || 'download')
|
||||
.replace(/[\r\n]/g, '')
|
||||
.replace(/[^\w\s.-]/g, '_');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(safeName)}"; filename*=UTF-8''${encodeURIComponent(
|
||||
safeName
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!res.getHeader('Content-Type')) {
|
||||
res.setHeader('Content-Type', contentType || 'application/octet-stream');
|
||||
}
|
||||
}
|
||||
|
||||
export function sniffMime(
|
||||
buffer: Buffer,
|
||||
declared?: string
|
||||
): string | undefined {
|
||||
try {
|
||||
const detected = getMime(buffer);
|
||||
if (detected) return detected;
|
||||
} catch {}
|
||||
return declared;
|
||||
}
|
||||
|
||||
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
|
||||
|
||||
export const STORAGE_PROXY_ROOT = '/api/storage';
|
||||
export const PROXY_UPLOAD_PATH = `${STORAGE_PROXY_ROOT}/upload`;
|
||||
export const PROXY_MULTIPART_PATH = `${STORAGE_PROXY_ROOT}/multipart`;
|
||||
|
||||
export function createStorageUploadToken(
|
||||
path: string,
|
||||
fields: (string | number)[],
|
||||
expiresAt: number,
|
||||
signKey: string
|
||||
) {
|
||||
const canonical = JSON.stringify([
|
||||
'affine-storage-upload',
|
||||
1,
|
||||
'PUT',
|
||||
path,
|
||||
...fields,
|
||||
expiresAt,
|
||||
]);
|
||||
return createHmac('sha256', signKey).update(canonical).digest('base64url');
|
||||
}
|
||||
38
packages/backend/server/src/base/throttler/config.ts
Normal file
38
packages/backend/server/src/base/throttler/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineModuleConfig } from '../config';
|
||||
|
||||
export type ThrottlerType = 'default' | 'strict';
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
throttle: {
|
||||
enabled: boolean;
|
||||
throttlers: {
|
||||
[key in ThrottlerType]: ConfigItem<{
|
||||
ttl: number;
|
||||
limit: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('throttle', {
|
||||
enabled: {
|
||||
desc: 'Whether the throttler is enabled.',
|
||||
default: true,
|
||||
},
|
||||
'throttlers.default': {
|
||||
desc: 'The config for the default throttler.',
|
||||
default: {
|
||||
ttl: 60_000,
|
||||
limit: 120,
|
||||
},
|
||||
},
|
||||
'throttlers.strict': {
|
||||
desc: 'The config for the strict throttler.',
|
||||
default: {
|
||||
ttl: 60_000,
|
||||
limit: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
51
packages/backend/server/src/base/throttler/decorators.ts
Normal file
51
packages/backend/server/src/base/throttler/decorators.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
||||
import {
|
||||
SkipThrottle as RawSkipThrottle,
|
||||
Throttle as RawThrottle,
|
||||
} from '@nestjs/throttler';
|
||||
|
||||
import { ThrottlerType } from './config';
|
||||
|
||||
export type Throttlers = 'default' | 'strict' | 'authenticated';
|
||||
export const THROTTLER_PROTECTED = 'affine_throttler:protected';
|
||||
|
||||
/**
|
||||
* Choose what throttler to use
|
||||
*
|
||||
* If a Controller or Query do not protected behind a Throttler,
|
||||
* it will never be rate limited.
|
||||
*
|
||||
* - default: 120 calls within 60 seconds
|
||||
* - strict: 20 calls within 60 seconds
|
||||
* - authenticated: no rate limit for authenticated users, apply [default] throttler for unauthenticated users
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* \@Throttle()
|
||||
* \@Throttle('strict')
|
||||
*
|
||||
* // the config call be override by the second parameter,
|
||||
* // and the call count will be calculated separately
|
||||
* \@Throttle('default', { limit: 10, ttl: 10_000 })
|
||||
*
|
||||
*/
|
||||
export function Throttle(
|
||||
type: ThrottlerType | 'authenticated' = 'default',
|
||||
override: { limit?: number; ttl?: number } = {}
|
||||
): MethodDecorator & ClassDecorator {
|
||||
return applyDecorators(
|
||||
SetMetadata(THROTTLER_PROTECTED, type),
|
||||
RawThrottle({
|
||||
[type]: override,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function SkipThrottle(
|
||||
skip: Partial<Record<ThrottlerType, boolean>> = {
|
||||
default: true,
|
||||
strict: true,
|
||||
}
|
||||
): MethodDecorator & ClassDecorator {
|
||||
return RawSkipThrottle(skip);
|
||||
}
|
||||
277
packages/backend/server/src/base/throttler/index.ts
Normal file
277
packages/backend/server/src/base/throttler/index.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import './config';
|
||||
|
||||
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
InjectThrottlerOptions,
|
||||
InjectThrottlerStorage,
|
||||
ThrottlerGuard,
|
||||
ThrottlerModule,
|
||||
type ThrottlerModuleOptions,
|
||||
ThrottlerOptionsFactory,
|
||||
ThrottlerRequest,
|
||||
ThrottlerStorageService,
|
||||
} from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { CacheRedis } from '../redis';
|
||||
import { getRequestResponseFromContext } from '../utils/request';
|
||||
import { getRequestTrackerId } from '../utils/request-tracker';
|
||||
import type { ThrottlerType } from './config';
|
||||
import { THROTTLER_PROTECTED, Throttlers } from './decorators';
|
||||
|
||||
const REDIS_THROTTLE_SCRIPT = `
|
||||
local now = redis.call("TIME")
|
||||
local nowMs = now[1] * 1000 + math.floor(now[2] / 1000)
|
||||
local blockedUntil = tonumber(redis.call("HGET", KEYS[1], "blockedUntil")) or 0
|
||||
|
||||
if blockedUntil > nowMs then
|
||||
return {
|
||||
tonumber(redis.call("HGET", KEYS[1], "hits")) or 0,
|
||||
redis.call("PTTL", KEYS[1]),
|
||||
blockedUntil - nowMs
|
||||
}
|
||||
end
|
||||
|
||||
if blockedUntil > 0 then
|
||||
redis.call("HDEL", KEYS[1], "blockedUntil")
|
||||
redis.call("HSET", KEYS[1], "hits", 0)
|
||||
end
|
||||
|
||||
local hits = redis.call("HINCRBY", KEYS[1], "hits", 1)
|
||||
if hits == 1 then
|
||||
redis.call("PEXPIRE", KEYS[1], ARGV[1])
|
||||
end
|
||||
|
||||
local blockTtl = 0
|
||||
if hits > tonumber(ARGV[2]) then
|
||||
blockedUntil = nowMs + tonumber(ARGV[3])
|
||||
redis.call("HSET", KEYS[1], "blockedUntil", blockedUntil)
|
||||
if redis.call("PTTL", KEYS[1]) < tonumber(ARGV[3]) then
|
||||
redis.call("PEXPIRE", KEYS[1], ARGV[3])
|
||||
end
|
||||
blockTtl = tonumber(ARGV[3])
|
||||
end
|
||||
|
||||
return { hits, redis.call("PTTL", KEYS[1]), blockTtl }
|
||||
`;
|
||||
|
||||
@Injectable()
|
||||
export class ThrottlerStorage extends ThrottlerStorageService {
|
||||
constructor(private readonly redis: CacheRedis) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async increment(
|
||||
key: string,
|
||||
ttl: number,
|
||||
limit: number,
|
||||
blockDuration: number,
|
||||
throttlerName: string
|
||||
) {
|
||||
if (env.testing) {
|
||||
return super.increment(key, ttl, limit, blockDuration, throttlerName);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.redis.eval(
|
||||
REDIS_THROTTLE_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
ttl,
|
||||
limit,
|
||||
Math.max(blockDuration, 1)
|
||||
);
|
||||
if (!Array.isArray(result) || result.length !== 3) {
|
||||
throw new Error('Unexpected Redis throttler response');
|
||||
}
|
||||
|
||||
const totalHits = Number(result[0]);
|
||||
const timeToExpire = Math.max(0, Math.ceil(Number(result[1]) / 1000));
|
||||
const timeToBlockExpire = Math.max(
|
||||
0,
|
||||
Math.ceil(Number(result[2]) / 1000)
|
||||
);
|
||||
|
||||
return {
|
||||
totalHits,
|
||||
timeToExpire,
|
||||
isBlocked: timeToBlockExpire > 0,
|
||||
timeToBlockExpire,
|
||||
};
|
||||
} catch {
|
||||
// Preserve availability if Redis is unavailable. The inherited local
|
||||
// storage still protects each process while the shared limiter recovers.
|
||||
return super.increment(key, ttl, limit, blockDuration, throttlerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
class CustomOptionsFactory implements ThrottlerOptionsFactory {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly storage: ThrottlerStorage
|
||||
) {}
|
||||
|
||||
createThrottlerOptions() {
|
||||
const options: ThrottlerModuleOptions = {
|
||||
throttlers: Object.entries(this.config.throttle.throttlers).map(
|
||||
([name, config]) => ({
|
||||
name,
|
||||
...config,
|
||||
})
|
||||
),
|
||||
storage: this.storage,
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CloudThrottlerGuard extends ThrottlerGuard {
|
||||
constructor(
|
||||
@InjectThrottlerOptions() options: ThrottlerModuleOptions,
|
||||
@InjectThrottlerStorage() storageService: ThrottlerStorage,
|
||||
reflector: Reflector,
|
||||
private readonly config: Config
|
||||
) {
|
||||
super(options, storageService, reflector);
|
||||
}
|
||||
|
||||
override getRequestResponse(context: ExecutionContext): {
|
||||
req: Request;
|
||||
res: Response;
|
||||
} {
|
||||
return getRequestResponseFromContext(context) as any;
|
||||
}
|
||||
|
||||
override getTracker(req: Request): Promise<string> {
|
||||
// throttler prefix make the key in store recognizable
|
||||
return Promise.resolve(`throttler:${getRequestTrackerId(req)}`);
|
||||
}
|
||||
|
||||
override generateKey(
|
||||
context: ExecutionContext,
|
||||
tracker: string,
|
||||
throttler: string
|
||||
) {
|
||||
if (tracker.endsWith(';custom')) {
|
||||
return `${tracker};${throttler}:${context.getClass().name}.${context.getHandler().name}`;
|
||||
}
|
||||
|
||||
return `${tracker};${throttler}`;
|
||||
}
|
||||
|
||||
override async handleRequest(request: ThrottlerRequest) {
|
||||
const {
|
||||
context,
|
||||
throttler: throttlerOptions,
|
||||
ttl,
|
||||
blockDuration,
|
||||
} = request;
|
||||
|
||||
let limit = request.limit;
|
||||
|
||||
// give it 'default' if no throttler is specified,
|
||||
// so the unauthenticated users visits will always hit default throttler
|
||||
// authenticated users will directly bypass unprotected APIs in [CloudThrottlerGuard.canActivate]
|
||||
const throttler = this.getSpecifiedThrottler(context) ?? 'default';
|
||||
|
||||
// by pass unmatched throttlers
|
||||
if (throttlerOptions.name !== throttler) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { req, res } = this.getRequestResponse(context);
|
||||
const ignoreUserAgents =
|
||||
throttlerOptions.ignoreUserAgents ?? this.commonOptions.ignoreUserAgents;
|
||||
if (Array.isArray(ignoreUserAgents)) {
|
||||
for (const pattern of ignoreUserAgents) {
|
||||
const ua = req.headers['user-agent'];
|
||||
if (ua && pattern.test(ua)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tracker = await this.getTracker(req);
|
||||
|
||||
// custom limit or ttl APIs will be treated standalone
|
||||
if (limit !== throttlerOptions.limit || ttl !== throttlerOptions.ttl) {
|
||||
tracker += ';custom';
|
||||
}
|
||||
|
||||
const key = this.generateKey(
|
||||
context,
|
||||
tracker,
|
||||
throttlerOptions.name ?? 'default'
|
||||
);
|
||||
const { timeToExpire, totalHits, isBlocked, timeToBlockExpire } =
|
||||
await this.storageService.increment(key, ttl, limit, blockDuration, key);
|
||||
|
||||
if (isBlocked) {
|
||||
res.header('Retry-After', timeToBlockExpire.toString());
|
||||
await this.throwThrottlingException(context, {
|
||||
limit,
|
||||
ttl,
|
||||
key,
|
||||
tracker,
|
||||
totalHits,
|
||||
timeToExpire,
|
||||
isBlocked,
|
||||
timeToBlockExpire,
|
||||
});
|
||||
}
|
||||
|
||||
res.header(`${this.headerPrefix}-Limit`, limit.toString());
|
||||
res.header(
|
||||
`${this.headerPrefix}-Remaining`,
|
||||
(limit - totalHits).toString()
|
||||
);
|
||||
res.header(`${this.headerPrefix}-Reset`, timeToExpire.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
override async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (!this.config.throttle.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { req } = this.getRequestResponse(context);
|
||||
|
||||
const throttler = this.getSpecifiedThrottler(context);
|
||||
|
||||
// if user is logged in, bypass non-protected handlers
|
||||
if (!throttler && req.session?.user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
getSpecifiedThrottler(context: ExecutionContext): ThrottlerType | undefined {
|
||||
const throttler = this.reflector.getAllAndOverride<Throttlers | undefined>(
|
||||
THROTTLER_PROTECTED,
|
||||
[context.getHandler(), context.getClass()]
|
||||
);
|
||||
|
||||
return throttler === 'authenticated' ? undefined : throttler;
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ThrottlerModule.forRootAsync({
|
||||
useClass: CustomOptionsFactory,
|
||||
}),
|
||||
],
|
||||
providers: [ThrottlerStorage, CloudThrottlerGuard],
|
||||
exports: [ThrottlerStorage, CloudThrottlerGuard],
|
||||
})
|
||||
export class RateLimiterModule {}
|
||||
|
||||
export * from './decorators';
|
||||
@@ -0,0 +1,135 @@
|
||||
# Snapshot report for `src/base/utils/__tests__/duration.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `duration.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should parse duration strings correctly
|
||||
|
||||
> parser - 1ms
|
||||
|
||||
'{"ms":1}'
|
||||
|
||||
> ms - 1ms
|
||||
|
||||
1
|
||||
|
||||
> parser - 1s
|
||||
|
||||
'{"s":1}'
|
||||
|
||||
> ms - 1s
|
||||
|
||||
1000
|
||||
|
||||
> parser - 1m
|
||||
|
||||
'{"m":1}'
|
||||
|
||||
> ms - 1m
|
||||
|
||||
60000
|
||||
|
||||
> parser - 1h
|
||||
|
||||
'{"h":1}'
|
||||
|
||||
> ms - 1h
|
||||
|
||||
3600000
|
||||
|
||||
> parser - 1d
|
||||
|
||||
'{"d":1}'
|
||||
|
||||
> ms - 1d
|
||||
|
||||
86400000
|
||||
|
||||
> parser - 1w
|
||||
|
||||
'{"w":1}'
|
||||
|
||||
> ms - 1w
|
||||
|
||||
604800000
|
||||
|
||||
> parser - 1M
|
||||
|
||||
'{"M":1}'
|
||||
|
||||
> ms - 1M
|
||||
|
||||
2592000000
|
||||
|
||||
> parser - 1y
|
||||
|
||||
'{"y":1}'
|
||||
|
||||
> ms - 1y
|
||||
|
||||
31536000000
|
||||
|
||||
> parser - 1000ms
|
||||
|
||||
'{"ms":1000}'
|
||||
|
||||
> ms - 1000ms
|
||||
|
||||
1000
|
||||
|
||||
> parser - 60s
|
||||
|
||||
'{"s":60}'
|
||||
|
||||
> ms - 60s
|
||||
|
||||
60000
|
||||
|
||||
> parser - 30m
|
||||
|
||||
'{"m":30}'
|
||||
|
||||
> ms - 30m
|
||||
|
||||
1800000
|
||||
|
||||
> parser - 1h30m
|
||||
|
||||
'{"h":1,"m":30}'
|
||||
|
||||
> ms - 1h30m
|
||||
|
||||
5400000
|
||||
|
||||
> parser - 15d
|
||||
|
||||
'{"d":15}'
|
||||
|
||||
> ms - 15d
|
||||
|
||||
1296000000
|
||||
|
||||
> parser - 1y
|
||||
|
||||
'{"y":1}'
|
||||
|
||||
> ms - 1y
|
||||
|
||||
31536000000
|
||||
|
||||
> parser - 12M
|
||||
|
||||
'{"M":12}'
|
||||
|
||||
> ms - 12M
|
||||
|
||||
31104000000
|
||||
|
||||
> parser - 1y1M1d1h1m1s1ms
|
||||
|
||||
'{"y":1,"M":1,"d":1,"h":1,"m":1,"s":1,"ms":1}'
|
||||
|
||||
> ms - 1y1M1d1h1m1s1ms
|
||||
|
||||
34218061001
|
||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { Due } from '../duration';
|
||||
|
||||
test('should parse duration strings correctly', t => {
|
||||
const testcases = [
|
||||
'1ms',
|
||||
'1s',
|
||||
'1m',
|
||||
'1h',
|
||||
'1d',
|
||||
'1w',
|
||||
'1M',
|
||||
'1y',
|
||||
'1000ms',
|
||||
'60s',
|
||||
'30m',
|
||||
'1h30m',
|
||||
'15d',
|
||||
'1y',
|
||||
'12M',
|
||||
'1y1M1d1h1m1s1ms',
|
||||
];
|
||||
|
||||
for (const str of testcases) {
|
||||
t.snapshot(JSON.stringify(Due.parse(str)), `parser - ${str}`);
|
||||
t.snapshot(Due.ms(str), `ms - ${str}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('should calc relative time correctly', t => {
|
||||
const date = new Date();
|
||||
t.is(Due.before('1d', date).getTime(), date.getTime() - 1000 * 60 * 60 * 24);
|
||||
|
||||
const date2 = new Date();
|
||||
t.is(Due.after('1d', date2).getTime(), date2.getTime() + 1000 * 60 * 60 * 24);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import {
|
||||
exponentialBackoffDelay,
|
||||
ExponentialBackoffScheduler,
|
||||
} from '../promise';
|
||||
|
||||
test('exponentialBackoffDelay should cap exponential growth at maxDelayMs', t => {
|
||||
t.is(exponentialBackoffDelay(0, { baseDelayMs: 100, maxDelayMs: 500 }), 100);
|
||||
t.is(exponentialBackoffDelay(1, { baseDelayMs: 100, maxDelayMs: 500 }), 200);
|
||||
t.is(exponentialBackoffDelay(3, { baseDelayMs: 100, maxDelayMs: 500 }), 500);
|
||||
});
|
||||
|
||||
test('ExponentialBackoffScheduler should track pending callback and increase delay per attempt', async t => {
|
||||
const clock = Sinon.useFakeTimers();
|
||||
t.teardown(() => {
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
const calls: number[] = [];
|
||||
const scheduler = new ExponentialBackoffScheduler({
|
||||
baseDelayMs: 100,
|
||||
maxDelayMs: 500,
|
||||
});
|
||||
|
||||
t.is(
|
||||
scheduler.schedule(() => {
|
||||
calls.push(1);
|
||||
}),
|
||||
100
|
||||
);
|
||||
t.true(scheduler.pending);
|
||||
t.is(
|
||||
scheduler.schedule(() => {
|
||||
calls.push(2);
|
||||
}),
|
||||
null
|
||||
);
|
||||
|
||||
await clock.tickAsync(100);
|
||||
t.deepEqual(calls, [1]);
|
||||
t.false(scheduler.pending);
|
||||
|
||||
t.is(
|
||||
scheduler.schedule(() => {
|
||||
calls.push(3);
|
||||
}),
|
||||
200
|
||||
);
|
||||
await clock.tickAsync(200);
|
||||
t.deepEqual(calls, [1, 3]);
|
||||
});
|
||||
|
||||
test('ExponentialBackoffScheduler reset should clear pending work and restart from the base delay', t => {
|
||||
const scheduler = new ExponentialBackoffScheduler({
|
||||
baseDelayMs: 100,
|
||||
maxDelayMs: 500,
|
||||
});
|
||||
|
||||
t.is(
|
||||
scheduler.schedule(() => {}),
|
||||
100
|
||||
);
|
||||
t.true(scheduler.pending);
|
||||
|
||||
scheduler.reset();
|
||||
t.false(scheduler.pending);
|
||||
t.is(
|
||||
scheduler.schedule(() => {}),
|
||||
100
|
||||
);
|
||||
|
||||
scheduler.clear();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from 'ava';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { getClientVersionFromRequest } from '../request';
|
||||
|
||||
test('should get client version from x-affine-version header', t => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-affine-version': '0.22.2',
|
||||
},
|
||||
} as unknown as Request;
|
||||
|
||||
t.is(getClientVersionFromRequest(req), '0.22.2');
|
||||
|
||||
const req2 = {
|
||||
headers: {
|
||||
'x-affine-version': ['0.22.2', '0.23.0-beta.2'],
|
||||
},
|
||||
} as unknown as Request;
|
||||
|
||||
t.is(getClientVersionFromRequest(req2), '0.22.2');
|
||||
});
|
||||
|
||||
test('should not get client version from x-affine-version header', t => {
|
||||
const req = {
|
||||
headers: {
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.205 Safari/537.36',
|
||||
},
|
||||
} as unknown as Request;
|
||||
|
||||
t.is(getClientVersionFromRequest(req), undefined);
|
||||
});
|
||||
116
packages/backend/server/src/base/utils/client-version.ts
Normal file
116
packages/backend/server/src/base/utils/client-version.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import semver from 'semver';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Example: 2026.2.6-canary.015
|
||||
const CANARY_DATE_VERSION_RE =
|
||||
/^v?(\d{4})\.(\d{1,2})\.(\d{1,2})-canary\.(\d+)(?:\+.*)?$/i;
|
||||
|
||||
export const CANARY_CLIENT_VERSION_MAX_AGE_DAYS = 62; // ~2 months
|
||||
export const CANARY_CLIENT_VERSION_MAX_FUTURE_SKEW_DAYS = 2;
|
||||
|
||||
export type CanaryDateClientVersion = {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
dateMs: number;
|
||||
};
|
||||
|
||||
export function parseCanaryDateClientVersion(
|
||||
version: string
|
||||
): CanaryDateClientVersion | null {
|
||||
const raw = version.trim();
|
||||
const match = CANARY_DATE_VERSION_RE.exec(raw);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number.parseInt(match[1], 10);
|
||||
const month = Number.parseInt(match[2], 10);
|
||||
const day = Number.parseInt(match[3], 10);
|
||||
const build = match[4].replace(/^0+(?=\d)/, '');
|
||||
|
||||
if (
|
||||
!Number.isInteger(year) ||
|
||||
!Number.isInteger(month) ||
|
||||
!Number.isInteger(day) ||
|
||||
year < 0 ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateMs = Date.UTC(year, month - 1, day);
|
||||
const date = new Date(dateMs);
|
||||
if (
|
||||
date.getUTCFullYear() !== year ||
|
||||
date.getUTCMonth() !== month - 1 ||
|
||||
date.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
normalized: `${year}.${month}.${day}-canary.${build}`,
|
||||
dateMs,
|
||||
};
|
||||
}
|
||||
|
||||
export type CanaryClientVersionCheckResult =
|
||||
| { matched: false }
|
||||
| { matched: true; allowed: boolean; normalized: string };
|
||||
|
||||
export function checkCanaryDateClientVersion(
|
||||
version: string,
|
||||
options?: {
|
||||
nowMs?: number;
|
||||
maxAgeDays?: number;
|
||||
maxFutureSkewDays?: number;
|
||||
}
|
||||
): CanaryClientVersionCheckResult {
|
||||
const parsed = parseCanaryDateClientVersion(version);
|
||||
if (!parsed) {
|
||||
return { matched: false };
|
||||
}
|
||||
|
||||
const nowMs = options?.nowMs ?? Date.now();
|
||||
const maxAgeDays = options?.maxAgeDays ?? CANARY_CLIENT_VERSION_MAX_AGE_DAYS;
|
||||
const maxFutureSkewDays =
|
||||
options?.maxFutureSkewDays ?? CANARY_CLIENT_VERSION_MAX_FUTURE_SKEW_DAYS;
|
||||
|
||||
const ageMs = nowMs - parsed.dateMs;
|
||||
const maxAgeMs = maxAgeDays * DAY_MS;
|
||||
const maxFutureSkewMs = maxFutureSkewDays * DAY_MS;
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
allowed: ageMs <= maxAgeMs && ageMs >= -maxFutureSkewMs,
|
||||
normalized: parsed.normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeComparableVersion(version: string): string | null {
|
||||
const canary = parseCanaryDateClientVersion(version);
|
||||
return semver.valid(canary?.normalized ?? version.trim(), {
|
||||
loose: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasNewerVersion(
|
||||
currentVersion: string,
|
||||
nextVersion: string
|
||||
): boolean {
|
||||
const current = normalizeComparableVersion(currentVersion);
|
||||
const next = normalizeComparableVersion(nextVersion);
|
||||
|
||||
if (!current || !next) {
|
||||
return currentVersion.trim() !== nextVersion.trim();
|
||||
}
|
||||
|
||||
return semver.gt(next, current, {
|
||||
loose: true,
|
||||
});
|
||||
}
|
||||
78
packages/backend/server/src/base/utils/duration.ts
Normal file
78
packages/backend/server/src/base/utils/duration.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
type DurationUnit = 'd' | 'w' | 'M' | 'y' | 'h' | 'm' | 's' | 'ms';
|
||||
type DurationInput = Partial<Record<DurationUnit, number>>;
|
||||
|
||||
const UnitToSecMap: Record<DurationUnit, number> = {
|
||||
ms: 0.001,
|
||||
s: 1,
|
||||
m: 60,
|
||||
h: 3600,
|
||||
d: 24 * 3600,
|
||||
w: 7 * 24 * 3600,
|
||||
M: 30 * 24 * 3600,
|
||||
y: 365 * 24 * 3600,
|
||||
};
|
||||
|
||||
const KnownCharCodeToCharMap: Record<number, DurationUnit> = {
|
||||
100: 'd',
|
||||
119: 'w',
|
||||
77: 'M',
|
||||
121: 'y',
|
||||
104: 'h',
|
||||
109: 'm',
|
||||
115: 's',
|
||||
};
|
||||
|
||||
function parse(str: string): DurationInput {
|
||||
let input: DurationInput = {};
|
||||
|
||||
let acc = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
const code = ch.charCodeAt(0);
|
||||
|
||||
// number [0..9]
|
||||
if (code >= 48 && code <= 57) {
|
||||
acc = acc * 10 + code - 48;
|
||||
} else {
|
||||
let unit = KnownCharCodeToCharMap[code];
|
||||
if (!unit) {
|
||||
throw new Error(`Invalid duration string unit ${ch}`);
|
||||
}
|
||||
|
||||
// look ahead a char for 'ms' checking if unit met 'm'
|
||||
if (unit === 'm' && str[i + 1] === 's') {
|
||||
unit = 'ms';
|
||||
i++;
|
||||
}
|
||||
|
||||
input[unit] = acc;
|
||||
acc = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export const Due = {
|
||||
ms: (dueStr: string | DurationInput) => {
|
||||
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
|
||||
return Object.entries(input).reduce((duration, [unit, val]) => {
|
||||
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0) * 1000;
|
||||
}, 0);
|
||||
},
|
||||
s: (dueStr: string | DurationInput) => {
|
||||
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
|
||||
return Object.entries(input).reduce((duration, [unit, val]) => {
|
||||
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0);
|
||||
}, 0);
|
||||
},
|
||||
parse,
|
||||
after: (dueStr: string | number | DurationInput, date?: Date) => {
|
||||
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
|
||||
return new Date((date?.getTime() ?? Date.now()) + timestamp);
|
||||
},
|
||||
before: (dueStr: string | number | DurationInput, date?: Date) => {
|
||||
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
|
||||
return new Date((date?.getTime() ?? Date.now()) - timestamp);
|
||||
},
|
||||
};
|
||||
9
packages/backend/server/src/base/utils/index.ts
Normal file
9
packages/backend/server/src/base/utils/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './client-version';
|
||||
export * from './duration';
|
||||
export * from './promise';
|
||||
export * from './request';
|
||||
export * from './request-tracker';
|
||||
export * from './ssrf';
|
||||
export * from './stream';
|
||||
export * from './types';
|
||||
export * from './unit';
|
||||
112
packages/backend/server/src/base/utils/promise.ts
Normal file
112
packages/backend/server/src/base/utils/promise.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { defer as rxjsDefer, retry } from 'rxjs';
|
||||
|
||||
export class RetryablePromise<T> extends Promise<T> {
|
||||
constructor(
|
||||
executor: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (reason?: any) => void
|
||||
) => void,
|
||||
retryTimes: number = 3,
|
||||
retryIntervalInMs: number = 300
|
||||
) {
|
||||
super((resolve, reject) => {
|
||||
rxjsDefer(() => new Promise<T>(executor))
|
||||
.pipe(
|
||||
retry({
|
||||
count: retryTimes,
|
||||
delay: retryIntervalInMs,
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: v => {
|
||||
resolve(v);
|
||||
},
|
||||
error: e => {
|
||||
reject(e);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function retryable<Ret = unknown>(
|
||||
asyncFn: () => Promise<Ret>,
|
||||
retryTimes = 3,
|
||||
retryIntervalInMs = 300
|
||||
): Promise<Ret> {
|
||||
return new RetryablePromise<Ret>(
|
||||
(resolve, reject) => {
|
||||
asyncFn().then(resolve).catch(reject);
|
||||
},
|
||||
retryTimes,
|
||||
retryIntervalInMs
|
||||
);
|
||||
}
|
||||
|
||||
export function defer(dispose: () => Promise<void>) {
|
||||
return {
|
||||
[Symbol.asyncDispose]: dispose,
|
||||
};
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return delay(ms);
|
||||
}
|
||||
|
||||
export function exponentialBackoffDelay(
|
||||
attempt: number,
|
||||
{
|
||||
baseDelayMs,
|
||||
maxDelayMs,
|
||||
factor = 2,
|
||||
}: { baseDelayMs: number; maxDelayMs: number; factor?: number }
|
||||
): number {
|
||||
return Math.min(
|
||||
baseDelayMs * Math.pow(factor, Math.max(0, attempt)),
|
||||
maxDelayMs
|
||||
);
|
||||
}
|
||||
|
||||
export class ExponentialBackoffScheduler {
|
||||
#attempt = 0;
|
||||
#timer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
factor?: number;
|
||||
}
|
||||
) {}
|
||||
|
||||
get pending() {
|
||||
return this.#timer !== null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.#timer) {
|
||||
clearTimeout(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#attempt = 0;
|
||||
this.clear();
|
||||
}
|
||||
|
||||
schedule(callback: () => void) {
|
||||
if (this.#timer) return null;
|
||||
|
||||
const timeout = exponentialBackoffDelay(this.#attempt, this.options);
|
||||
this.#timer = globalThis.setTimeout(() => {
|
||||
this.#timer = null;
|
||||
callback();
|
||||
}, timeout);
|
||||
this.#attempt += 1;
|
||||
|
||||
return timeout;
|
||||
}
|
||||
}
|
||||
47
packages/backend/server/src/base/utils/request-tracker.ts
Normal file
47
packages/backend/server/src/base/utils/request-tracker.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
function firstForwardedForIp(value?: string) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [first] = value.split(',', 1);
|
||||
const ip = first?.trim();
|
||||
|
||||
return ip || undefined;
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values: Array<string | undefined>) {
|
||||
for (const value of values) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export function getRequestClientIp(req: Request) {
|
||||
return (
|
||||
firstNonEmpty(
|
||||
req.get('CF-Connecting-IP'),
|
||||
firstForwardedForIp(req.get('X-Forwarded-For')),
|
||||
req.get('X-Real-IP'),
|
||||
req.ip
|
||||
) ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
export function getRequestTrackerId(req: Request) {
|
||||
return (
|
||||
req.session?.sessionId ??
|
||||
firstNonEmpty(
|
||||
req.get('CF-Connecting-IP'),
|
||||
firstForwardedForIp(req.get('X-Forwarded-For')),
|
||||
req.get('X-Real-IP'),
|
||||
req.get('CF-Ray'),
|
||||
req.ip
|
||||
) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
172
packages/backend/server/src/base/utils/request.ts
Normal file
172
packages/backend/server/src/base/utils/request.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { IncomingMessage } from 'node:http';
|
||||
|
||||
import type { ArgumentsHost, ExecutionContext } from '@nestjs/common';
|
||||
import type { GqlContextType } from '@nestjs/graphql';
|
||||
import { GqlArgumentsHost } from '@nestjs/graphql';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ClsServiceManager } from 'nestjs-cls';
|
||||
import type { Socket } from 'socket.io';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RequestResponse = {
|
||||
req: Request;
|
||||
res?: Response;
|
||||
};
|
||||
|
||||
const RequestCookieValueSchema = z.string().min(1);
|
||||
const RequestHeaderValueSchema = z.string().min(1);
|
||||
|
||||
export function getRequestResponseFromHost(
|
||||
host: ArgumentsHost
|
||||
): RequestResponse {
|
||||
switch (host.getType<GqlContextType>()) {
|
||||
case 'graphql': {
|
||||
const gqlContext = GqlArgumentsHost.create(host).getContext<{
|
||||
req: Request;
|
||||
}>();
|
||||
return {
|
||||
req: gqlContext.req,
|
||||
res: gqlContext.req.res,
|
||||
};
|
||||
}
|
||||
case 'http': {
|
||||
const http = host.switchToHttp();
|
||||
return {
|
||||
req: http.getRequest<Request>(),
|
||||
res: http.getResponse<Response>(),
|
||||
};
|
||||
}
|
||||
case 'ws': {
|
||||
const ws = host.switchToWs();
|
||||
const req = ws.getClient<Socket>().request as Request;
|
||||
parseCookies(req);
|
||||
return { req };
|
||||
}
|
||||
case 'rpc': {
|
||||
const rpc = host.switchToRpc();
|
||||
const { req } = rpc.getContext<{ req: Request }>();
|
||||
|
||||
return {
|
||||
req,
|
||||
res: req.res,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequestFromHost(host: ArgumentsHost): Request {
|
||||
return getRequestResponseFromHost(host).req;
|
||||
}
|
||||
|
||||
export function getRequestResponseFromContext(
|
||||
ctx: ExecutionContext
|
||||
): RequestResponse {
|
||||
return getRequestResponseFromHost(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* simple patch for request not protected by `cookie-parser`
|
||||
* only take effect if `req.cookies` is not defined
|
||||
*/
|
||||
export function parseCookies(
|
||||
req: IncomingMessage & { cookies?: Record<string, string> }
|
||||
) {
|
||||
if (req.cookies) return;
|
||||
|
||||
const cookieStr = req.headers.cookie ?? '';
|
||||
req.cookies = cookieStr.split(';').reduce(
|
||||
(cookies, cookie) => {
|
||||
const [key, val] = cookie.split('=');
|
||||
|
||||
if (key) {
|
||||
const rawKey = key.trim();
|
||||
const rawVal = val ? val.trim() : val;
|
||||
|
||||
let safeKey = rawKey;
|
||||
let safeVal = rawVal;
|
||||
|
||||
try {
|
||||
safeKey = decodeURIComponent(rawKey);
|
||||
} catch {}
|
||||
|
||||
if (rawVal) {
|
||||
try {
|
||||
safeVal = decodeURIComponent(rawVal);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
cookies[safeKey] = safeVal;
|
||||
}
|
||||
|
||||
return cookies;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
export function getRequestCookie(
|
||||
req: IncomingMessage & { cookies?: Record<string, unknown> },
|
||||
name: string
|
||||
) {
|
||||
parseCookies(req as IncomingMessage & { cookies?: Record<string, string> });
|
||||
|
||||
const value = req.cookies?.[name];
|
||||
|
||||
const parsed = RequestCookieValueSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
export function getRequestHeader(req: IncomingMessage, name: string) {
|
||||
const value = req.headers[name.toLowerCase()];
|
||||
|
||||
const parsed = RequestHeaderValueSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request type
|
||||
*
|
||||
* @description
|
||||
* - `graphql`: graphql request
|
||||
* - `http`: http request
|
||||
* - `ws`: websocket request
|
||||
* - `event`: event
|
||||
* - `job`: cron job
|
||||
* - `rpc`: rpc request
|
||||
*/
|
||||
export type RequestType = GqlContextType | 'event' | 'job';
|
||||
|
||||
export function genRequestId(type: RequestType) {
|
||||
return `${env.DEPLOYMENT_TYPE}:${type}:${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function getOrGenRequestId(type: RequestType) {
|
||||
// The request id must exist in a cls context,
|
||||
// but it can be lost in unexpected scenarios, such as unit tests, where it is automatically generated.
|
||||
return ClsServiceManager.getClsService()?.getId() ?? genRequestId(type);
|
||||
}
|
||||
|
||||
export function getRequestIdFromRequest(req: Request, type: RequestType) {
|
||||
const traceContext = req.headers['x-cloud-trace-context'] as string;
|
||||
const traceId = traceContext ? traceContext.split('/', 1)[0] : undefined;
|
||||
if (traceId) return traceId;
|
||||
return genRequestId(type);
|
||||
}
|
||||
|
||||
export function getRequestIdFromHost(host: ArgumentsHost) {
|
||||
const type = host.getType<GqlContextType>();
|
||||
if (type === 'ws') {
|
||||
return genRequestId(type);
|
||||
}
|
||||
const req = getRequestFromHost(host);
|
||||
return getRequestIdFromRequest(req, type);
|
||||
}
|
||||
|
||||
export function getClientVersionFromRequest(req: Request) {
|
||||
let version = req.headers['x-affine-version'];
|
||||
if (Array.isArray(version)) {
|
||||
version = version[0];
|
||||
}
|
||||
return version;
|
||||
}
|
||||
153
packages/backend/server/src/base/utils/ssrf.ts
Normal file
153
packages/backend/server/src/base/utils/ssrf.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
assertSafeUrl as assertSafeUrlFromNative,
|
||||
safeFetch as safeFetchFromNative,
|
||||
type SafeFetchRequest,
|
||||
} from '../../native';
|
||||
import { ResponseTooLargeError, SsrfBlockedError } from '../error/errors.gen';
|
||||
|
||||
export type SSRFBlockReason =
|
||||
| 'invalid_url'
|
||||
| 'disallowed_protocol'
|
||||
| 'url_has_credentials'
|
||||
| 'blocked_hostname'
|
||||
| 'host_not_allowed'
|
||||
| 'unresolvable_hostname'
|
||||
| 'blocked_ip'
|
||||
| 'too_many_redirects';
|
||||
|
||||
const SSRF_REASONS = new Set<string>([
|
||||
'invalid_url',
|
||||
'disallowed_protocol',
|
||||
'url_has_credentials',
|
||||
'blocked_hostname',
|
||||
'host_not_allowed',
|
||||
'unresolvable_hostname',
|
||||
'blocked_ip',
|
||||
'too_many_redirects',
|
||||
]);
|
||||
|
||||
function createSsrfBlockedError(reason: SSRFBlockReason) {
|
||||
return new SsrfBlockedError({ reason });
|
||||
}
|
||||
|
||||
function mapNativeFetchError(error: unknown, limitBytes?: number) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const reason = [...SSRF_REASONS].find(reason => message.includes(reason));
|
||||
if (reason) {
|
||||
return createSsrfBlockedError(reason as SSRFBlockReason);
|
||||
}
|
||||
if (message.includes('response_too_large')) {
|
||||
return new ResponseTooLargeError({
|
||||
limitBytes: limitBytes ?? 0,
|
||||
receivedBytes: limitBytes ? limitBytes + 1 : 0,
|
||||
});
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
export interface SafeFetchOptions {
|
||||
timeoutMs?: number;
|
||||
maxRedirects?: number;
|
||||
maxBytes?: number;
|
||||
allowedHeaders?: string[];
|
||||
allowedHosts?: string[];
|
||||
allowHttp?: boolean;
|
||||
allowPrivateTargetOrigin?: boolean;
|
||||
enableEch?: boolean;
|
||||
echConfigList?: Buffer;
|
||||
}
|
||||
|
||||
export async function assertSsrFSafeUrl(rawUrl: string | URL): Promise<URL> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = rawUrl instanceof URL ? rawUrl : new URL(rawUrl);
|
||||
} catch {
|
||||
throw createSsrfBlockedError('invalid_url');
|
||||
}
|
||||
|
||||
try {
|
||||
assertSafeUrlFromNative({ url: url.toString() });
|
||||
return url;
|
||||
} catch (error) {
|
||||
throw mapNativeFetchError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeFetch(
|
||||
rawUrl: string | URL,
|
||||
init: RequestInit = {},
|
||||
options: SafeFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
const url = rawUrl.toString();
|
||||
const method = String(init.method ?? 'GET').toUpperCase();
|
||||
if (!['GET', 'HEAD', 'POST', 'PUT', 'PROPFIND', 'REPORT'].includes(method)) {
|
||||
throw new Error(`Unsupported safeFetch method: ${method}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetchFromNative({
|
||||
url,
|
||||
method: method.toLowerCase() as NonNullable<SafeFetchRequest['method']>,
|
||||
headers: normalizeHeaders(init.headers),
|
||||
body: normalizeBody(init.body),
|
||||
timeoutMs: options.timeoutMs,
|
||||
maxRedirects: options.maxRedirects,
|
||||
maxBytes: options.maxBytes,
|
||||
allowedHeaders: options.allowedHeaders,
|
||||
allowedHosts: options.allowedHosts,
|
||||
allowHttp: options.allowHttp,
|
||||
allowPrivateTargetOrigin: options.allowPrivateTargetOrigin,
|
||||
enableEch: options.enableEch,
|
||||
echConfigList: options.echConfigList,
|
||||
});
|
||||
const body =
|
||||
method === 'HEAD' || [204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: response.body;
|
||||
const webResponse = new Response(body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
Object.defineProperty(webResponse, 'url', {
|
||||
value: response.finalUrl,
|
||||
});
|
||||
return webResponse;
|
||||
} catch (error) {
|
||||
throw mapNativeFetchError(error, options.maxBytes);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: RequestInit['headers'] | undefined) {
|
||||
if (!headers) return undefined;
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBody(body: RequestInit['body'] | null | undefined) {
|
||||
if (body === null || body === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
return Buffer.from(body);
|
||||
}
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return Buffer.from(body);
|
||||
}
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
||||
}
|
||||
throw new Error('Unsupported safeFetch body type.');
|
||||
}
|
||||
|
||||
export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
const copy = new Uint8Array(buffer.byteLength);
|
||||
copy.set(buffer);
|
||||
return copy.buffer;
|
||||
}
|
||||
71
packages/backend/server/src/base/utils/stream.ts
Normal file
71
packages/backend/server/src/base/utils/stream.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { BlobQuotaExceeded, StorageQuotaExceeded } from '../error';
|
||||
import { OneKB } from './unit';
|
||||
|
||||
export type CheckExceededResult =
|
||||
| {
|
||||
storageQuotaExceeded: boolean;
|
||||
blobQuotaExceeded: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export async function readBuffer(
|
||||
readable: Readable,
|
||||
checkExceeded: (recvSize: number) => CheckExceededResult
|
||||
): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalSize = 0;
|
||||
let result: CheckExceededResult;
|
||||
|
||||
readable.on('data', chunk => {
|
||||
totalSize += chunk.length;
|
||||
|
||||
// check size after receive each chunk to avoid unnecessary memory usage
|
||||
result = checkExceeded(totalSize);
|
||||
if (result?.blobQuotaExceeded) {
|
||||
reject(new BlobQuotaExceeded());
|
||||
} else if (result?.storageQuotaExceeded) {
|
||||
reject(new StorageQuotaExceeded());
|
||||
}
|
||||
|
||||
if (checkExceeded(totalSize)) {
|
||||
reject(new BlobQuotaExceeded());
|
||||
readable.destroy(new BlobQuotaExceeded());
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
readable.on('error', reject);
|
||||
readable.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks, totalSize);
|
||||
|
||||
if (checkExceeded(buffer.length)) {
|
||||
reject(new BlobQuotaExceeded());
|
||||
} else {
|
||||
resolve(buffer);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function readBufferWithLimit(
|
||||
readable: Readable,
|
||||
limit: number = 500 * OneKB
|
||||
): Promise<Buffer> {
|
||||
return readBuffer(readable, size =>
|
||||
size > limit
|
||||
? { blobQuotaExceeded: true, storageQuotaExceeded: false }
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
export async function readableToBuffer(readable: Readable) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
61
packages/backend/server/src/base/utils/types.ts
Normal file
61
packages/backend/server/src/base/utils/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export function ApplyType<T>(): ConstructorOf<T> {
|
||||
// @ts-expect-error used to fake the type of config
|
||||
return class Inner implements T {};
|
||||
}
|
||||
|
||||
export type PathType<T, Path extends string> =
|
||||
T extends Record<string, any>
|
||||
? string extends Path
|
||||
? unknown
|
||||
: Path extends keyof T
|
||||
? T[Path]
|
||||
: Path extends `${infer K}.${infer R}`
|
||||
? K extends keyof T
|
||||
? PathType<T[K], R>
|
||||
: unknown
|
||||
: unknown
|
||||
: unknown;
|
||||
|
||||
export type Join<Prefix, Suffixes> = Prefix extends string | number
|
||||
? Suffixes extends string | number
|
||||
? Prefix extends ''
|
||||
? Suffixes
|
||||
: `${Prefix}.${Suffixes}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type LeafPaths<
|
||||
T,
|
||||
Prefix extends string = '',
|
||||
MaxDepth extends string = '.....',
|
||||
Depth extends string = '',
|
||||
> = Depth extends MaxDepth
|
||||
? never
|
||||
: T extends Record<string | number, any>
|
||||
? {
|
||||
[K in keyof T]-?: K extends string | number
|
||||
? T[K] extends PrimitiveType
|
||||
? K
|
||||
: T[K] extends { __leaf: true }
|
||||
? K
|
||||
: Join<K, LeafPaths<T[K], Prefix, MaxDepth, `${Depth}.`>>
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
export type LeafVisitor<T, P extends string = ''> = {
|
||||
[K in keyof T]: T[K] extends object
|
||||
? LeafVisitor<T[K], Join<P, K>>
|
||||
: P extends ''
|
||||
? K
|
||||
: Join<P, K>;
|
||||
};
|
||||
|
||||
export interface FileUpload {
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
encoding: string;
|
||||
createReadStream: () => Readable;
|
||||
}
|
||||
9
packages/backend/server/src/base/utils/unit.ts
Normal file
9
packages/backend/server/src/base/utils/unit.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const OneKB = 1024;
|
||||
export const OneMB = OneKB * OneKB;
|
||||
export const OneGB = OneKB * OneMB;
|
||||
export const OneMinute = 1000 * 60;
|
||||
export const OneHour = OneMinute * 60;
|
||||
export const OneDay = OneHour * 24;
|
||||
export const OneWeek = OneDay * 7;
|
||||
export const OneMonth = OneDay * 30;
|
||||
export const OneYear = OneDay * 365;
|
||||
89
packages/backend/server/src/base/websocket/adapter.ts
Normal file
89
packages/backend/server/src/base/websocket/adapter.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import { Config } from '../config';
|
||||
import {
|
||||
buildCorsAllowedOrigins,
|
||||
CORS_ALLOWED_HEADERS,
|
||||
CORS_ALLOWED_METHODS,
|
||||
corsOriginCallback,
|
||||
} from '../cors';
|
||||
import { AuthenticationRequired } from '../error';
|
||||
import { URLHelper } from '../helpers';
|
||||
import { AFFiNELogger } from '../logger';
|
||||
import { SocketIoRedis } from '../redis';
|
||||
import { WEBSOCKET_OPTIONS } from './options';
|
||||
|
||||
export class SocketIoAdapter extends IoAdapter {
|
||||
constructor(private readonly app: INestApplication) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
override createIOServer(port: number, options?: any): Server {
|
||||
const logger = this.app.get(AFFiNELogger);
|
||||
const config = this.app.get(WEBSOCKET_OPTIONS) as Config['websocket'] & {
|
||||
canActivate: (socket: Socket) => Promise<boolean>;
|
||||
};
|
||||
const url = this.app.get(URLHelper);
|
||||
const allowedOrigins = buildCorsAllowedOrigins(url);
|
||||
|
||||
const server: Server = super.createIOServer(port, {
|
||||
...config,
|
||||
...options,
|
||||
cors: {
|
||||
origin: (
|
||||
origin: string | undefined,
|
||||
callback: (error: Error | null, allow?: boolean) => void
|
||||
) => {
|
||||
corsOriginCallback(
|
||||
origin,
|
||||
allowedOrigins,
|
||||
blockedOrigin =>
|
||||
logger.warn(
|
||||
`Blocked WebSocket CORS request from origin: ${blockedOrigin}`
|
||||
),
|
||||
callback
|
||||
);
|
||||
},
|
||||
credentials: true,
|
||||
methods: CORS_ALLOWED_METHODS,
|
||||
allowedHeaders: CORS_ALLOWED_HEADERS,
|
||||
},
|
||||
});
|
||||
|
||||
if (config.canActivate) {
|
||||
server.use((socket, next) => {
|
||||
config
|
||||
.canActivate(socket)
|
||||
.then(pass => {
|
||||
if (pass) {
|
||||
next();
|
||||
} else {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
next(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const pubClient = this.app.get(SocketIoRedis);
|
||||
const subClient = pubClient.duplicate();
|
||||
|
||||
server.adapter(createAdapter(pubClient, subClient));
|
||||
const close = server.close;
|
||||
|
||||
server.close = async fn => {
|
||||
await close.call(server, fn);
|
||||
// NOTE(@forehalo):
|
||||
// the lifecycle of duplicated redis client will not be controlled by nestjs lifecycle
|
||||
// we've got to manually disconnect it
|
||||
subClient.disconnect();
|
||||
};
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
34
packages/backend/server/src/base/websocket/config.ts
Normal file
34
packages/backend/server/src/base/websocket/config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { GatewayMetadata } from '@nestjs/websockets';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModuleConfig } from '../config';
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
websocket: {
|
||||
transports: ConfigItem<GatewayMetadata['transports']>;
|
||||
maxHttpBufferSize: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('websocket', {
|
||||
transports: {
|
||||
desc: 'The enabled transports for accepting websocket traffics.',
|
||||
default: ['websocket', 'polling'],
|
||||
shape: z.array(z.enum(['websocket', 'polling'])),
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['websocket', 'polling'],
|
||||
},
|
||||
},
|
||||
link: 'https://docs.nestjs.com/websockets/gateways#transports',
|
||||
},
|
||||
maxHttpBufferSize: {
|
||||
desc: 'How many bytes or characters a message can be, before closing the session (to avoid DoS).',
|
||||
default: 1e8, // 100 MB
|
||||
shape: z.number().int().positive(),
|
||||
},
|
||||
});
|
||||
14
packages/backend/server/src/base/websocket/index.ts
Normal file
14
packages/backend/server/src/base/websocket/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WEBSOCKET_OPTIONS, websocketOptionsProvider } from './options';
|
||||
|
||||
@Module({
|
||||
providers: [websocketOptionsProvider],
|
||||
exports: [websocketOptionsProvider],
|
||||
})
|
||||
export class WebSocketModule {}
|
||||
|
||||
export { WEBSOCKET_OPTIONS };
|
||||
export { SocketIoAdapter } from './adapter';
|
||||
13
packages/backend/server/src/base/websocket/options.ts
Normal file
13
packages/backend/server/src/base/websocket/options.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
|
||||
|
||||
export const websocketOptionsProvider: FactoryProvider = {
|
||||
provide: WEBSOCKET_OPTIONS,
|
||||
useFactory: (config: Config) => {
|
||||
return config.websocket;
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
Reference in New Issue
Block a user