mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 03:26:47 +08:00
chore: rename fundamentals to base (#9119)
This commit is contained in:
+51
@@ -0,0 +1,51 @@
|
||||
export interface CacheSetOptions {
|
||||
/**
|
||||
* in milliseconds
|
||||
*/
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
// extends if needed
|
||||
export interface Cache {
|
||||
// standard operation
|
||||
get<T = unknown>(key: string): Promise<T | undefined>;
|
||||
set<T = unknown>(
|
||||
key: string,
|
||||
value: T,
|
||||
opts?: CacheSetOptions
|
||||
): Promise<boolean>;
|
||||
setnx<T = unknown>(
|
||||
key: string,
|
||||
value: T,
|
||||
opts?: CacheSetOptions
|
||||
): Promise<boolean>;
|
||||
increase(key: string, count?: number): Promise<number>;
|
||||
decrease(key: string, count?: number): Promise<number>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
has(key: string): Promise<boolean>;
|
||||
ttl(key: string): Promise<number>;
|
||||
expire(key: string, ttl: number): Promise<boolean>;
|
||||
|
||||
// list operations
|
||||
pushBack<T = unknown>(key: string, ...values: T[]): Promise<number>;
|
||||
pushFront<T = unknown>(key: string, ...values: T[]): Promise<number>;
|
||||
len(key: string): Promise<number>;
|
||||
list<T = unknown>(key: string, start: number, end: number): Promise<T[]>;
|
||||
popFront<T = unknown>(key: string, count?: number): Promise<T[]>;
|
||||
popBack<T = unknown>(key: string, count?: number): Promise<T[]>;
|
||||
|
||||
// map operations
|
||||
mapSet<T = unknown>(
|
||||
map: string,
|
||||
key: string,
|
||||
value: T,
|
||||
opts: CacheSetOptions
|
||||
): Promise<boolean>;
|
||||
mapIncrease(map: string, key: string, count?: number): Promise<number>;
|
||||
mapDecrease(map: string, key: string, count?: number): Promise<number>;
|
||||
mapGet<T = unknown>(map: string, key: string): Promise<T | undefined>;
|
||||
mapDelete(map: string, key: string): Promise<boolean>;
|
||||
mapKeys(map: string): Promise<string[]>;
|
||||
mapRandomKey(map: string): Promise<string | undefined>;
|
||||
mapLen(map: string): Promise<number>;
|
||||
}
|
||||
+14
@@ -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';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { LocalCache } from './local';
|
||||
|
||||
@Injectable()
|
||||
export class Cache extends LocalCache {}
|
||||
|
||||
@Injectable()
|
||||
export class SessionCache extends LocalCache {
|
||||
constructor() {
|
||||
super({ namespace: 'session' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
import Keyv, { KeyvOptions } from 'keyv';
|
||||
|
||||
import type { Cache, CacheSetOptions } from './def';
|
||||
|
||||
export class LocalCache implements Cache {
|
||||
private readonly kv: Keyv;
|
||||
|
||||
constructor(opts: KeyvOptions = {}) {
|
||||
this.kv = new Keyv(opts);
|
||||
}
|
||||
|
||||
// standard operation
|
||||
async get<T = unknown>(key: string): Promise<T | undefined> {
|
||||
return this.kv.get<T>(key).catch(() => undefined);
|
||||
}
|
||||
|
||||
async set<T = unknown>(
|
||||
key: string,
|
||||
value: T,
|
||||
opts: CacheSetOptions = {}
|
||||
): Promise<boolean> {
|
||||
return this.kv
|
||||
.set(key, value, opts.ttl)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async setnx<T = unknown>(
|
||||
key: string,
|
||||
value: T,
|
||||
opts?: CacheSetOptions | undefined
|
||||
): Promise<boolean> {
|
||||
if (!(await this.has(key))) {
|
||||
return this.set(key, value, opts);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async increase(key: string, count: number = 1): Promise<number> {
|
||||
const prev = (await this.get(key)) ?? 0;
|
||||
if (typeof prev !== 'number') {
|
||||
throw new Error(
|
||||
`Expect a Number keyed by ${key}, but found ${typeof prev}`
|
||||
);
|
||||
}
|
||||
|
||||
const curr = prev + count;
|
||||
return (await this.set(key, curr)) ? curr : prev;
|
||||
}
|
||||
|
||||
async decrease(key: string, count: number = 1): Promise<number> {
|
||||
return this.increase(key, -count);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return this.kv.delete(key).catch(() => false);
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return this.kv.has(key).catch(() => false);
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return this.kv
|
||||
.get(key, { raw: true })
|
||||
.then(raw => (raw?.expires ? raw.expires - Date.now() : Infinity))
|
||||
.catch(() => 0);
|
||||
}
|
||||
|
||||
async expire(key: string, ttl: number): Promise<boolean> {
|
||||
const value = await this.kv.get(key);
|
||||
return this.set(key, value, { ttl });
|
||||
}
|
||||
|
||||
// list operations
|
||||
private async getArray<T = unknown>(key: string) {
|
||||
const raw = await this.kv.get<T[]>(key, { raw: true });
|
||||
if (raw && !Array.isArray(raw.value)) {
|
||||
throw new Error(
|
||||
`Expect an Array keyed by ${key}, but found ${raw.value}`
|
||||
);
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
private async setArray<T = unknown>(
|
||||
key: string,
|
||||
value: T[],
|
||||
opts: CacheSetOptions = {}
|
||||
) {
|
||||
return this.set(key, value, opts).then(() => value.length);
|
||||
}
|
||||
|
||||
async pushBack<T = unknown>(key: string, ...values: T[]): Promise<number> {
|
||||
let list: any[] = [];
|
||||
let ttl: number | undefined = undefined;
|
||||
const raw = await this.getArray(key);
|
||||
if (raw) {
|
||||
if (raw.value) {
|
||||
list = raw.value;
|
||||
}
|
||||
if (raw.expires) {
|
||||
ttl = raw.expires - Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
list = list.concat(values);
|
||||
return this.setArray(key, list, { ttl });
|
||||
}
|
||||
|
||||
async pushFront<T = unknown>(key: string, ...values: T[]): Promise<number> {
|
||||
let list: any[] = [];
|
||||
let ttl: number | undefined = undefined;
|
||||
const raw = await this.getArray(key);
|
||||
if (raw) {
|
||||
if (raw.value) {
|
||||
list = raw.value;
|
||||
}
|
||||
if (raw.expires) {
|
||||
ttl = raw.expires - Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
list = values.concat(list);
|
||||
return this.setArray(key, list, { ttl });
|
||||
}
|
||||
|
||||
async len(key: string): Promise<number> {
|
||||
return this.getArray<any[]>(key).then(v => v?.value?.length ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* list array elements with `[start, end]`
|
||||
* the end indice is inclusive
|
||||
*/
|
||||
async list<T = unknown>(
|
||||
key: string,
|
||||
start: number,
|
||||
end: number
|
||||
): Promise<T[]> {
|
||||
const raw = await this.getArray<T>(key);
|
||||
if (raw?.value) {
|
||||
start = (raw.value.length + start) % raw.value.length;
|
||||
end = ((raw.value.length + end) % raw.value.length) + 1;
|
||||
return raw.value.slice(start, end);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async trim<T = unknown>(key: string, start: number, end: number) {
|
||||
const raw = await this.getArray<T>(key);
|
||||
if (raw && raw.value) {
|
||||
start = (raw.value.length + start) % raw.value.length;
|
||||
// make negative end index work, and end indice is inclusive
|
||||
end = ((raw.value.length + end) % raw.value.length) + 1;
|
||||
const result = raw.value.splice(start, end);
|
||||
|
||||
await this.set(key, raw.value, {
|
||||
ttl: raw.expires ? raw.expires - Date.now() : undefined,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async popFront<T = unknown>(key: string, count: number = 1) {
|
||||
return this.trim<T>(key, 0, count - 1);
|
||||
}
|
||||
|
||||
async popBack<T = unknown>(key: string, count: number = 1) {
|
||||
return this.trim<T>(key, -count, count - 1);
|
||||
}
|
||||
|
||||
// map operations
|
||||
private async getMap<T = unknown>(map: string) {
|
||||
const raw = await this.kv.get<Record<string, T>>(map, { raw: true });
|
||||
|
||||
if (raw) {
|
||||
if (typeof raw.value !== 'object') {
|
||||
throw new Error(
|
||||
`Expect an Object keyed by ${map}, but found ${typeof raw}`
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(raw.value)) {
|
||||
throw new Error(`Expect an Object keyed by ${map}, but found an Array`);
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
private async setMap<T = unknown>(
|
||||
map: string,
|
||||
value: Record<string, T>,
|
||||
opts: CacheSetOptions = {}
|
||||
) {
|
||||
return this.kv.set(map, value, opts.ttl).then(() => true);
|
||||
}
|
||||
|
||||
async mapGet<T = unknown>(map: string, key: string): Promise<T | undefined> {
|
||||
const raw = await this.getMap<T>(map);
|
||||
if (raw?.value) {
|
||||
return raw.value[key];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async mapSet<T = unknown>(
|
||||
map: string,
|
||||
key: string,
|
||||
value: T
|
||||
): Promise<boolean> {
|
||||
const raw = await this.getMap(map);
|
||||
const data = raw?.value ?? {};
|
||||
|
||||
data[key] = value;
|
||||
|
||||
return this.setMap(map, data, {
|
||||
ttl: raw?.expires ? raw.expires - Date.now() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async mapDelete(map: string, key: string): Promise<boolean> {
|
||||
const raw = await this.getMap(map);
|
||||
|
||||
if (raw?.value) {
|
||||
delete raw.value[key];
|
||||
return this.setMap(map, raw.value, {
|
||||
ttl: raw.expires ? raw.expires - Date.now() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async mapIncrease(
|
||||
map: string,
|
||||
key: string,
|
||||
count: number = 1
|
||||
): Promise<number> {
|
||||
const prev = (await this.mapGet(map, key)) ?? 0;
|
||||
|
||||
if (typeof prev !== 'number') {
|
||||
throw new Error(
|
||||
`Expect a Number keyed by ${key}, but found ${typeof prev}`
|
||||
);
|
||||
}
|
||||
|
||||
const curr = prev + count;
|
||||
|
||||
return (await this.mapSet(map, key, curr)) ? curr : prev;
|
||||
}
|
||||
|
||||
async mapDecrease(
|
||||
map: string,
|
||||
key: string,
|
||||
count: number = 1
|
||||
): Promise<number> {
|
||||
return this.mapIncrease(map, key, -count);
|
||||
}
|
||||
|
||||
async mapKeys(map: string): Promise<string[]> {
|
||||
const raw = await this.getMap(map);
|
||||
if (raw?.value) {
|
||||
return Object.keys(raw.value);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async mapRandomKey(map: string): Promise<string | undefined> {
|
||||
const keys = await this.mapKeys(map);
|
||||
return keys[Math.floor(Math.random() * keys.length)];
|
||||
}
|
||||
|
||||
async mapLen(map: string): Promise<number> {
|
||||
const raw = await this.getMap(map);
|
||||
return raw?.value ? Object.keys(raw.value).length : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { LeafPaths } from '../utils/types';
|
||||
import { AppStartupConfig } from './types';
|
||||
|
||||
export type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
|
||||
export type ServerFlavor = 'allinone' | 'graphql' | 'sync' | 'renderer';
|
||||
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
|
||||
export type NODE_ENV = 'development' | 'test' | 'production' | 'script';
|
||||
|
||||
export enum DeploymentType {
|
||||
Affine = 'affine',
|
||||
Selfhosted = 'selfhosted',
|
||||
}
|
||||
|
||||
export type ConfigPaths = LeafPaths<AppStartupConfig, '', '......'>;
|
||||
|
||||
export interface PreDefinedAFFiNEConfig {
|
||||
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
readonly projectRoot: string;
|
||||
readonly AFFINE_ENV: AFFINE_ENV;
|
||||
readonly NODE_ENV: NODE_ENV;
|
||||
readonly version: string;
|
||||
readonly type: DeploymentType;
|
||||
readonly isSelfhosted: boolean;
|
||||
readonly flavor: { type: string } & { [key in ServerFlavor]: boolean };
|
||||
readonly affine: { canary: boolean; beta: boolean; stable: boolean };
|
||||
readonly node: {
|
||||
prod: boolean;
|
||||
dev: boolean;
|
||||
test: boolean;
|
||||
script: boolean;
|
||||
};
|
||||
readonly deploy: boolean;
|
||||
}
|
||||
|
||||
export interface AppPluginsConfig {}
|
||||
|
||||
export type AFFiNEConfig = PreDefinedAFFiNEConfig &
|
||||
AppStartupConfig &
|
||||
AppPluginsConfig;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace globalThis {
|
||||
// eslint-disable-next-line no-var
|
||||
var AFFiNE: AFFiNEConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import pkg from '../../../package.json' with { type: 'json' };
|
||||
import {
|
||||
AFFINE_ENV,
|
||||
AFFiNEConfig,
|
||||
DeploymentType,
|
||||
NODE_ENV,
|
||||
PreDefinedAFFiNEConfig,
|
||||
ServerFlavor,
|
||||
} from './def';
|
||||
import { readEnv } from './env';
|
||||
import { defaultStartupConfig } from './register';
|
||||
|
||||
function getPredefinedAFFiNEConfig(): PreDefinedAFFiNEConfig {
|
||||
const NODE_ENV = readEnv<NODE_ENV>('NODE_ENV', 'production', [
|
||||
'development',
|
||||
'test',
|
||||
'production',
|
||||
'script',
|
||||
]);
|
||||
const AFFINE_ENV = readEnv<AFFINE_ENV>('AFFINE_ENV', 'dev', [
|
||||
'dev',
|
||||
'beta',
|
||||
'production',
|
||||
]);
|
||||
const flavor = readEnv<ServerFlavor>('SERVER_FLAVOR', 'allinone', [
|
||||
'allinone',
|
||||
'graphql',
|
||||
'sync',
|
||||
'renderer',
|
||||
]);
|
||||
const deploymentType = readEnv<DeploymentType>(
|
||||
'DEPLOYMENT_TYPE',
|
||||
NODE_ENV === 'development'
|
||||
? DeploymentType.Affine
|
||||
: DeploymentType.Selfhosted,
|
||||
Object.values(DeploymentType)
|
||||
);
|
||||
const isSelfhosted = deploymentType === DeploymentType.Selfhosted;
|
||||
const affine = {
|
||||
canary: AFFINE_ENV === 'dev',
|
||||
beta: AFFINE_ENV === 'beta',
|
||||
stable: AFFINE_ENV === 'production',
|
||||
};
|
||||
const node = {
|
||||
prod: NODE_ENV === 'production',
|
||||
dev: NODE_ENV === 'development',
|
||||
test: NODE_ENV === 'test',
|
||||
script: NODE_ENV === 'script',
|
||||
};
|
||||
|
||||
return {
|
||||
ENV_MAP: {},
|
||||
NODE_ENV,
|
||||
AFFINE_ENV,
|
||||
serverId: 'some-randome-uuid',
|
||||
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
|
||||
version: pkg.version,
|
||||
type: deploymentType,
|
||||
isSelfhosted,
|
||||
flavor: {
|
||||
type: flavor,
|
||||
allinone: flavor === 'allinone',
|
||||
graphql: flavor === 'graphql' || flavor === 'allinone',
|
||||
sync: flavor === 'sync' || flavor === 'allinone',
|
||||
renderer: flavor === 'renderer' || flavor === 'allinone',
|
||||
},
|
||||
affine,
|
||||
node,
|
||||
deploy: !node.dev && !node.test,
|
||||
projectRoot: resolve(fileURLToPath(import.meta.url), '../../../../'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAFFiNEConfigModifier(): AFFiNEConfig {
|
||||
const predefined = getPredefinedAFFiNEConfig() as AFFiNEConfig;
|
||||
|
||||
return chainableProxy(predefined);
|
||||
}
|
||||
|
||||
function merge(a: any, b: any) {
|
||||
if (typeof b !== 'object' || b instanceof Map || b instanceof Set) {
|
||||
return b;
|
||||
}
|
||||
|
||||
if (Array.isArray(b)) {
|
||||
if (Array.isArray(a)) {
|
||||
return a.concat(b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
const result = { ...a };
|
||||
Object.keys(b).forEach(key => {
|
||||
result[key] = merge(result[key], b[key]);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeConfigOverride(override: any) {
|
||||
return merge(defaultStartupConfig, override);
|
||||
}
|
||||
|
||||
function chainableProxy(obj: any) {
|
||||
const keys: Set<string> = new Set(Object.keys(obj));
|
||||
return new Proxy(obj, {
|
||||
get(target, prop) {
|
||||
if (!(prop in target)) {
|
||||
keys.add(prop as string);
|
||||
target[prop] = chainableProxy({});
|
||||
}
|
||||
return target[prop];
|
||||
},
|
||||
set(target, prop, value) {
|
||||
keys.add(prop as string);
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
!(
|
||||
value instanceof Map ||
|
||||
value instanceof Set ||
|
||||
value instanceof Array
|
||||
)
|
||||
) {
|
||||
value = chainableProxy(value);
|
||||
}
|
||||
target[prop] = value;
|
||||
return true;
|
||||
},
|
||||
ownKeys() {
|
||||
return Array.from(keys);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { set } from 'lodash-es';
|
||||
|
||||
import type { AFFiNEConfig, EnvConfigType } from './def';
|
||||
|
||||
/**
|
||||
* parse number value from environment variables
|
||||
*/
|
||||
function int(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> = {
|
||||
int,
|
||||
float,
|
||||
boolean,
|
||||
string: value => value,
|
||||
};
|
||||
|
||||
export function parseEnvValue(value: string | undefined, type: EnvConfigType) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return envParsers[type](value);
|
||||
}
|
||||
|
||||
export function applyEnvToConfig(rawConfig: AFFiNEConfig) {
|
||||
for (const env in rawConfig.ENV_MAP) {
|
||||
const config = rawConfig.ENV_MAP[env];
|
||||
const [path, value] =
|
||||
typeof config === 'string'
|
||||
? [config, parseEnvValue(process.env[env], 'string')]
|
||||
: [config[0], parseEnvValue(process.env[env], config[1] ?? 'string')];
|
||||
|
||||
if (value !== undefined) {
|
||||
set(rawConfig, path, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readEnv<T>(
|
||||
env: string,
|
||||
defaultValue: T,
|
||||
availableValues?: T[]
|
||||
) {
|
||||
const value = process.env[env];
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (availableValues && !availableValues.includes(value as any)) {
|
||||
throw new Error(
|
||||
`Invalid value '${value}' for environment variable ${env}, expected one of [${availableValues.join(
|
||||
', '
|
||||
)}]`
|
||||
);
|
||||
}
|
||||
|
||||
return value as T;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { DynamicModule, FactoryProvider } from '@nestjs/common';
|
||||
import { merge } from 'lodash-es';
|
||||
|
||||
import { AFFiNEConfig } from './def';
|
||||
import { Config } from './provider';
|
||||
import { Runtime } from './runtime/service';
|
||||
|
||||
export * from './def';
|
||||
export * from './default';
|
||||
export { applyEnvToConfig, parseEnvValue } from './env';
|
||||
export * from './provider';
|
||||
export { defineRuntimeConfig, defineStartupConfig } from './register';
|
||||
export type { AppConfig, ConfigItem, ModuleConfig } from './types';
|
||||
|
||||
function createConfigProvider(
|
||||
override?: DeepPartial<Config>
|
||||
): FactoryProvider<Config> {
|
||||
return {
|
||||
provide: Config,
|
||||
useFactory: (runtime: Runtime) => {
|
||||
return Object.freeze(merge({}, globalThis.AFFiNE, override, { runtime }));
|
||||
},
|
||||
inject: [Runtime],
|
||||
};
|
||||
}
|
||||
|
||||
export class ConfigModule {
|
||||
static forRoot = (override?: DeepPartial<AFFiNEConfig>): DynamicModule => {
|
||||
const provider = createConfigProvider(override);
|
||||
|
||||
return {
|
||||
global: true,
|
||||
module: ConfigModule,
|
||||
providers: [provider, Runtime],
|
||||
exports: [provider],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export { Runtime };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApplyType } from '../utils/types';
|
||||
import { AFFiNEConfig } from './def';
|
||||
import type { Runtime } from './runtime/service';
|
||||
|
||||
/**
|
||||
* @example
|
||||
*
|
||||
* import { Config } from '@affine/server'
|
||||
*
|
||||
* class TestConfig {
|
||||
* constructor(private readonly config: Config) {}
|
||||
* test() {
|
||||
* return this.config.env
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export class Config extends ApplyType<AFFiNEConfig>() {
|
||||
runtime!: Runtime;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Prisma, RuntimeConfigType } from '@prisma/client';
|
||||
import { get, merge, set } from 'lodash-es';
|
||||
|
||||
import {
|
||||
AppModulesConfigDef,
|
||||
AppStartupConfig,
|
||||
ModuleRuntimeConfigDescriptions,
|
||||
ModuleStartupConfigDescriptions,
|
||||
} from './types';
|
||||
|
||||
export const defaultStartupConfig: AppStartupConfig = {} as any;
|
||||
export const defaultRuntimeConfig: Record<
|
||||
string,
|
||||
Prisma.RuntimeConfigCreateInput
|
||||
> = {} as any;
|
||||
|
||||
export function runtimeConfigType(val: any): RuntimeConfigType {
|
||||
if (Array.isArray(val)) {
|
||||
return RuntimeConfigType.Array;
|
||||
}
|
||||
|
||||
switch (typeof val) {
|
||||
case 'string':
|
||||
return RuntimeConfigType.String;
|
||||
case 'number':
|
||||
return RuntimeConfigType.Number;
|
||||
case 'boolean':
|
||||
return RuntimeConfigType.Boolean;
|
||||
default:
|
||||
return RuntimeConfigType.Object;
|
||||
}
|
||||
}
|
||||
|
||||
function registerRuntimeConfig<T extends keyof AppModulesConfigDef>(
|
||||
module: T,
|
||||
configs: ModuleRuntimeConfigDescriptions<T>
|
||||
) {
|
||||
Object.entries(configs).forEach(([key, value]) => {
|
||||
defaultRuntimeConfig[`${module}/${key}`] = {
|
||||
id: `${module}/${key}`,
|
||||
module,
|
||||
key,
|
||||
description: value.desc,
|
||||
value: value.default,
|
||||
type: runtimeConfigType(value.default),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function defineStartupConfig<T extends keyof AppModulesConfigDef>(
|
||||
module: T,
|
||||
configs: ModuleStartupConfigDescriptions<AppModulesConfigDef[T]>
|
||||
) {
|
||||
set(
|
||||
defaultStartupConfig,
|
||||
module,
|
||||
merge(get(defaultStartupConfig, module, {}), configs)
|
||||
);
|
||||
}
|
||||
|
||||
export function defineRuntimeConfig<T extends keyof AppModulesConfigDef>(
|
||||
module: T,
|
||||
configs: ModuleRuntimeConfigDescriptions<T>
|
||||
) {
|
||||
registerRuntimeConfig(module, configs);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { OnEvent } from '../../event';
|
||||
import { Payload } from '../../event/def';
|
||||
import { FlattenedAppRuntimeConfig } from '../types';
|
||||
|
||||
declare module '../../event/def' {
|
||||
interface EventDefinitions {
|
||||
runtimeConfig: {
|
||||
[K in keyof FlattenedAppRuntimeConfig]: {
|
||||
changed: Payload<FlattenedAppRuntimeConfig[K]>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* not implemented yet
|
||||
*/
|
||||
export const OnRuntimeConfigChange_DO_NOT_USE = (
|
||||
nameWithModule: keyof FlattenedAppRuntimeConfig
|
||||
) => {
|
||||
return OnEvent(`runtimeConfig.${nameWithModule}.changed`);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { difference, keyBy } from 'lodash-es';
|
||||
|
||||
import { Cache } from '../../cache';
|
||||
import { InvalidRuntimeConfigType, RuntimeConfigNotFound } from '../../error';
|
||||
import { defer } from '../../utils/promise';
|
||||
import { defaultRuntimeConfig, runtimeConfigType } from '../register';
|
||||
import { AppRuntimeConfigModules, FlattenedAppRuntimeConfig } from '../types';
|
||||
|
||||
function validateConfigType<K extends keyof FlattenedAppRuntimeConfig>(
|
||||
key: K,
|
||||
value: any
|
||||
) {
|
||||
const config = defaultRuntimeConfig[key];
|
||||
|
||||
if (!config) {
|
||||
throw new RuntimeConfigNotFound({ key });
|
||||
}
|
||||
|
||||
const want = config.type;
|
||||
const get = runtimeConfigType(value);
|
||||
if (get !== want) {
|
||||
throw new InvalidRuntimeConfigType({
|
||||
key,
|
||||
want,
|
||||
get,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* runtime.fetch(k) // v1
|
||||
* runtime.fetchAll(k1, k2, k3) // [v1, v2, v3]
|
||||
* runtime.set(k, v)
|
||||
* runtime.update(k, (v) => {
|
||||
* v.xxx = 'yyy';
|
||||
* return v
|
||||
* })
|
||||
*/
|
||||
@Injectable()
|
||||
export class Runtime implements OnModuleInit {
|
||||
private readonly logger = new Logger('App:RuntimeConfig');
|
||||
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
// circular deps: runtime => cache => redis(maybe) => config => runtime
|
||||
@Inject(forwardRef(() => Cache)) private readonly cache: Cache
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.upgradeDB();
|
||||
}
|
||||
|
||||
async fetch<K extends keyof FlattenedAppRuntimeConfig>(
|
||||
k: K
|
||||
): Promise<FlattenedAppRuntimeConfig[K]> {
|
||||
const cached = await this.loadCache<K>(k);
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const dbValue = await this.loadDb<K>(k);
|
||||
|
||||
if (dbValue === undefined) {
|
||||
throw new RuntimeConfigNotFound({ key: k });
|
||||
}
|
||||
|
||||
await this.setCache(k, dbValue);
|
||||
|
||||
return dbValue;
|
||||
}
|
||||
|
||||
async fetchAll<
|
||||
Selector extends { [Key in keyof FlattenedAppRuntimeConfig]?: true },
|
||||
>(
|
||||
selector: Selector
|
||||
): Promise<{
|
||||
// @ts-expect-error allow
|
||||
[Key in keyof Selector]: FlattenedAppRuntimeConfig[Key];
|
||||
}> {
|
||||
const keys = Object.keys(selector);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return {} as any;
|
||||
}
|
||||
|
||||
const records = await this.db.runtimeConfig.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
in: keys,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const keyed = keyBy(records, 'id');
|
||||
return keys.reduce((ret, key) => {
|
||||
ret[key] = keyed[key]?.value ?? defaultRuntimeConfig[key].value;
|
||||
return ret;
|
||||
}, {} as any);
|
||||
}
|
||||
|
||||
async list(module?: AppRuntimeConfigModules) {
|
||||
return await this.db.runtimeConfig.findMany({
|
||||
where: module ? { module, deletedAt: null } : { deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
async set<
|
||||
K extends keyof FlattenedAppRuntimeConfig,
|
||||
V = FlattenedAppRuntimeConfig[K],
|
||||
>(key: K, value: V) {
|
||||
validateConfigType(key, value);
|
||||
const config = await this.db.runtimeConfig.update({
|
||||
where: {
|
||||
id: key,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
value: value as any,
|
||||
},
|
||||
});
|
||||
|
||||
await this.setCache(key, config.value as FlattenedAppRuntimeConfig[K]);
|
||||
return config;
|
||||
}
|
||||
|
||||
async update<
|
||||
K extends keyof FlattenedAppRuntimeConfig,
|
||||
V = FlattenedAppRuntimeConfig[K],
|
||||
>(k: K, modifier: (v: V) => V | Promise<V>) {
|
||||
const data = await this.fetch<K>(k);
|
||||
|
||||
const updated = await modifier(data as V);
|
||||
|
||||
await this.set(k, updated);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async loadDb<K extends keyof FlattenedAppRuntimeConfig>(
|
||||
k: K
|
||||
): Promise<FlattenedAppRuntimeConfig[K] | undefined> {
|
||||
const v = await this.db.runtimeConfig.findFirst({
|
||||
where: {
|
||||
id: k,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (v) {
|
||||
return v.value as FlattenedAppRuntimeConfig[K];
|
||||
} else {
|
||||
const record = await this.db.runtimeConfig.create({
|
||||
data: defaultRuntimeConfig[k],
|
||||
});
|
||||
|
||||
return record.value as any;
|
||||
}
|
||||
}
|
||||
|
||||
async loadCache<K extends keyof FlattenedAppRuntimeConfig>(
|
||||
k: K
|
||||
): Promise<FlattenedAppRuntimeConfig[K] | undefined> {
|
||||
return this.cache.get<FlattenedAppRuntimeConfig[K]>(`SERVER_RUNTIME:${k}`);
|
||||
}
|
||||
|
||||
async setCache<K extends keyof FlattenedAppRuntimeConfig>(
|
||||
k: K,
|
||||
v: FlattenedAppRuntimeConfig[K]
|
||||
): Promise<boolean> {
|
||||
return this.cache.set<FlattenedAppRuntimeConfig[K]>(
|
||||
`SERVER_RUNTIME:${k}`,
|
||||
v,
|
||||
{ ttl: 60 * 1000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade the DB with latest runtime configs
|
||||
*/
|
||||
private async upgradeDB() {
|
||||
const existingConfig = await this.db.runtimeConfig.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const defined = Object.keys(defaultRuntimeConfig);
|
||||
const existing = existingConfig.map(c => c.id);
|
||||
const newConfigs = difference(defined, existing);
|
||||
const deleteConfigs = difference(existing, defined);
|
||||
|
||||
if (!newConfigs.length && !deleteConfigs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found runtime config changes, upgrading...`);
|
||||
const acquired = await this.cache.setnx('runtime:upgrade', 1, {
|
||||
ttl: 10 * 60 * 1000,
|
||||
});
|
||||
await using _ = defer(async () => {
|
||||
await this.cache.delete('runtime:upgrade');
|
||||
});
|
||||
|
||||
if (acquired) {
|
||||
for (const key of newConfigs) {
|
||||
await this.db.runtimeConfig.upsert({
|
||||
create: defaultRuntimeConfig[key],
|
||||
// old deleted setting should be restored
|
||||
update: {
|
||||
...defaultRuntimeConfig[key],
|
||||
deletedAt: null,
|
||||
},
|
||||
where: {
|
||||
id: key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.db.runtimeConfig.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: deleteConfigs,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log('Upgrade completed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Join, PathType } from '../utils/types';
|
||||
|
||||
export type ConfigItem<T> = T & { __type: 'ConfigItem' };
|
||||
|
||||
type ConfigDef = Record<string, any> | never;
|
||||
|
||||
export interface ModuleConfig<
|
||||
Startup extends ConfigDef = never,
|
||||
Runtime extends ConfigDef = never,
|
||||
> {
|
||||
startup: Startup;
|
||||
runtime: Runtime;
|
||||
}
|
||||
|
||||
export type RuntimeConfigDescription<T> = {
|
||||
desc: string;
|
||||
default: T;
|
||||
};
|
||||
|
||||
type ConfigItemLeaves<T, P extends string = ''> =
|
||||
T extends Record<string, any>
|
||||
? {
|
||||
[K in keyof T]: K extends string
|
||||
? T[K] extends { __type: 'ConfigItem' }
|
||||
? K
|
||||
: T[K] extends PrimitiveType
|
||||
? K
|
||||
: Join<K, ConfigItemLeaves<T[K], P>>
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
type StartupConfigDescriptions<T extends ConfigDef> = {
|
||||
[K in keyof T]: T[K] extends Record<string, any>
|
||||
? T[K] extends ConfigItem<infer V>
|
||||
? V
|
||||
: T[K]
|
||||
: T[K];
|
||||
};
|
||||
|
||||
type ModuleConfigLeaves<T, P extends string = ''> =
|
||||
T extends Record<string, any>
|
||||
? {
|
||||
[K in keyof T]: K extends string
|
||||
? T[K] extends ModuleConfig<any, any>
|
||||
? K
|
||||
: Join<K, ModuleConfigLeaves<T[K], P>>
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
type FlattenModuleConfigs<T extends Record<string, any>> = {
|
||||
// @ts-expect-error allow
|
||||
[K in ModuleConfigLeaves<T>]: PathType<T, K>;
|
||||
};
|
||||
|
||||
type _AppStartupConfig<T extends Record<string, any>> = {
|
||||
[K in keyof T]: T[K] extends ModuleConfig<infer S, any>
|
||||
? S
|
||||
: _AppStartupConfig<T[K]>;
|
||||
};
|
||||
|
||||
// for extending
|
||||
export interface AppConfig {}
|
||||
export type AppModulesConfigDef = FlattenModuleConfigs<AppConfig>;
|
||||
export type AppConfigModules = keyof AppModulesConfigDef;
|
||||
export type AppStartupConfig = _AppStartupConfig<AppConfig>;
|
||||
|
||||
// app runtime config keyed by module names
|
||||
export type AppRuntimeConfigByModules = {
|
||||
[Module in keyof AppModulesConfigDef]: AppModulesConfigDef[Module] extends ModuleConfig<
|
||||
any,
|
||||
infer Runtime
|
||||
>
|
||||
? Runtime extends never
|
||||
? never
|
||||
: {
|
||||
// @ts-expect-error allow
|
||||
[K in ConfigItemLeaves<Runtime>]: PathType<
|
||||
Runtime,
|
||||
K
|
||||
> extends infer Config
|
||||
? Config extends ConfigItem<infer V>
|
||||
? V
|
||||
: Config
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
};
|
||||
|
||||
// names of modules that have runtime config
|
||||
export type AppRuntimeConfigModules = {
|
||||
[Module in keyof AppRuntimeConfigByModules]: AppRuntimeConfigByModules[Module] extends never
|
||||
? never
|
||||
: Module;
|
||||
}[keyof AppRuntimeConfigByModules];
|
||||
|
||||
// runtime config keyed by module names flattened into config names
|
||||
// { auth: { allowSignup: boolean } } => { 'auth/allowSignup': boolean }
|
||||
export type FlattenedAppRuntimeConfig = UnionToIntersection<
|
||||
{
|
||||
[Module in keyof AppRuntimeConfigByModules]: AppModulesConfigDef[Module] extends never
|
||||
? never
|
||||
: {
|
||||
[K in keyof AppRuntimeConfigByModules[Module] as K extends string
|
||||
? `${Module}/${K}`
|
||||
: never]: AppRuntimeConfigByModules[Module][K];
|
||||
};
|
||||
}[keyof AppRuntimeConfigByModules]
|
||||
>;
|
||||
|
||||
export type ModuleStartupConfigDescriptions<T extends ModuleConfig<any, any>> =
|
||||
T extends ModuleConfig<infer S, any>
|
||||
? S extends never
|
||||
? undefined
|
||||
: StartupConfigDescriptions<S>
|
||||
: never;
|
||||
|
||||
export type ModuleRuntimeConfigDescriptions<
|
||||
Module extends keyof AppRuntimeConfigByModules,
|
||||
> = AppModulesConfigDef[Module] extends never
|
||||
? never
|
||||
: {
|
||||
[K in keyof AppRuntimeConfigByModules[Module]]: RuntimeConfigDescription<
|
||||
AppRuntimeConfigByModules[Module][K]
|
||||
>;
|
||||
};
|
||||
@@ -0,0 +1,595 @@
|
||||
import { STATUS_CODES } from 'node:http';
|
||||
|
||||
import { HttpStatus, Logger } from '@nestjs/common';
|
||||
import { capitalize } from 'lodash-es';
|
||||
|
||||
export type UserFriendlyErrorBaseType =
|
||||
| 'bad_request'
|
||||
| 'too_many_requests'
|
||||
| 'resource_not_found'
|
||||
| 'resource_already_exists'
|
||||
| 'invalid_input'
|
||||
| 'action_forbidden'
|
||||
| 'no_permission'
|
||||
| 'quota_exceeded'
|
||||
| 'authentication_required'
|
||||
| 'internal_server_error';
|
||||
|
||||
type ErrorArgType = 'string' | 'number' | 'boolean';
|
||||
type ErrorArgs = Record<string, ErrorArgType | Record<string, ErrorArgType>>;
|
||||
|
||||
export type UserFriendlyErrorOptions = {
|
||||
type: UserFriendlyErrorBaseType;
|
||||
args?: ErrorArgs;
|
||||
message: string | ((args: any) => string);
|
||||
};
|
||||
|
||||
const BaseTypeToHttpStatusMap: Record<UserFriendlyErrorBaseType, HttpStatus> = {
|
||||
too_many_requests: HttpStatus.TOO_MANY_REQUESTS,
|
||||
bad_request: HttpStatus.BAD_REQUEST,
|
||||
resource_not_found: HttpStatus.NOT_FOUND,
|
||||
resource_already_exists: HttpStatus.BAD_REQUEST,
|
||||
invalid_input: HttpStatus.BAD_REQUEST,
|
||||
action_forbidden: HttpStatus.FORBIDDEN,
|
||||
no_permission: HttpStatus.FORBIDDEN,
|
||||
quota_exceeded: HttpStatus.PAYMENT_REQUIRED,
|
||||
authentication_required: HttpStatus.UNAUTHORIZED,
|
||||
internal_server_error: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
const IncludedEvents = new Set([
|
||||
// email
|
||||
'invalid_email',
|
||||
'email_token_not_found',
|
||||
'invalid_email_token',
|
||||
'email_already_used',
|
||||
'same_email_provided',
|
||||
// magic link
|
||||
'action_forbidden',
|
||||
'link_expired',
|
||||
'email_verification_required',
|
||||
// oauth
|
||||
'missing_oauth_query_parameter',
|
||||
'unknown_oauth_provider',
|
||||
'invalid_oauth_callback_state',
|
||||
'oauth_state_expired',
|
||||
'oauth_account_already_connected',
|
||||
]);
|
||||
|
||||
export class UserFriendlyError extends Error {
|
||||
/**
|
||||
* Standard HTTP status code
|
||||
*/
|
||||
status: number;
|
||||
|
||||
/**
|
||||
* Business error category, for example 'resource_already_exists' or 'quota_exceeded'
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Additional data that could be used for error handling or formatting
|
||||
*/
|
||||
data: any;
|
||||
|
||||
constructor(
|
||||
type: UserFriendlyErrorBaseType,
|
||||
name: keyof typeof USER_FRIENDLY_ERRORS,
|
||||
message?: string | ((args?: any) => string),
|
||||
args?: any
|
||||
) {
|
||||
const defaultMsg = USER_FRIENDLY_ERRORS[name].message;
|
||||
// disallow message override for `internal_server_error`
|
||||
// to avoid leak internal information to user
|
||||
let msg =
|
||||
name === 'internal_server_error' ? defaultMsg : (message ?? defaultMsg);
|
||||
|
||||
if (typeof msg === 'function') {
|
||||
msg = msg(args);
|
||||
}
|
||||
|
||||
super(msg);
|
||||
this.status = BaseTypeToHttpStatusMap[type];
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.data = args;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
status: this.status,
|
||||
code: STATUS_CODES[this.status] ?? 'BAD REQUEST',
|
||||
type: this.type.toUpperCase(),
|
||||
name: this.name.toUpperCase(),
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
|
||||
toText() {
|
||||
const json = this.toJSON();
|
||||
return [
|
||||
`Status: ${json.status}`,
|
||||
`Type: ${json.type}`,
|
||||
`Name: ${json.name}`,
|
||||
`Message: ${json.message}`,
|
||||
`Data: ${JSON.stringify(json.data)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
log(context: string) {
|
||||
// ignore all user behavior error log
|
||||
if (
|
||||
this.type !== 'internal_server_error' &&
|
||||
!IncludedEvents.has(this.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
new Logger(context).error(
|
||||
'Internal server error',
|
||||
this.cause ? ((this.cause as any).stack ?? this.cause) : this.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @ObjectType()
|
||||
* export class XXXDataType {
|
||||
* @Field()
|
||||
*
|
||||
* }
|
||||
*/
|
||||
function generateErrorArgs(name: string, args: ErrorArgs) {
|
||||
const typeName = `${name}DataType`;
|
||||
const lines = [`@ObjectType()`, `class ${typeName} {`];
|
||||
Object.entries(args).forEach(([arg, fieldArgs]) => {
|
||||
if (typeof fieldArgs === 'object') {
|
||||
const subResult = generateErrorArgs(
|
||||
name + 'Field' + capitalize(arg),
|
||||
fieldArgs
|
||||
);
|
||||
lines.unshift(subResult.def);
|
||||
lines.push(
|
||||
` @Field(() => ${subResult.name}) ${arg}!: ${subResult.name};`
|
||||
);
|
||||
} else {
|
||||
lines.push(` @Field() ${arg}!: ${fieldArgs}`);
|
||||
}
|
||||
});
|
||||
|
||||
lines.push('}');
|
||||
|
||||
return { name: typeName, def: lines.join('\n') };
|
||||
}
|
||||
|
||||
export function generateUserFriendlyErrors() {
|
||||
const output = [
|
||||
'/* eslint-disable */',
|
||||
'// AUTO GENERATED FILE',
|
||||
`import { createUnionType, Field, ObjectType, registerEnumType } from '@nestjs/graphql';`,
|
||||
'',
|
||||
`import { UserFriendlyError } from './def';`,
|
||||
];
|
||||
|
||||
const errorNames: string[] = [];
|
||||
const argTypes: string[] = [];
|
||||
|
||||
for (const code in USER_FRIENDLY_ERRORS) {
|
||||
errorNames.push(code.toUpperCase());
|
||||
// @ts-expect-error allow
|
||||
const options: UserFriendlyErrorOptions = USER_FRIENDLY_ERRORS[code];
|
||||
const className = code
|
||||
.split('_')
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
|
||||
const args = options.args
|
||||
? generateErrorArgs(className, options.args)
|
||||
: null;
|
||||
|
||||
const classDef = `
|
||||
export class ${className} extends UserFriendlyError {
|
||||
constructor(${args ? `args: ${args.name}, ` : ''}message?: string${args ? ` | ((args: ${args.name}) => string)` : ''}) {
|
||||
super('${options.type}', '${code}', message${args ? ', args' : ''});
|
||||
}
|
||||
}`;
|
||||
|
||||
if (args) {
|
||||
output.push(args.def);
|
||||
argTypes.push(args.name);
|
||||
}
|
||||
output.push(classDef);
|
||||
}
|
||||
|
||||
output.push(`export enum ErrorNames {
|
||||
${errorNames.join(',\n ')}
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
})
|
||||
|
||||
export const ErrorDataUnionType = createUnionType({
|
||||
name: 'ErrorDataUnion',
|
||||
types: () =>
|
||||
[${argTypes.join(', ')}] as const,
|
||||
});
|
||||
`);
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
// DEFINE ALL USER FRIENDLY ERRORS HERE
|
||||
export const USER_FRIENDLY_ERRORS = {
|
||||
// Internal uncaught errors
|
||||
internal_server_error: {
|
||||
type: 'internal_server_error',
|
||||
message: 'An internal error occurred.',
|
||||
},
|
||||
too_many_request: {
|
||||
type: 'too_many_requests',
|
||||
message: 'Too many requests.',
|
||||
},
|
||||
not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'Resource not found.',
|
||||
},
|
||||
|
||||
// User Errors
|
||||
user_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'User not found.',
|
||||
},
|
||||
user_avatar_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'User avatar not found.',
|
||||
},
|
||||
email_already_used: {
|
||||
type: 'resource_already_exists',
|
||||
message: 'This email has already been registered.',
|
||||
},
|
||||
same_email_provided: {
|
||||
type: 'invalid_input',
|
||||
message:
|
||||
'You are trying to update your account email to the same as the old one.',
|
||||
},
|
||||
wrong_sign_in_credentials: {
|
||||
type: 'invalid_input',
|
||||
args: { email: 'string' },
|
||||
message: ({ email }) => `Wrong user email or password: ${email}`,
|
||||
},
|
||||
unknown_oauth_provider: {
|
||||
type: 'invalid_input',
|
||||
args: { name: 'string' },
|
||||
message: ({ name }) => `Unknown authentication provider ${name}.`,
|
||||
},
|
||||
oauth_state_expired: {
|
||||
type: 'bad_request',
|
||||
message: 'OAuth state expired, please try again.',
|
||||
},
|
||||
invalid_oauth_callback_state: {
|
||||
type: 'bad_request',
|
||||
message: 'Invalid callback state parameter.',
|
||||
},
|
||||
missing_oauth_query_parameter: {
|
||||
type: 'bad_request',
|
||||
args: { name: 'string' },
|
||||
message: ({ name }) => `Missing query parameter \`${name}\`.`,
|
||||
},
|
||||
oauth_account_already_connected: {
|
||||
type: 'bad_request',
|
||||
message:
|
||||
'The third-party account has already been connected to another user.',
|
||||
},
|
||||
invalid_email: {
|
||||
type: 'invalid_input',
|
||||
args: { email: 'string' },
|
||||
message: ({ email }) => `An invalid email provided: ${email}`,
|
||||
},
|
||||
invalid_password_length: {
|
||||
type: 'invalid_input',
|
||||
args: { min: 'number', max: 'number' },
|
||||
message: ({ min, max }) =>
|
||||
`Password must be between ${min} and ${max} characters`,
|
||||
},
|
||||
password_required: {
|
||||
type: 'invalid_input',
|
||||
message: 'Password is required.',
|
||||
},
|
||||
wrong_sign_in_method: {
|
||||
type: 'invalid_input',
|
||||
message:
|
||||
'You are trying to sign in by a different method than you signed up with.',
|
||||
},
|
||||
early_access_required: {
|
||||
type: 'action_forbidden',
|
||||
message: `You don't have early access permission. Visit https://community.affine.pro/c/insider-general/ for more information.`,
|
||||
},
|
||||
sign_up_forbidden: {
|
||||
type: 'action_forbidden',
|
||||
message: `You are not allowed to sign up.`,
|
||||
},
|
||||
email_token_not_found: {
|
||||
type: 'invalid_input',
|
||||
message: 'The email token provided is not found.',
|
||||
},
|
||||
invalid_email_token: {
|
||||
type: 'invalid_input',
|
||||
message: 'An invalid email token provided.',
|
||||
},
|
||||
link_expired: {
|
||||
type: 'bad_request',
|
||||
message: 'The link has expired.',
|
||||
},
|
||||
|
||||
// Authentication & Permission Errors
|
||||
authentication_required: {
|
||||
type: 'authentication_required',
|
||||
message: 'You must sign in first to access this resource.',
|
||||
},
|
||||
action_forbidden: {
|
||||
type: 'action_forbidden',
|
||||
message: 'You are not allowed to perform this action.',
|
||||
},
|
||||
access_denied: {
|
||||
type: 'no_permission',
|
||||
message: 'You do not have permission to access this resource.',
|
||||
},
|
||||
email_verification_required: {
|
||||
type: 'action_forbidden',
|
||||
message: 'You must verify your email before accessing this resource.',
|
||||
},
|
||||
|
||||
// Workspace & Userspace & Doc & Sync errors
|
||||
space_not_found: {
|
||||
type: 'resource_not_found',
|
||||
args: { spaceId: 'string' },
|
||||
message: ({ spaceId }) => `Space ${spaceId} not found.`,
|
||||
},
|
||||
not_in_space: {
|
||||
type: 'action_forbidden',
|
||||
args: { spaceId: 'string' },
|
||||
message: ({ spaceId }) =>
|
||||
`You should join in Space ${spaceId} before broadcasting messages.`,
|
||||
},
|
||||
already_in_space: {
|
||||
type: 'action_forbidden',
|
||||
args: { spaceId: 'string' },
|
||||
message: ({ spaceId }) => `You have already joined in Space ${spaceId}.`,
|
||||
},
|
||||
space_access_denied: {
|
||||
type: 'no_permission',
|
||||
args: { spaceId: 'string' },
|
||||
message: ({ spaceId }) =>
|
||||
`You do not have permission to access Space ${spaceId}.`,
|
||||
},
|
||||
space_owner_not_found: {
|
||||
type: 'internal_server_error',
|
||||
args: { spaceId: 'string' },
|
||||
message: ({ spaceId }) => `Owner of Space ${spaceId} not found.`,
|
||||
},
|
||||
cant_change_space_owner: {
|
||||
type: 'action_forbidden',
|
||||
message: 'You are not allowed to change the owner of a Space.',
|
||||
},
|
||||
doc_not_found: {
|
||||
type: 'resource_not_found',
|
||||
args: { spaceId: 'string', docId: 'string' },
|
||||
message: ({ spaceId, docId }) =>
|
||||
`Doc ${docId} under Space ${spaceId} not found.`,
|
||||
},
|
||||
doc_access_denied: {
|
||||
type: 'no_permission',
|
||||
args: { spaceId: 'string', docId: 'string' },
|
||||
message: ({ spaceId, docId }) =>
|
||||
`You do not have permission to access doc ${docId} under Space ${spaceId}.`,
|
||||
},
|
||||
version_rejected: {
|
||||
type: 'action_forbidden',
|
||||
args: { version: 'string', serverVersion: 'string' },
|
||||
message: ({ version, serverVersion }) =>
|
||||
`Your client with version ${version} is rejected by remote sync server. Please upgrade to ${serverVersion}.`,
|
||||
},
|
||||
invalid_history_timestamp: {
|
||||
type: 'invalid_input',
|
||||
args: { timestamp: 'string' },
|
||||
message: 'Invalid doc history timestamp provided.',
|
||||
},
|
||||
doc_history_not_found: {
|
||||
type: 'resource_not_found',
|
||||
args: { spaceId: 'string', docId: 'string', timestamp: 'number' },
|
||||
message: ({ spaceId, docId, timestamp }) =>
|
||||
`History of ${docId} at ${timestamp} under Space ${spaceId}.`,
|
||||
},
|
||||
blob_not_found: {
|
||||
type: 'resource_not_found',
|
||||
args: { spaceId: 'string', blobId: 'string' },
|
||||
message: ({ spaceId, blobId }) =>
|
||||
`Blob ${blobId} not found in Space ${spaceId}.`,
|
||||
},
|
||||
expect_to_publish_page: {
|
||||
type: 'invalid_input',
|
||||
message: 'Expected to publish a page, not a Space.',
|
||||
},
|
||||
expect_to_revoke_public_page: {
|
||||
type: 'invalid_input',
|
||||
message: 'Expected to revoke a public page, not a Space.',
|
||||
},
|
||||
page_is_not_public: {
|
||||
type: 'bad_request',
|
||||
message: 'Page is not public.',
|
||||
},
|
||||
failed_to_save_updates: {
|
||||
type: 'internal_server_error',
|
||||
message: 'Failed to store doc updates.',
|
||||
},
|
||||
failed_to_upsert_snapshot: {
|
||||
type: 'internal_server_error',
|
||||
message: 'Failed to store doc snapshot.',
|
||||
},
|
||||
|
||||
// Subscription Errors
|
||||
unsupported_subscription_plan: {
|
||||
type: 'invalid_input',
|
||||
args: { plan: 'string' },
|
||||
message: ({ plan }) => `Unsupported subscription plan: ${plan}.`,
|
||||
},
|
||||
failed_to_checkout: {
|
||||
type: 'internal_server_error',
|
||||
message: 'Failed to create checkout session.',
|
||||
},
|
||||
invalid_checkout_parameters: {
|
||||
type: 'invalid_input',
|
||||
message: 'Invalid checkout parameters provided.',
|
||||
},
|
||||
subscription_already_exists: {
|
||||
type: 'resource_already_exists',
|
||||
args: { plan: 'string' },
|
||||
message: ({ plan }) => `You have already subscribed to the ${plan} plan.`,
|
||||
},
|
||||
invalid_subscription_parameters: {
|
||||
type: 'invalid_input',
|
||||
message: 'Invalid subscription parameters provided.',
|
||||
},
|
||||
subscription_not_exists: {
|
||||
type: 'resource_not_found',
|
||||
args: { plan: 'string' },
|
||||
message: ({ plan }) => `You didn't subscribe to the ${plan} plan.`,
|
||||
},
|
||||
subscription_has_been_canceled: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Your subscription has already been canceled.',
|
||||
},
|
||||
subscription_has_not_been_canceled: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Your subscription has not been canceled.',
|
||||
},
|
||||
subscription_expired: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Your subscription has expired.',
|
||||
},
|
||||
same_subscription_recurring: {
|
||||
type: 'bad_request',
|
||||
args: { recurring: 'string' },
|
||||
message: ({ recurring }) =>
|
||||
`Your subscription has already been in ${recurring} recurring state.`,
|
||||
},
|
||||
customer_portal_create_failed: {
|
||||
type: 'internal_server_error',
|
||||
message: 'Failed to create customer portal session.',
|
||||
},
|
||||
subscription_plan_not_found: {
|
||||
type: 'resource_not_found',
|
||||
args: { plan: 'string', recurring: 'string' },
|
||||
message: 'You are trying to access a unknown subscription plan.',
|
||||
},
|
||||
cant_update_onetime_payment_subscription: {
|
||||
type: 'action_forbidden',
|
||||
message: 'You cannot update an onetime payment subscription.',
|
||||
},
|
||||
workspace_id_required_for_team_subscription: {
|
||||
type: 'invalid_input',
|
||||
message: 'A workspace is required to checkout for team subscription.',
|
||||
},
|
||||
workspace_id_required_to_update_team_subscription: {
|
||||
type: 'invalid_input',
|
||||
message: 'Workspace id is required to update team subscription.',
|
||||
},
|
||||
|
||||
// Copilot errors
|
||||
copilot_session_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: `Copilot session not found.`,
|
||||
},
|
||||
copilot_session_deleted: {
|
||||
type: 'action_forbidden',
|
||||
message: `Copilot session has been deleted.`,
|
||||
},
|
||||
no_copilot_provider_available: {
|
||||
type: 'internal_server_error',
|
||||
message: `No copilot provider available.`,
|
||||
},
|
||||
copilot_failed_to_generate_text: {
|
||||
type: 'internal_server_error',
|
||||
message: `Failed to generate text.`,
|
||||
},
|
||||
copilot_failed_to_create_message: {
|
||||
type: 'internal_server_error',
|
||||
message: `Failed to create chat message.`,
|
||||
},
|
||||
unsplash_is_not_configured: {
|
||||
type: 'internal_server_error',
|
||||
message: `Unsplash is not configured.`,
|
||||
},
|
||||
copilot_action_taken: {
|
||||
type: 'action_forbidden',
|
||||
message: `Action has been taken, no more messages allowed.`,
|
||||
},
|
||||
copilot_message_not_found: {
|
||||
type: 'resource_not_found',
|
||||
args: { messageId: 'string' },
|
||||
message: ({ messageId }) => `Copilot message ${messageId} not found.`,
|
||||
},
|
||||
copilot_prompt_not_found: {
|
||||
type: 'resource_not_found',
|
||||
args: { name: 'string' },
|
||||
message: ({ name }) => `Copilot prompt ${name} not found.`,
|
||||
},
|
||||
copilot_prompt_invalid: {
|
||||
type: 'invalid_input',
|
||||
message: `Copilot prompt is invalid.`,
|
||||
},
|
||||
copilot_provider_side_error: {
|
||||
type: 'internal_server_error',
|
||||
args: { provider: 'string', kind: 'string', message: 'string' },
|
||||
message: ({ provider, kind, message }) =>
|
||||
`Provider ${provider} failed with ${kind} error: ${message || 'unknown'}`,
|
||||
},
|
||||
|
||||
// Quota & Limit errors
|
||||
blob_quota_exceeded: {
|
||||
type: 'quota_exceeded',
|
||||
message: 'You have exceeded your blob storage quota.',
|
||||
},
|
||||
member_quota_exceeded: {
|
||||
type: 'quota_exceeded',
|
||||
message: 'You have exceeded your workspace member quota.',
|
||||
},
|
||||
copilot_quota_exceeded: {
|
||||
type: 'quota_exceeded',
|
||||
message:
|
||||
'You have reached the limit of actions in this workspace, please upgrade your plan.',
|
||||
},
|
||||
|
||||
// Config errors
|
||||
runtime_config_not_found: {
|
||||
type: 'resource_not_found',
|
||||
args: { key: 'string' },
|
||||
message: ({ key }) => `Runtime config ${key} not found.`,
|
||||
},
|
||||
invalid_runtime_config_type: {
|
||||
type: 'invalid_input',
|
||||
args: { key: 'string', want: 'string', get: 'string' },
|
||||
message: ({ key, want, get }) =>
|
||||
`Invalid runtime config type for '${key}', want '${want}', but get ${get}.`,
|
||||
},
|
||||
mailer_service_is_not_configured: {
|
||||
type: 'internal_server_error',
|
||||
message: 'Mailer service is not configured.',
|
||||
},
|
||||
cannot_delete_all_admin_account: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Cannot delete all admin accounts.',
|
||||
},
|
||||
cannot_delete_own_account: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Cannot delete own account.',
|
||||
},
|
||||
|
||||
// captcha errors
|
||||
captcha_verification_failed: {
|
||||
type: 'bad_request',
|
||||
message: 'Captcha verification failed.',
|
||||
},
|
||||
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||
@@ -0,0 +1,678 @@
|
||||
/* eslint-disable */
|
||||
// AUTO GENERATED FILE
|
||||
import { createUnionType, Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { UserFriendlyError } from './def';
|
||||
|
||||
export class InternalServerError extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'internal_server_error', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class TooManyRequest extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('too_many_requests', 'too_many_request', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'user_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAvatarNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'user_avatar_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailAlreadyUsed extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_already_exists', 'email_already_used', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SameEmailProvided extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'same_email_provided', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class WrongSignInCredentialsDataType {
|
||||
@Field() email!: string
|
||||
}
|
||||
|
||||
export class WrongSignInCredentials extends UserFriendlyError {
|
||||
constructor(args: WrongSignInCredentialsDataType, message?: string | ((args: WrongSignInCredentialsDataType) => string)) {
|
||||
super('invalid_input', 'wrong_sign_in_credentials', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class UnknownOauthProviderDataType {
|
||||
@Field() name!: string
|
||||
}
|
||||
|
||||
export class UnknownOauthProvider extends UserFriendlyError {
|
||||
constructor(args: UnknownOauthProviderDataType, message?: string | ((args: UnknownOauthProviderDataType) => string)) {
|
||||
super('invalid_input', 'unknown_oauth_provider', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class OauthStateExpired extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('bad_request', 'oauth_state_expired', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidOauthCallbackState extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('bad_request', 'invalid_oauth_callback_state', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class MissingOauthQueryParameterDataType {
|
||||
@Field() name!: string
|
||||
}
|
||||
|
||||
export class MissingOauthQueryParameter extends UserFriendlyError {
|
||||
constructor(args: MissingOauthQueryParameterDataType, message?: string | ((args: MissingOauthQueryParameterDataType) => string)) {
|
||||
super('bad_request', 'missing_oauth_query_parameter', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class OauthAccountAlreadyConnected extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('bad_request', 'oauth_account_already_connected', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class InvalidEmailDataType {
|
||||
@Field() email!: string
|
||||
}
|
||||
|
||||
export class InvalidEmail extends UserFriendlyError {
|
||||
constructor(args: InvalidEmailDataType, message?: string | ((args: InvalidEmailDataType) => string)) {
|
||||
super('invalid_input', 'invalid_email', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class InvalidPasswordLengthDataType {
|
||||
@Field() min!: number
|
||||
@Field() max!: number
|
||||
}
|
||||
|
||||
export class InvalidPasswordLength extends UserFriendlyError {
|
||||
constructor(args: InvalidPasswordLengthDataType, message?: string | ((args: InvalidPasswordLengthDataType) => string)) {
|
||||
super('invalid_input', 'invalid_password_length', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordRequired extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'password_required', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class WrongSignInMethod extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'wrong_sign_in_method', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class EarlyAccessRequired extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'early_access_required', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SignUpForbidden extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'sign_up_forbidden', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailTokenNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'email_token_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidEmailToken extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'invalid_email_token', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkExpired extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('bad_request', 'link_expired', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationRequired extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('authentication_required', 'authentication_required', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionForbidden extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'action_forbidden', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class AccessDenied extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('no_permission', 'access_denied', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailVerificationRequired extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'email_verification_required', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SpaceNotFoundDataType {
|
||||
@Field() spaceId!: string
|
||||
}
|
||||
|
||||
export class SpaceNotFound extends UserFriendlyError {
|
||||
constructor(args: SpaceNotFoundDataType, message?: string | ((args: SpaceNotFoundDataType) => string)) {
|
||||
super('resource_not_found', 'space_not_found', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class NotInSpaceDataType {
|
||||
@Field() spaceId!: string
|
||||
}
|
||||
|
||||
export class NotInSpace extends UserFriendlyError {
|
||||
constructor(args: NotInSpaceDataType, message?: string | ((args: NotInSpaceDataType) => string)) {
|
||||
super('action_forbidden', 'not_in_space', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class AlreadyInSpaceDataType {
|
||||
@Field() spaceId!: string
|
||||
}
|
||||
|
||||
export class AlreadyInSpace extends UserFriendlyError {
|
||||
constructor(args: AlreadyInSpaceDataType, message?: string | ((args: AlreadyInSpaceDataType) => string)) {
|
||||
super('action_forbidden', 'already_in_space', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SpaceAccessDeniedDataType {
|
||||
@Field() spaceId!: string
|
||||
}
|
||||
|
||||
export class SpaceAccessDenied extends UserFriendlyError {
|
||||
constructor(args: SpaceAccessDeniedDataType, message?: string | ((args: SpaceAccessDeniedDataType) => string)) {
|
||||
super('no_permission', 'space_access_denied', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SpaceOwnerNotFoundDataType {
|
||||
@Field() spaceId!: string
|
||||
}
|
||||
|
||||
export class SpaceOwnerNotFound extends UserFriendlyError {
|
||||
constructor(args: SpaceOwnerNotFoundDataType, message?: string | ((args: SpaceOwnerNotFoundDataType) => string)) {
|
||||
super('internal_server_error', 'space_owner_not_found', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CantChangeSpaceOwner extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cant_change_space_owner', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class DocNotFoundDataType {
|
||||
@Field() spaceId!: string
|
||||
@Field() docId!: string
|
||||
}
|
||||
|
||||
export class DocNotFound extends UserFriendlyError {
|
||||
constructor(args: DocNotFoundDataType, message?: string | ((args: DocNotFoundDataType) => string)) {
|
||||
super('resource_not_found', 'doc_not_found', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class DocAccessDeniedDataType {
|
||||
@Field() spaceId!: string
|
||||
@Field() docId!: string
|
||||
}
|
||||
|
||||
export class DocAccessDenied extends UserFriendlyError {
|
||||
constructor(args: DocAccessDeniedDataType, message?: string | ((args: DocAccessDeniedDataType) => string)) {
|
||||
super('no_permission', 'doc_access_denied', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class VersionRejectedDataType {
|
||||
@Field() version!: string
|
||||
@Field() serverVersion!: string
|
||||
}
|
||||
|
||||
export class VersionRejected extends UserFriendlyError {
|
||||
constructor(args: VersionRejectedDataType, message?: string | ((args: VersionRejectedDataType) => string)) {
|
||||
super('action_forbidden', 'version_rejected', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class InvalidHistoryTimestampDataType {
|
||||
@Field() timestamp!: string
|
||||
}
|
||||
|
||||
export class InvalidHistoryTimestamp extends UserFriendlyError {
|
||||
constructor(args: InvalidHistoryTimestampDataType, message?: string | ((args: InvalidHistoryTimestampDataType) => string)) {
|
||||
super('invalid_input', 'invalid_history_timestamp', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class DocHistoryNotFoundDataType {
|
||||
@Field() spaceId!: string
|
||||
@Field() docId!: string
|
||||
@Field() timestamp!: number
|
||||
}
|
||||
|
||||
export class DocHistoryNotFound extends UserFriendlyError {
|
||||
constructor(args: DocHistoryNotFoundDataType, message?: string | ((args: DocHistoryNotFoundDataType) => string)) {
|
||||
super('resource_not_found', 'doc_history_not_found', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class BlobNotFoundDataType {
|
||||
@Field() spaceId!: string
|
||||
@Field() blobId!: string
|
||||
}
|
||||
|
||||
export class BlobNotFound extends UserFriendlyError {
|
||||
constructor(args: BlobNotFoundDataType, message?: string | ((args: BlobNotFoundDataType) => string)) {
|
||||
super('resource_not_found', 'blob_not_found', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpectToPublishPage extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'expect_to_publish_page', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpectToRevokePublicPage extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'expect_to_revoke_public_page', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class PageIsNotPublic extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('bad_request', 'page_is_not_public', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class FailedToSaveUpdates extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'failed_to_save_updates', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class FailedToUpsertSnapshot extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'failed_to_upsert_snapshot', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class UnsupportedSubscriptionPlanDataType {
|
||||
@Field() plan!: string
|
||||
}
|
||||
|
||||
export class UnsupportedSubscriptionPlan extends UserFriendlyError {
|
||||
constructor(args: UnsupportedSubscriptionPlanDataType, message?: string | ((args: UnsupportedSubscriptionPlanDataType) => string)) {
|
||||
super('invalid_input', 'unsupported_subscription_plan', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class FailedToCheckout extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'failed_to_checkout', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCheckoutParameters extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'invalid_checkout_parameters', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SubscriptionAlreadyExistsDataType {
|
||||
@Field() plan!: string
|
||||
}
|
||||
|
||||
export class SubscriptionAlreadyExists extends UserFriendlyError {
|
||||
constructor(args: SubscriptionAlreadyExistsDataType, message?: string | ((args: SubscriptionAlreadyExistsDataType) => string)) {
|
||||
super('resource_already_exists', 'subscription_already_exists', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidSubscriptionParameters extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'invalid_subscription_parameters', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SubscriptionNotExistsDataType {
|
||||
@Field() plan!: string
|
||||
}
|
||||
|
||||
export class SubscriptionNotExists extends UserFriendlyError {
|
||||
constructor(args: SubscriptionNotExistsDataType, message?: string | ((args: SubscriptionNotExistsDataType) => string)) {
|
||||
super('resource_not_found', 'subscription_not_exists', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionHasBeenCanceled extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'subscription_has_been_canceled', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionHasNotBeenCanceled extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'subscription_has_not_been_canceled', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionExpired extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'subscription_expired', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SameSubscriptionRecurringDataType {
|
||||
@Field() recurring!: string
|
||||
}
|
||||
|
||||
export class SameSubscriptionRecurring extends UserFriendlyError {
|
||||
constructor(args: SameSubscriptionRecurringDataType, message?: string | ((args: SameSubscriptionRecurringDataType) => string)) {
|
||||
super('bad_request', 'same_subscription_recurring', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomerPortalCreateFailed extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'customer_portal_create_failed', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SubscriptionPlanNotFoundDataType {
|
||||
@Field() plan!: string
|
||||
@Field() recurring!: string
|
||||
}
|
||||
|
||||
export class SubscriptionPlanNotFound extends UserFriendlyError {
|
||||
constructor(args: SubscriptionPlanNotFoundDataType, message?: string | ((args: SubscriptionPlanNotFoundDataType) => string)) {
|
||||
super('resource_not_found', 'subscription_plan_not_found', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CantUpdateOnetimePaymentSubscription extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cant_update_onetime_payment_subscription', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceIdRequiredForTeamSubscription extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'workspace_id_required_for_team_subscription', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceIdRequiredToUpdateTeamSubscription extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'workspace_id_required_to_update_team_subscription', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotSessionNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'copilot_session_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotSessionDeleted extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'copilot_session_deleted', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NoCopilotProviderAvailable extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'no_copilot_provider_available', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotFailedToGenerateText extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'copilot_failed_to_generate_text', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotFailedToCreateMessage extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'copilot_failed_to_create_message', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsplashIsNotConfigured extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'unsplash_is_not_configured', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotActionTaken extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'copilot_action_taken', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class CopilotMessageNotFoundDataType {
|
||||
@Field() messageId!: string
|
||||
}
|
||||
|
||||
export class CopilotMessageNotFound extends UserFriendlyError {
|
||||
constructor(args: CopilotMessageNotFoundDataType, message?: string | ((args: CopilotMessageNotFoundDataType) => string)) {
|
||||
super('resource_not_found', 'copilot_message_not_found', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class CopilotPromptNotFoundDataType {
|
||||
@Field() name!: string
|
||||
}
|
||||
|
||||
export class CopilotPromptNotFound extends UserFriendlyError {
|
||||
constructor(args: CopilotPromptNotFoundDataType, message?: string | ((args: CopilotPromptNotFoundDataType) => string)) {
|
||||
super('resource_not_found', 'copilot_prompt_not_found', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotPromptInvalid extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('invalid_input', 'copilot_prompt_invalid', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class CopilotProviderSideErrorDataType {
|
||||
@Field() provider!: string
|
||||
@Field() kind!: string
|
||||
@Field() message!: string
|
||||
}
|
||||
|
||||
export class CopilotProviderSideError extends UserFriendlyError {
|
||||
constructor(args: CopilotProviderSideErrorDataType, message?: string | ((args: CopilotProviderSideErrorDataType) => string)) {
|
||||
super('internal_server_error', 'copilot_provider_side_error', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class BlobQuotaExceeded extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('quota_exceeded', 'blob_quota_exceeded', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class MemberQuotaExceeded extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('quota_exceeded', 'member_quota_exceeded', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotQuotaExceeded extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('quota_exceeded', 'copilot_quota_exceeded', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class RuntimeConfigNotFoundDataType {
|
||||
@Field() key!: string
|
||||
}
|
||||
|
||||
export class RuntimeConfigNotFound extends UserFriendlyError {
|
||||
constructor(args: RuntimeConfigNotFoundDataType, message?: string | ((args: RuntimeConfigNotFoundDataType) => string)) {
|
||||
super('resource_not_found', 'runtime_config_not_found', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class InvalidRuntimeConfigTypeDataType {
|
||||
@Field() key!: string
|
||||
@Field() want!: string
|
||||
@Field() get!: string
|
||||
}
|
||||
|
||||
export class InvalidRuntimeConfigType extends UserFriendlyError {
|
||||
constructor(args: InvalidRuntimeConfigTypeDataType, message?: string | ((args: InvalidRuntimeConfigTypeDataType) => string)) {
|
||||
super('invalid_input', 'invalid_runtime_config_type', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class MailerServiceIsNotConfigured extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'mailer_service_is_not_configured', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CannotDeleteAllAdminAccount extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cannot_delete_all_admin_account', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CannotDeleteOwnAccount extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cannot_delete_own_account', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CaptchaVerificationFailed extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('bad_request', 'captcha_verification_failed', message);
|
||||
}
|
||||
}
|
||||
export enum ErrorNames {
|
||||
INTERNAL_SERVER_ERROR,
|
||||
TOO_MANY_REQUEST,
|
||||
NOT_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
USER_AVATAR_NOT_FOUND,
|
||||
EMAIL_ALREADY_USED,
|
||||
SAME_EMAIL_PROVIDED,
|
||||
WRONG_SIGN_IN_CREDENTIALS,
|
||||
UNKNOWN_OAUTH_PROVIDER,
|
||||
OAUTH_STATE_EXPIRED,
|
||||
INVALID_OAUTH_CALLBACK_STATE,
|
||||
MISSING_OAUTH_QUERY_PARAMETER,
|
||||
OAUTH_ACCOUNT_ALREADY_CONNECTED,
|
||||
INVALID_EMAIL,
|
||||
INVALID_PASSWORD_LENGTH,
|
||||
PASSWORD_REQUIRED,
|
||||
WRONG_SIGN_IN_METHOD,
|
||||
EARLY_ACCESS_REQUIRED,
|
||||
SIGN_UP_FORBIDDEN,
|
||||
EMAIL_TOKEN_NOT_FOUND,
|
||||
INVALID_EMAIL_TOKEN,
|
||||
LINK_EXPIRED,
|
||||
AUTHENTICATION_REQUIRED,
|
||||
ACTION_FORBIDDEN,
|
||||
ACCESS_DENIED,
|
||||
EMAIL_VERIFICATION_REQUIRED,
|
||||
SPACE_NOT_FOUND,
|
||||
NOT_IN_SPACE,
|
||||
ALREADY_IN_SPACE,
|
||||
SPACE_ACCESS_DENIED,
|
||||
SPACE_OWNER_NOT_FOUND,
|
||||
CANT_CHANGE_SPACE_OWNER,
|
||||
DOC_NOT_FOUND,
|
||||
DOC_ACCESS_DENIED,
|
||||
VERSION_REJECTED,
|
||||
INVALID_HISTORY_TIMESTAMP,
|
||||
DOC_HISTORY_NOT_FOUND,
|
||||
BLOB_NOT_FOUND,
|
||||
EXPECT_TO_PUBLISH_PAGE,
|
||||
EXPECT_TO_REVOKE_PUBLIC_PAGE,
|
||||
PAGE_IS_NOT_PUBLIC,
|
||||
FAILED_TO_SAVE_UPDATES,
|
||||
FAILED_TO_UPSERT_SNAPSHOT,
|
||||
UNSUPPORTED_SUBSCRIPTION_PLAN,
|
||||
FAILED_TO_CHECKOUT,
|
||||
INVALID_CHECKOUT_PARAMETERS,
|
||||
SUBSCRIPTION_ALREADY_EXISTS,
|
||||
INVALID_SUBSCRIPTION_PARAMETERS,
|
||||
SUBSCRIPTION_NOT_EXISTS,
|
||||
SUBSCRIPTION_HAS_BEEN_CANCELED,
|
||||
SUBSCRIPTION_HAS_NOT_BEEN_CANCELED,
|
||||
SUBSCRIPTION_EXPIRED,
|
||||
SAME_SUBSCRIPTION_RECURRING,
|
||||
CUSTOMER_PORTAL_CREATE_FAILED,
|
||||
SUBSCRIPTION_PLAN_NOT_FOUND,
|
||||
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
|
||||
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
|
||||
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
|
||||
COPILOT_SESSION_NOT_FOUND,
|
||||
COPILOT_SESSION_DELETED,
|
||||
NO_COPILOT_PROVIDER_AVAILABLE,
|
||||
COPILOT_FAILED_TO_GENERATE_TEXT,
|
||||
COPILOT_FAILED_TO_CREATE_MESSAGE,
|
||||
UNSPLASH_IS_NOT_CONFIGURED,
|
||||
COPILOT_ACTION_TAKEN,
|
||||
COPILOT_MESSAGE_NOT_FOUND,
|
||||
COPILOT_PROMPT_NOT_FOUND,
|
||||
COPILOT_PROMPT_INVALID,
|
||||
COPILOT_PROVIDER_SIDE_ERROR,
|
||||
BLOB_QUOTA_EXCEEDED,
|
||||
MEMBER_QUOTA_EXCEEDED,
|
||||
COPILOT_QUOTA_EXCEEDED,
|
||||
RUNTIME_CONFIG_NOT_FOUND,
|
||||
INVALID_RUNTIME_CONFIG_TYPE,
|
||||
MAILER_SERVICE_IS_NOT_CONFIGURED,
|
||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
|
||||
CANNOT_DELETE_OWN_ACCOUNT,
|
||||
CAPTCHA_VERIFICATION_FAILED
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
})
|
||||
|
||||
export const ErrorDataUnionType = createUnionType({
|
||||
name: 'ErrorDataUnion',
|
||||
types: () =>
|
||||
[WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const,
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
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 { Config } from '../config/provider';
|
||||
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');
|
||||
constructor(private readonly config: Config) {}
|
||||
onModuleInit() {
|
||||
if (!this.config.node.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';
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Snapshot, User, Workspace } from '@prisma/client';
|
||||
|
||||
import { Flatten, Payload } from './types';
|
||||
|
||||
export interface WorkspaceEvents {
|
||||
team: {
|
||||
seatAvailable: Payload<{ inviteId: string; email: string }[]>;
|
||||
reviewRequest: Payload<{ inviteIds: string[] }>;
|
||||
declineRequest: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
inviteeId: User['id'];
|
||||
}>;
|
||||
};
|
||||
deleted: Payload<Workspace['id']>;
|
||||
blob: {
|
||||
deleted: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
key: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DocEvents {
|
||||
deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
|
||||
updated: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
|
||||
}
|
||||
|
||||
export interface UserEvents {
|
||||
updated: Payload<Omit<User, 'password'>>;
|
||||
deleted: Payload<
|
||||
User & {
|
||||
ownedWorkspaces: Workspace['id'][];
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event definitions can be extended by
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* declare module './event/def' {
|
||||
* interface UserEvents {
|
||||
* created: Payload<User>;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* assert<Event, 'user.created'>()
|
||||
*/
|
||||
export interface EventDefinitions {
|
||||
workspace: WorkspaceEvents;
|
||||
snapshot: DocEvents;
|
||||
user: UserEvents;
|
||||
}
|
||||
|
||||
export type EventKV = Flatten<EventDefinitions>;
|
||||
|
||||
export type Event = keyof EventKV;
|
||||
export type EventPayload<E extends Event> = EventKV[E];
|
||||
export type { Payload };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Global, Injectable, Module } from '@nestjs/common';
|
||||
import {
|
||||
EventEmitter2,
|
||||
EventEmitterModule,
|
||||
OnEvent as RawOnEvent,
|
||||
} from '@nestjs/event-emitter';
|
||||
|
||||
import type { Event, EventPayload } from './def';
|
||||
|
||||
@Injectable()
|
||||
export class EventEmitter {
|
||||
constructor(private readonly emitter: EventEmitter2) {}
|
||||
|
||||
emit<E extends Event>(event: E, payload: EventPayload<E>) {
|
||||
return this.emitter.emit(event, payload);
|
||||
}
|
||||
|
||||
emitAsync<E extends Event>(event: E, payload: EventPayload<E>) {
|
||||
return this.emitter.emitAsync(event, payload);
|
||||
}
|
||||
|
||||
on<E extends Event>(event: E, handler: (payload: EventPayload<E>) => void) {
|
||||
return this.emitter.on(event, handler);
|
||||
}
|
||||
|
||||
once<E extends Event>(event: E, handler: (payload: EventPayload<E>) => void) {
|
||||
return this.emitter.once(event, handler);
|
||||
}
|
||||
}
|
||||
|
||||
export const OnEvent = RawOnEvent as (
|
||||
event: Event,
|
||||
opts?: Parameters<typeof RawOnEvent>[1]
|
||||
) => MethodDecorator;
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [EventEmitterModule.forRoot()],
|
||||
providers: [EventEmitter],
|
||||
exports: [EventEmitter],
|
||||
})
|
||||
export class EventModule {}
|
||||
export { Event, EventPayload };
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Join, PathType } from '../utils/types';
|
||||
|
||||
export type Payload<T> = {
|
||||
__payload: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type Leaves<T, P extends string = ''> =
|
||||
T extends Record<string, any>
|
||||
? {
|
||||
[K in keyof T]: K extends string
|
||||
? T[K] extends Payload<any>
|
||||
? K
|
||||
: Join<K, Leaves<T[K], P>>
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
export type Flatten<T extends Record<string, any>> = {
|
||||
// @ts-expect-error allow
|
||||
[K in Leaves<T>]: PathType<T, K> extends Payload<infer U> ? U : never;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../../base/config';
|
||||
|
||||
declare module '../../base/config' {
|
||||
interface AppConfig {
|
||||
graphql: ModuleConfig<ApolloDriverConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('graphql', {
|
||||
buildSchemaOptions: {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
introspection: true,
|
||||
playground: true,
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import './config';
|
||||
|
||||
import { STATUS_CODES } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { ApolloDriver } from '@nestjs/apollo';
|
||||
import { Global, HttpStatus, Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { Request, Response } from 'express';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { UserFriendlyError } from '../error';
|
||||
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,
|
||||
path: `${config.server.path}/graphql`,
|
||||
csrfPrevention: {
|
||||
requestHeaders: ['content-type'],
|
||||
},
|
||||
autoSchemaFile: join(
|
||||
fileURLToPath(import.meta.url),
|
||||
config.node.dev
|
||||
? '../../../schema.gql'
|
||||
: '../../../../node_modules/.cache/schema.gql'
|
||||
),
|
||||
sortSchema: true,
|
||||
context: ({
|
||||
req,
|
||||
res,
|
||||
}: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
}): GraphqlContext => ({
|
||||
req,
|
||||
res,
|
||||
isAdminQuery: false,
|
||||
}),
|
||||
includeStacktraceInErrorResponses: !config.node.prod,
|
||||
plugins: [new GQLLoggerPlugin()],
|
||||
formatError: (formattedError, error) => {
|
||||
// @ts-expect-error allow assign
|
||||
formattedError.extensions ??= {};
|
||||
|
||||
if (
|
||||
error instanceof GraphQLError &&
|
||||
error.originalError instanceof UserFriendlyError
|
||||
) {
|
||||
// @ts-expect-error allow assign
|
||||
formattedError.extensions = error.originalError.toJSON();
|
||||
formattedError.extensions.stacktrace = error.originalError.stack;
|
||||
return formattedError;
|
||||
} else {
|
||||
// @ts-expect-error allow assign
|
||||
formattedError.message = 'Internal Server Error';
|
||||
|
||||
formattedError.extensions['status'] =
|
||||
HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
formattedError.extensions['code'] =
|
||||
STATUS_CODES[HttpStatus.INTERNAL_SERVER_ERROR];
|
||||
}
|
||||
|
||||
return formattedError;
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [Config],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class GqlModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
ApolloServerPlugin,
|
||||
GraphQLRequestContext,
|
||||
GraphQLRequestListener,
|
||||
} from '@apollo/server';
|
||||
import { Plugin } from '@nestjs/apollo';
|
||||
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 {
|
||||
requestDidStart(
|
||||
ctx: GraphQLRequestContext<RequestContext>
|
||||
): Promise<GraphQLRequestListener<GraphQLRequestContext<RequestContext>>> {
|
||||
const res = ctx.contextValue.req.res as Response;
|
||||
const operation = ctx.request.operationName;
|
||||
|
||||
metrics.gql.counter('query_counter').add(1, { operation });
|
||||
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, { operation });
|
||||
return Promise.resolve();
|
||||
},
|
||||
didEncounterErrors: ctx => {
|
||||
ctx.errors.forEach(gqlErr => {
|
||||
const error = mapAnyError(
|
||||
gqlErr.originalError ? gqlErr.originalError : gqlErr
|
||||
);
|
||||
error.log('GraphQL');
|
||||
|
||||
metrics.gql.counter('query_error_counter').add(1, {
|
||||
operation,
|
||||
code: error.status,
|
||||
type: error.type,
|
||||
error: error.name,
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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()
|
||||
);
|
||||
|
||||
const provider = GUARD_PROVIDER[providerName as NamedGuards];
|
||||
if (provider) {
|
||||
return await provider.canActivate(context);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -0,0 +1,2 @@
|
||||
export { UseNamedGuard } from './guard';
|
||||
export { GuardProvider, type RegisterGuardName } from './provider';
|
||||
@@ -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,98 @@
|
||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { CryptoHelper } from '../crypto';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
crypto: CryptoHelper;
|
||||
}>;
|
||||
|
||||
const key = `-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
|
||||
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
|
||||
-----END EC PRIVATE KEY-----`;
|
||||
const privateKey = createPrivateKey({
|
||||
key,
|
||||
format: 'pem',
|
||||
type: 'sec1',
|
||||
})
|
||||
.export({
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
})
|
||||
.toString('utf8');
|
||||
|
||||
const publicKey = createPublicKey({
|
||||
key,
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.export({
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.toString('utf8');
|
||||
|
||||
test.beforeEach(async t => {
|
||||
t.context.crypto = new CryptoHelper({
|
||||
crypto: {
|
||||
secret: {
|
||||
publicKey,
|
||||
privateKey,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
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(data, signature));
|
||||
t.false(t.context.crypto.verify(data, 'fake-signature'));
|
||||
});
|
||||
|
||||
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 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
|
||||
t.is(encrypted, 'AAAAAAAAAAAAAAAAWUDlJRhzP+SZ3avvmLcgnou+q4E11w==');
|
||||
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('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 and verify password', async t => {
|
||||
const password = 'mySecurePassword';
|
||||
const hash = await t.context.crypto.encryptPassword(password);
|
||||
t.true(await t.context.crypto.verifyPassword(password, hash));
|
||||
t.false(await t.context.crypto.verifyPassword('wrong-password', hash));
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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 safe redirect', t => {
|
||||
const res = {
|
||||
redirect: (to: string) => to,
|
||||
} as any;
|
||||
|
||||
const spy = Sinon.spy(res, 'redirect');
|
||||
function allow(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(to));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
function deny(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(t.context.url.home));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
[
|
||||
'https://app.affine.local',
|
||||
'https://app.affine.local/path',
|
||||
'https://app.affine.local/path?query=1',
|
||||
].forEach(allow);
|
||||
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
crypto: ModuleConfig<{
|
||||
secret: {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't use this in production
|
||||
const examplePrivateKey = `-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
|
||||
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
|
||||
-----END EC PRIVATE KEY-----`;
|
||||
|
||||
defineStartupConfig('crypto', {
|
||||
secret: (function () {
|
||||
const AFFINE_PRIVATE_KEY =
|
||||
process.env.AFFINE_PRIVATE_KEY ?? examplePrivateKey;
|
||||
const privateKey = createPrivateKey({
|
||||
key: Buffer.from(AFFINE_PRIVATE_KEY),
|
||||
format: 'pem',
|
||||
type: 'sec1',
|
||||
})
|
||||
.export({
|
||||
format: 'pem',
|
||||
type: 'pkcs8',
|
||||
})
|
||||
.toString('utf8');
|
||||
const publicKey = createPublicKey({
|
||||
key: Buffer.from(AFFINE_PRIVATE_KEY),
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.export({
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.toString('utf8');
|
||||
|
||||
return {
|
||||
publicKey,
|
||||
privateKey,
|
||||
};
|
||||
})(),
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createSign,
|
||||
createVerify,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
hash as hashPassword,
|
||||
verify as verifyPassword,
|
||||
} from '@node-rs/argon2';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
const NONCE_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 12;
|
||||
|
||||
@Injectable()
|
||||
export class CryptoHelper {
|
||||
keyPair: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
sha256: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
};
|
||||
};
|
||||
|
||||
constructor(config: Config) {
|
||||
this.keyPair = {
|
||||
publicKey: Buffer.from(config.crypto.secret.publicKey, 'utf8'),
|
||||
privateKey: Buffer.from(config.crypto.secret.privateKey, 'utf8'),
|
||||
sha256: {
|
||||
publicKey: this.sha256(config.crypto.secret.publicKey),
|
||||
privateKey: this.sha256(config.crypto.secret.privateKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
sign(data: string) {
|
||||
const sign = createSign('rsa-sha256');
|
||||
sign.update(data, 'utf-8');
|
||||
sign.end();
|
||||
return sign.sign(this.keyPair.privateKey, 'base64');
|
||||
}
|
||||
|
||||
verify(data: string, signature: string) {
|
||||
const verify = createVerify('rsa-sha256');
|
||||
verify.update(data, 'utf-8');
|
||||
verify.end();
|
||||
return verify.verify(this.keyPair.privateKey, signature, 'base64');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
verifyPassword(password: string, hash: string) {
|
||||
return verifyPassword(hash, 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);
|
||||
}
|
||||
|
||||
sha256(data: string) {
|
||||
return createHash('sha256').update(data).digest();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,96 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
@Injectable()
|
||||
export class URLHelper {
|
||||
private readonly redirectAllowHosts: string[];
|
||||
|
||||
readonly origin: string;
|
||||
readonly baseUrl: string;
|
||||
readonly home: string;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
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.config.server.https ? 'https' : 'http',
|
||||
'://',
|
||||
this.config.server.host,
|
||||
this.config.server.host === 'localhost' || isIP(this.config.server.host)
|
||||
? `:${this.config.server.port}`
|
||||
: '',
|
||||
].join('');
|
||||
this.baseUrl = this.origin + this.config.server.path;
|
||||
}
|
||||
|
||||
this.home = this.baseUrl;
|
||||
this.redirectAllowHosts = [this.baseUrl];
|
||||
}
|
||||
|
||||
stringify(query: Record<string, any>) {
|
||||
return new URLSearchParams(query).toString();
|
||||
}
|
||||
|
||||
url(path: string, query: Record<string, any> = {}) {
|
||||
const url = new URL(path, this.origin);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
safeRedirect(res: Response, to: string) {
|
||||
try {
|
||||
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
|
||||
|
||||
for (const host of this.redirectAllowHosts) {
|
||||
const hostURL = new URL(host);
|
||||
if (
|
||||
hostURL.origin === finalTo.origin &&
|
||||
finalTo.pathname.startsWith(hostURL.pathname)
|
||||
) {
|
||||
return res.redirect(finalTo.toString().replace(/\/$/, ''));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// just ignore invalid url
|
||||
}
|
||||
|
||||
// redirect to home if the url is invalid
|
||||
return res.redirect(this.home);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export {
|
||||
Cache,
|
||||
CacheInterceptor,
|
||||
MakeCache,
|
||||
PreventCache,
|
||||
SessionCache,
|
||||
} from './cache';
|
||||
export {
|
||||
type AFFiNEConfig,
|
||||
applyEnvToConfig,
|
||||
Config,
|
||||
type ConfigPaths,
|
||||
DeploymentType,
|
||||
getAFFiNEConfigModifier,
|
||||
} from './config';
|
||||
export * from './error';
|
||||
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
||||
export type { GraphqlContext } from './graphql';
|
||||
export * from './guard';
|
||||
export { CryptoHelper, URLHelper } from './helpers';
|
||||
export { MailService } from './mailer';
|
||||
export { CallMetric, metrics } from './metrics';
|
||||
export { type ILocker, Lock, Locker, Mutex, RequestMutex } from './mutex';
|
||||
export {
|
||||
GatewayErrorWrapper,
|
||||
getOptionalModuleMetadata,
|
||||
GlobalExceptionFilter,
|
||||
mapAnyError,
|
||||
mapSseError,
|
||||
OptionalModule,
|
||||
} from './nestjs';
|
||||
export { type PrismaTransaction } from './prisma';
|
||||
export * from './storage';
|
||||
export { type StorageProvider, StorageProviderFactory } from './storage';
|
||||
export { CloudThrottlerGuard, SkipThrottle, Throttle } from './throttler';
|
||||
export {
|
||||
getRequestFromHost,
|
||||
getRequestResponseFromContext,
|
||||
getRequestResponseFromHost,
|
||||
parseCookies,
|
||||
} from './utils/request';
|
||||
export type * from './utils/types';
|
||||
@@ -0,0 +1,16 @@
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
/**
|
||||
* Configurations for mail service used to post auth or bussiness mails.
|
||||
*
|
||||
* @see https://nodemailer.com/smtp/
|
||||
*/
|
||||
mailer: ModuleConfig<SMTPTransport.Options>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('mailer', {});
|
||||
@@ -0,0 +1,24 @@
|
||||
import './config';
|
||||
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { OptionalModule } from '../nestjs';
|
||||
import { MailService } from './mail.service';
|
||||
import { MAILER } from './mailer';
|
||||
|
||||
@Global()
|
||||
@OptionalModule({
|
||||
providers: [MAILER],
|
||||
exports: [MAILER],
|
||||
requires: ['mailer.host'],
|
||||
})
|
||||
class MailerModule {}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [MailerModule],
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
export { MailService };
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { MailerServiceIsNotConfigured } from '../error';
|
||||
import { URLHelper } from '../helpers';
|
||||
import { metrics } from '../metrics';
|
||||
import type { MailerService, Options } from './mailer';
|
||||
import { MAILER_SERVICE } from './mailer';
|
||||
import { emailTemplate } from './template';
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper,
|
||||
@Optional() @Inject(MAILER_SERVICE) private readonly mailer?: MailerService
|
||||
) {}
|
||||
|
||||
async sendMail(options: Options) {
|
||||
if (!this.mailer) {
|
||||
throw new MailerServiceIsNotConfigured();
|
||||
}
|
||||
|
||||
metrics.mail.counter('total').add(1);
|
||||
try {
|
||||
const result = await this.mailer.sendMail({
|
||||
from: this.config.mailer?.from,
|
||||
...options,
|
||||
});
|
||||
|
||||
metrics.mail.counter('sent').add(1);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
metrics.mail.counter('error').add(1);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
hasConfigured() {
|
||||
return !!this.mailer;
|
||||
}
|
||||
|
||||
async sendInviteEmail(
|
||||
to: string,
|
||||
inviteId: string,
|
||||
invitationInfo: {
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
user: {
|
||||
avatar: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const buttonUrl = this.url.link(`/invite/${inviteId}`);
|
||||
const workspaceAvatar = invitationInfo.workspace.avatar;
|
||||
|
||||
const content = `<p style="margin:0">${
|
||||
invitationInfo.user.avatar
|
||||
? `<img
|
||||
src="${invitationInfo.user.avatar}"
|
||||
alt=""
|
||||
width="24px"
|
||||
height="24px"
|
||||
style="width:24px; height:24px; border-radius: 12px;object-fit: cover;vertical-align: middle"
|
||||
/>`
|
||||
: ''
|
||||
}
|
||||
<span style="font-weight:500;margin-right: 4px;">${
|
||||
invitationInfo.user.name
|
||||
}</span>
|
||||
<span>invited you to join</span>
|
||||
<img
|
||||
src="cid:workspaceAvatar"
|
||||
alt=""
|
||||
width="24px"
|
||||
height="24px"
|
||||
style="width:24px; height:24px; margin-left:4px;border-radius: 12px;object-fit: cover;vertical-align: middle"
|
||||
/>
|
||||
<span style="font-weight:500;margin-right: 4px;">${
|
||||
invitationInfo.workspace.name
|
||||
}</span></p><p style="margin-top:8px;margin-bottom:0;">Click button to join this workspace</p>`;
|
||||
|
||||
const html = emailTemplate({
|
||||
title: 'You are invited!',
|
||||
content,
|
||||
buttonContent: 'Accept & Join',
|
||||
buttonUrl,
|
||||
});
|
||||
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: `${invitationInfo.user.name} invited you to join ${invitationInfo.workspace.name}`,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
cid: 'workspaceAvatar',
|
||||
filename: 'image.png',
|
||||
content: workspaceAvatar,
|
||||
encoding: 'base64',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignUpMail(url: string, options: Options) {
|
||||
const html = emailTemplate({
|
||||
title: 'Create AFFiNE Account',
|
||||
content:
|
||||
'Click the button below to complete your account creation and sign in. This magic link will expire in 30 minutes.',
|
||||
buttonContent: ' Create account and sign in',
|
||||
buttonUrl: url,
|
||||
});
|
||||
|
||||
return this.sendMail({
|
||||
html,
|
||||
subject: 'Your AFFiNE account is waiting for you!',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignInMail(url: string, options: Options) {
|
||||
const html = emailTemplate({
|
||||
title: 'Sign in to AFFiNE',
|
||||
content:
|
||||
'Click the button below to securely sign in. The magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Sign in to AFFiNE',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
html,
|
||||
subject: 'Sign in to AFFiNE',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async sendChangePasswordEmail(to: string, url: string) {
|
||||
const html = emailTemplate({
|
||||
title: 'Modify your AFFiNE password',
|
||||
content:
|
||||
'Click the button below to reset your password. The magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Set new password',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: `Modify your AFFiNE password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSetPasswordEmail(to: string, url: string) {
|
||||
const html = emailTemplate({
|
||||
title: 'Set your AFFiNE password',
|
||||
content:
|
||||
'Click the button below to set your password. The magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Set your password',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: `Set your AFFiNE password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendChangeEmail(to: string, url: string) {
|
||||
const html = emailTemplate({
|
||||
title: 'Verify your current email for AFFiNE',
|
||||
content:
|
||||
'You recently requested to change the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Verify and set up a new email address',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: `Verify your current email for AFFiNE`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendVerifyChangeEmail(to: string, url: string) {
|
||||
const html = emailTemplate({
|
||||
title: 'Verify your new email address',
|
||||
content:
|
||||
'You recently requested to change the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Verify your new email address',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: `Verify your new email for AFFiNE`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendVerifyEmail(to: string, url: string) {
|
||||
const html = emailTemplate({
|
||||
title: 'Verify your email address',
|
||||
content:
|
||||
'You recently requested to verify the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Verify your email address',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: `Verify your email for AFFiNE`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendNotificationChangeEmail(to: string) {
|
||||
const html = emailTemplate({
|
||||
title: 'Email change successful',
|
||||
content: `As per your request, we have changed your email. Please make sure you're using ${to} when you log in the next time. `,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: `Your email has been changed`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendAcceptedEmail(
|
||||
to: string,
|
||||
{
|
||||
inviteeName,
|
||||
workspaceName,
|
||||
}: {
|
||||
inviteeName: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
) {
|
||||
const title = `${inviteeName} accepted your invitation`;
|
||||
|
||||
const html = emailTemplate({
|
||||
title,
|
||||
content: `${inviteeName} has joined ${workspaceName}`,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: title,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendLeaveWorkspaceEmail(
|
||||
to: string,
|
||||
{
|
||||
inviteeName,
|
||||
workspaceName,
|
||||
}: {
|
||||
inviteeName: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
) {
|
||||
const title = `${inviteeName} left ${workspaceName}`;
|
||||
|
||||
const html = emailTemplate({
|
||||
title,
|
||||
content: `${inviteeName} has left your workspace`,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: title,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
// =================== Team Workspace Mails ===================
|
||||
async sendReviewRequestMail(
|
||||
to: string,
|
||||
invitee: string,
|
||||
ws: { id: string; name: string }
|
||||
) {
|
||||
const { id: workspaceId, name: workspaceName } = ws;
|
||||
const title = `New request to join ${workspaceName}`;
|
||||
|
||||
const html = emailTemplate({
|
||||
title: 'Request to join your workspace',
|
||||
content: `${invitee} has requested to join ${workspaceName}. As a workspace owner/admin, you can approve or decline this request.`,
|
||||
buttonContent: 'Review request',
|
||||
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
|
||||
});
|
||||
return this.sendMail({ to, subject: title, html });
|
||||
}
|
||||
|
||||
async sendReviewApproveEmail(to: string, ws: { id: string; name: string }) {
|
||||
const { id: workspaceId, name: workspaceName } = ws;
|
||||
const title = `Your request to join ${workspaceName} has been approved`;
|
||||
|
||||
const html = emailTemplate({
|
||||
title: 'Welcome to the workspace!',
|
||||
content: `Your request to join ${workspaceName} has been accepted. You can now access the team workspace and collaborate with other members.`,
|
||||
buttonContent: 'Open Workspace',
|
||||
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
|
||||
});
|
||||
return this.sendMail({ to, subject: title, html });
|
||||
}
|
||||
|
||||
async sendReviewDeclinedEmail(to: string, ws: { name: string }) {
|
||||
const { name: workspaceName } = ws;
|
||||
const title = `Your request to join ${workspaceName} was declined`;
|
||||
|
||||
const html = emailTemplate({
|
||||
title: 'Request declined',
|
||||
content: `Your request to join ${workspaceName} has been declined by the workspace admin.`,
|
||||
});
|
||||
return this.sendMail({ to, subject: title, html });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FactoryProvider, Logger } from '@nestjs/common';
|
||||
import { createTransport, Transporter } from 'nodemailer';
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
export const MAILER_SERVICE = Symbol('MAILER_SERVICE');
|
||||
|
||||
export type MailerService = Transporter<SMTPTransport.SentMessageInfo>;
|
||||
export type Response = SMTPTransport.SentMessageInfo;
|
||||
export type Options = SMTPTransport.Options;
|
||||
|
||||
export const MAILER: FactoryProvider<
|
||||
Transporter<SMTPTransport.SentMessageInfo> | undefined
|
||||
> = {
|
||||
provide: MAILER_SERVICE,
|
||||
useFactory: (config: Config) => {
|
||||
if (config.mailer) {
|
||||
const logger = new Logger('Mailer');
|
||||
const auth = config.mailer.auth;
|
||||
if (auth && auth.user && !('pass' in auth)) {
|
||||
logger.warn(
|
||||
'Mailer service has not configured password, please make sure your mailer service allow empty password.'
|
||||
);
|
||||
}
|
||||
|
||||
return createTransport(config.mailer);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
export const emailTemplate = ({
|
||||
title,
|
||||
content,
|
||||
buttonContent,
|
||||
buttonUrl,
|
||||
subContent,
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
buttonContent?: string;
|
||||
buttonUrl?: string;
|
||||
subContent?: string;
|
||||
}) => {
|
||||
return `<body style="background: #f6f7fb; overflow: hidden">
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="24px"
|
||||
style="
|
||||
background: #fff;
|
||||
max-width: 450px;
|
||||
margin: 32px auto 0 auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
"
|
||||
>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://affine.pro" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
|
||||
alt="AFFiNE log"
|
||||
height="32px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>${title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>${content}</td>
|
||||
</tr>
|
||||
${
|
||||
buttonContent && buttonUrl
|
||||
? `<tr>
|
||||
<td style="margin-left: 24px; padding-top: 0; padding-bottom: ${
|
||||
subContent ? '0' : '64px'
|
||||
}">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 8px" bgcolor="#1E96EB">
|
||||
<a
|
||||
href="${buttonUrl}"
|
||||
target="_blank"
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 18px;
|
||||
border: 1px solid rgba(0,0,0,.1);
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
"
|
||||
>${buttonContent}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
subContent
|
||||
? `<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #444;
|
||||
padding-top: 24px;
|
||||
"
|
||||
>
|
||||
${subContent}
|
||||
</td>
|
||||
</tr>`
|
||||
: ''
|
||||
}
|
||||
</table>
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
style="
|
||||
background: #fafafa;
|
||||
max-width: 450px;
|
||||
margin: 0 auto 32px auto;
|
||||
border-radius: 0 0 16px 16px;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<tr align="center">
|
||||
<td>
|
||||
<table cellpadding="0">
|
||||
<tr>
|
||||
<td style="padding: 0 10px">
|
||||
<a
|
||||
href="https://github.com/toeverything/AFFiNE"
|
||||
target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
|
||||
alt="AFFiNE github link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://twitter.com/AffineOfficial" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
|
||||
alt="AFFiNE twitter link"
|
||||
height="16px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://discord.gg/whd5mjYqVw" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
|
||||
alt="AFFiNE discord link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.youtube.com/@affinepro" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
|
||||
alt="AFFiNE youtube link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://t.me/affineworkos" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
|
||||
alt="AFFiNE telegram link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.reddit.com/r/Affine/" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
|
||||
alt="AFFiNE reddit link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
One hyper-fused platform for wildly creative minds
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: inter, Arial, Helvetica, sans-serif;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
Copyright<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
|
||||
alt="copyright"
|
||||
height="14px"
|
||||
style="vertical-align: middle; margin: 0 4px"
|
||||
/>2023-${new Date().getUTCFullYear()} Toeverything
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>`;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
metrics: ModuleConfig<{
|
||||
/**
|
||||
* Enable metric and tracing collection
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Enable telemetry
|
||||
*/
|
||||
telemetry: {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
};
|
||||
customerIo: {
|
||||
token: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('metrics', {
|
||||
enabled: false,
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
token: '',
|
||||
},
|
||||
customerIo: {
|
||||
token: '',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import './config';
|
||||
|
||||
import {
|
||||
Global,
|
||||
Module,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
Provider,
|
||||
} from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
|
||||
import { Config } from '../config';
|
||||
import {
|
||||
LocalOpentelemetryFactory,
|
||||
OpentelemetryFactory,
|
||||
registerCustomMetrics,
|
||||
} from './opentelemetry';
|
||||
|
||||
const factorProvider: Provider = {
|
||||
provide: OpentelemetryFactory,
|
||||
useFactory: (config: Config) => {
|
||||
return config.metrics.enabled ? new LocalOpentelemetryFactory() : null;
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [factorProvider],
|
||||
exports: [factorProvider],
|
||||
})
|
||||
export class MetricsModule implements OnModuleInit, OnModuleDestroy {
|
||||
private sdk: NodeSDK | null = null;
|
||||
constructor(private readonly ref: ModuleRef) {}
|
||||
|
||||
onModuleInit() {
|
||||
const factor = this.ref.get(OpentelemetryFactory, { strict: false });
|
||||
if (factor) {
|
||||
this.sdk = factor.create();
|
||||
this.sdk.start();
|
||||
registerCustomMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.sdk) {
|
||||
await this.sdk.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export * from './metrics';
|
||||
export * from './utils';
|
||||
export { OpentelemetryFactory };
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Attributes,
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MetricOptions,
|
||||
} from '@opentelemetry/api';
|
||||
|
||||
import { getMeter } from './opentelemetry';
|
||||
|
||||
type MetricType = 'counter' | 'gauge' | 'histogram';
|
||||
type Metric<T extends MetricType> = T extends 'counter'
|
||||
? Counter
|
||||
: T extends 'gauge'
|
||||
? Histogram
|
||||
: T extends 'histogram'
|
||||
? Histogram
|
||||
: never;
|
||||
|
||||
export type ScopedMetrics = {
|
||||
[T in MetricType]: (name: string, opts?: MetricOptions) => Metric<T>;
|
||||
};
|
||||
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';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
return meter.createCounter(name, opts);
|
||||
},
|
||||
gauge(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
let value: any;
|
||||
let attrs: Attributes | undefined;
|
||||
const ob$ = meter.createObservableGauge(name, opts);
|
||||
|
||||
ob$.addCallback(result => {
|
||||
result.observe(value, attrs);
|
||||
});
|
||||
|
||||
return {
|
||||
record: (newValue, newAttrs) => {
|
||||
value = newValue;
|
||||
attrs = newAttrs;
|
||||
},
|
||||
} satisfies Histogram;
|
||||
},
|
||||
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;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function stopMetrics() {}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import {
|
||||
CompositePropagator,
|
||||
W3CBaggagePropagator,
|
||||
W3CTraceContextPropagator,
|
||||
} from '@opentelemetry/core';
|
||||
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
|
||||
import { ZipkinExporter } from '@opentelemetry/exporter-zipkin';
|
||||
import { HostMetrics } from '@opentelemetry/host-metrics';
|
||||
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 { Resource } from '@opentelemetry/resources';
|
||||
import type { MeterProvider } from '@opentelemetry/sdk-metrics';
|
||||
import { MetricProducer, MetricReader } from '@opentelemetry/sdk-metrics';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import {
|
||||
BatchSpanProcessor,
|
||||
SpanExporter,
|
||||
TraceIdRatioBasedSampler,
|
||||
} from '@opentelemetry/sdk-trace-node';
|
||||
import {
|
||||
SEMRESATTRS_K8S_NAMESPACE_NAME,
|
||||
SEMRESATTRS_SERVICE_NAME,
|
||||
SEMRESATTRS_SERVICE_VERSION,
|
||||
} from '@opentelemetry/semantic-conventions';
|
||||
import prismaInstrument from '@prisma/instrumentation';
|
||||
|
||||
import { PrismaMetricProducer } from './prisma';
|
||||
|
||||
const { PrismaInstrumentation } = prismaInstrument;
|
||||
|
||||
export abstract class OpentelemetryFactory {
|
||||
abstract getMetricReader(): MetricReader;
|
||||
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 new Resource({
|
||||
[SEMRESATTRS_K8S_NAMESPACE_NAME]: AFFiNE.AFFINE_ENV,
|
||||
[SEMRESATTRS_SERVICE_NAME]: AFFiNE.flavor.type,
|
||||
[SEMRESATTRS_SERVICE_VERSION]: AFFiNE.version,
|
||||
});
|
||||
}
|
||||
|
||||
create() {
|
||||
const traceExporter = this.getSpanExporter();
|
||||
return new NodeSDK({
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalOpentelemetryFactory
|
||||
extends OpentelemetryFactory
|
||||
implements OnModuleDestroy
|
||||
{
|
||||
private readonly metricsExporter = new PrometheusExporter({
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.metricsExporter.shutdown();
|
||||
}
|
||||
|
||||
override getMetricReader(): MetricReader {
|
||||
return this.metricsExporter;
|
||||
}
|
||||
|
||||
override getSpanExporter(): SpanExporter {
|
||||
return new ZipkinExporter();
|
||||
}
|
||||
}
|
||||
|
||||
function getMeterProvider() {
|
||||
return metrics.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);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { HrTime, ValueType } from '@opentelemetry/api';
|
||||
import { hrTime } from '@opentelemetry/core';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import {
|
||||
AggregationTemporality,
|
||||
CollectionResult,
|
||||
DataPointType,
|
||||
InstrumentType,
|
||||
MetricProducer,
|
||||
ScopeMetrics,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
|
||||
import { PrismaService } from '../prisma';
|
||||
|
||||
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: Resource.EMPTY,
|
||||
scopeMetrics: [],
|
||||
},
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (!PrismaService.INSTANCE) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const prisma = PrismaService.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',
|
||||
type: InstrumentType.COUNTER,
|
||||
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',
|
||||
type: InstrumentType.UP_DOWN_COUNTER,
|
||||
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',
|
||||
type: InstrumentType.HISTOGRAM,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Attributes } from '@opentelemetry/api';
|
||||
|
||||
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 = (
|
||||
scope: KnownMetricScopes,
|
||||
name: string,
|
||||
record?: { timer?: boolean; count?: boolean; error?: boolean },
|
||||
attrs?: Attributes
|
||||
): MethodDecorator => {
|
||||
// @ts-expect-error allow
|
||||
return (
|
||||
_target,
|
||||
_key,
|
||||
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
||||
) => {
|
||||
const originalMethod = desc.value;
|
||||
if (!originalMethod) {
|
||||
return desc;
|
||||
}
|
||||
|
||||
const timer = metrics[scope].histogram('function_timer', {
|
||||
description: 'function call time costs',
|
||||
unit: 'ms',
|
||||
});
|
||||
const count = metrics[scope].counter('function_calls', {
|
||||
description: 'function call counter',
|
||||
});
|
||||
|
||||
desc.value = async function (...args: any[]) {
|
||||
const start = Date.now();
|
||||
let error = false;
|
||||
|
||||
const end = () => {
|
||||
timer?.record(Date.now() - start, { ...attrs, name, error });
|
||||
};
|
||||
|
||||
try {
|
||||
if (!record || !!record.count) {
|
||||
count.add(1, attrs);
|
||||
}
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (err) {
|
||||
if (!record || !!record.error) {
|
||||
error = true;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
count.add(1, { ...attrs, name, error });
|
||||
if (!record || !!record.timer) {
|
||||
end();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return desc;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { Locker } from './local-lock';
|
||||
import { Mutex, RequestMutex } from './mutex';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [Mutex, RequestMutex, Locker],
|
||||
exports: [Mutex, RequestMutex],
|
||||
})
|
||||
export class MutexModule {}
|
||||
|
||||
export { Locker, Mutex, RequestMutex };
|
||||
export { type Locker as ILocker, Lock } from './lock';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Cache } from '../cache';
|
||||
import { Lock, Locker as ILocker } from './lock';
|
||||
|
||||
@Injectable()
|
||||
export class Locker implements ILocker {
|
||||
constructor(private readonly cache: Cache) {}
|
||||
|
||||
async lock(owner: string, key: string): Promise<Lock> {
|
||||
const lockKey = `MutexLock:${key}`;
|
||||
const prevOwner = await this.cache.get<string>(lockKey);
|
||||
|
||||
if (prevOwner && prevOwner !== owner) {
|
||||
throw new Error(`Lock for resource [${key}] has been holder by others`);
|
||||
}
|
||||
|
||||
const acquired = await this.cache.set(lockKey, owner);
|
||||
|
||||
if (acquired) {
|
||||
return new Lock(async () => {
|
||||
await this.cache.delete(lockKey);
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Failed to acquire lock for resource [${key}]`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
export interface Locker {
|
||||
lock(owner: string, key: string): Promise<Lock>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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 { GraphqlContext } from '../graphql';
|
||||
import { retryable } from '../utils/promise';
|
||||
import { Locker } from './local-lock';
|
||||
|
||||
export const MUTEX_RETRY = 5;
|
||||
export const MUTEX_WAIT = 100;
|
||||
|
||||
@Injectable()
|
||||
export class Mutex {
|
||||
protected logger = new Logger(Mutex.name);
|
||||
|
||||
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.lock('resource-key');
|
||||
* if (lock) {
|
||||
* // do something
|
||||
* } else {
|
||||
* // failed to lock
|
||||
* }
|
||||
* }
|
||||
* // lock is released here
|
||||
* ```
|
||||
* @param key resource key
|
||||
* @returns LockGuard
|
||||
*/
|
||||
async lock(key: string, owner: string = 'global') {
|
||||
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 lock(key: string) {
|
||||
return super.lock(key, this.getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStartupConfig, ModuleConfig } from '../../base/config';
|
||||
|
||||
export interface ServerStartupConfigurations {
|
||||
/**
|
||||
* Base url of AFFiNE server, used for generating external urls.
|
||||
* default to be `[AFFiNE.protocol]://[AFFiNE.host][:AFFiNE.port]/[AFFiNE.path]` if not specified
|
||||
*/
|
||||
externalUrl: string;
|
||||
/**
|
||||
* Whether the server is hosted on a ssl enabled domain
|
||||
*/
|
||||
https: boolean;
|
||||
/**
|
||||
* where the server get deployed(FQDN).
|
||||
*/
|
||||
host: string;
|
||||
/**
|
||||
* which port the server will listen on
|
||||
*/
|
||||
port: number;
|
||||
/**
|
||||
* subpath where the server get deployed if there is.
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
declare module '../../base/config' {
|
||||
interface AppConfig {
|
||||
server: ModuleConfig<ServerStartupConfigurations>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('server', {
|
||||
externalUrl: '',
|
||||
https: false,
|
||||
host: 'localhost',
|
||||
port: 3010,
|
||||
path: '',
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
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 { of } from 'rxjs';
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
import {
|
||||
InternalServerError,
|
||||
NotFound,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
} from '../error';
|
||||
import { metrics } from '../metrics';
|
||||
|
||||
export function mapAnyError(error: any): UserFriendlyError {
|
||||
if (error instanceof UserFriendlyError) {
|
||||
return error;
|
||||
} else if (error instanceof ThrottlerException) {
|
||||
return new TooManyRequest();
|
||||
} else if (error instanceof NotFoundException) {
|
||||
return new NotFound();
|
||||
} 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');
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const error = mapAnyError(originalError);
|
||||
error.log('Sse');
|
||||
metrics.sse.counter('error').add(1, { status: error.status });
|
||||
return of({
|
||||
type: 'error' as const,
|
||||
data: error.toJSON(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import './config';
|
||||
export * from './exception';
|
||||
export * from './optional-module';
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
DynamicModule,
|
||||
Module,
|
||||
ModuleMetadata,
|
||||
Provider,
|
||||
Type,
|
||||
} from '@nestjs/common';
|
||||
import { omit } from 'lodash-es';
|
||||
|
||||
import type { AFFiNEConfig, ConfigPaths } from '../config';
|
||||
|
||||
export interface OptionalModuleMetadata extends ModuleMetadata {
|
||||
/**
|
||||
* Only install module if given config paths are defined in AFFiNE config.
|
||||
*/
|
||||
requires?: ConfigPaths[];
|
||||
|
||||
/**
|
||||
* Only install module if the predication returns true.
|
||||
*/
|
||||
if?: (config: AFFiNEConfig) => boolean;
|
||||
|
||||
/**
|
||||
* Defines which feature will be enabled if the module installed.
|
||||
*/
|
||||
contributesTo?: import('../../core/config').ServerFeature; // avoid circlar dependency
|
||||
|
||||
/**
|
||||
* Defines which providers provided by other modules will be overridden if the module installed.
|
||||
*/
|
||||
overrides?: Provider[];
|
||||
}
|
||||
|
||||
const additionalOptions = [
|
||||
'contributesTo',
|
||||
'requires',
|
||||
'if',
|
||||
'overrides',
|
||||
] as const satisfies Array<keyof OptionalModuleMetadata>;
|
||||
|
||||
type OptionalDynamicModule = DynamicModule & OptionalModuleMetadata;
|
||||
|
||||
export function OptionalModule(metadata: OptionalModuleMetadata) {
|
||||
return (target: Type) => {
|
||||
additionalOptions.forEach(option => {
|
||||
if (Object.hasOwn(metadata, option)) {
|
||||
Reflect.defineMetadata(option, metadata[option], target);
|
||||
}
|
||||
});
|
||||
|
||||
if (metadata.overrides) {
|
||||
metadata.providers = (metadata.providers ?? []).concat(
|
||||
metadata.overrides
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
metadata.exports = (metadata.exports ?? []).concat(metadata.overrides);
|
||||
}
|
||||
|
||||
const nestMetadata = omit(metadata, additionalOptions);
|
||||
Module(nestMetadata)(target);
|
||||
};
|
||||
}
|
||||
|
||||
export function getOptionalModuleMetadata<
|
||||
T extends keyof OptionalModuleMetadata,
|
||||
>(target: Type | OptionalDynamicModule, key: T): OptionalModuleMetadata[T] {
|
||||
if ('module' in target) {
|
||||
return target[key];
|
||||
} else {
|
||||
return Reflect.getMetadata(key, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
interface PrismaStartupConfiguration extends Prisma.PrismaClientOptions {
|
||||
datasourceUrl: string;
|
||||
}
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
prisma: ModuleConfig<PrismaStartupConfiguration>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('prisma', {
|
||||
datasourceUrl: '',
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import './config';
|
||||
|
||||
import { Global, Module, Provider } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { PrismaService } from './service';
|
||||
|
||||
// only `PrismaClient` can be injected
|
||||
const clientProvider: Provider = {
|
||||
provide: PrismaClient,
|
||||
useFactory: (config: Config) => {
|
||||
if (PrismaService.INSTANCE) {
|
||||
return PrismaService.INSTANCE;
|
||||
}
|
||||
|
||||
return new PrismaService(config.prisma);
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [clientProvider],
|
||||
exports: [clientProvider],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
export { PrismaService } from './service';
|
||||
|
||||
export type PrismaTransaction = Parameters<
|
||||
Parameters<PrismaClient['$transaction']>[0]
|
||||
>[0];
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
static INSTANCE: PrismaService | null = null;
|
||||
|
||||
constructor(opts: Prisma.PrismaClientOptions) {
|
||||
super(opts);
|
||||
PrismaService.INSTANCE = this;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (!AFFiNE.node.test) {
|
||||
await this.$disconnect();
|
||||
PrismaService.INSTANCE = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import test from 'ava';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { ListObjectsMetadata } from '../providers';
|
||||
import { FsStorageProvider } from '../providers/fs';
|
||||
|
||||
const config = {
|
||||
path: join(process.cwd(), 'node_modules', '.cache/affine-test-storage'),
|
||||
};
|
||||
|
||||
function createProvider() {
|
||||
return new FsStorageProvider(
|
||||
config,
|
||||
'test' + Math.random().toString(16).substring(2, 8)
|
||||
);
|
||||
}
|
||||
|
||||
function keys(list: ListObjectsMetadata[]) {
|
||||
return list.map(i => i.key);
|
||||
}
|
||||
|
||||
async function randomPut(
|
||||
provider: FsStorageProvider,
|
||||
prefix = ''
|
||||
): Promise<string> {
|
||||
const key = prefix + 'test-key-' + Math.random().toString(16).substring(2, 8);
|
||||
const body = Buffer.from(key);
|
||||
provider.put(key, body);
|
||||
return key;
|
||||
}
|
||||
|
||||
test.after.always(() => {
|
||||
fs.rm(config.path, { recursive: true });
|
||||
});
|
||||
|
||||
test('put & get', async t => {
|
||||
const provider = createProvider();
|
||||
const key = 'testKey';
|
||||
const body = Buffer.from('testBody');
|
||||
await provider.put(key, body);
|
||||
|
||||
const result = await provider.get(key);
|
||||
|
||||
t.deepEqual(await getStreamAsBuffer(result.body!), body);
|
||||
t.is(result.metadata?.contentLength, body.length);
|
||||
});
|
||||
|
||||
test('list - one level', async t => {
|
||||
const provider = createProvider();
|
||||
const list = await Promise.all(
|
||||
Array.from({ length: 100 }).map(() => randomPut(provider))
|
||||
);
|
||||
list.sort();
|
||||
// random order, use set
|
||||
const result = await provider.list();
|
||||
t.deepEqual(keys(result), list);
|
||||
|
||||
const result2 = await provider.list('test-key');
|
||||
t.deepEqual(keys(result2), list);
|
||||
|
||||
const result3 = await provider.list('testKey');
|
||||
t.is(result3.length, 0);
|
||||
});
|
||||
|
||||
test('list recursively', async t => {
|
||||
const provider = createProvider();
|
||||
|
||||
await Promise.all([
|
||||
Promise.all(Array.from({ length: 10 }).map(() => randomPut(provider))),
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }).map(() => randomPut(provider, 'a/'))
|
||||
),
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }).map(() => randomPut(provider, 'a/b/'))
|
||||
),
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }).map(() => randomPut(provider, 'a/b/t/'))
|
||||
),
|
||||
]);
|
||||
|
||||
const r1 = await provider.list();
|
||||
t.is(r1.length, 40);
|
||||
|
||||
// contains all `a/xxx` and `a/b/xxx` and `a/b/c/xxx`
|
||||
const r2 = await provider.list('a');
|
||||
t.is(r2.length, 30);
|
||||
|
||||
// contains only `a/b/xxx`
|
||||
const r3 = await provider.list('a/b');
|
||||
const r4 = await provider.list('a/b/');
|
||||
t.is(r3.length, 20);
|
||||
t.deepEqual(r3, r4);
|
||||
|
||||
// prefix is not ended with '/', it's open to all files and sub dirs
|
||||
// contains all `a/b/t/xxx` and `a/b/t{xxxx}`
|
||||
const r5 = await provider.list('a/b/t');
|
||||
|
||||
t.is(r5.length, 20);
|
||||
});
|
||||
|
||||
test('delete', async t => {
|
||||
const provider = createProvider();
|
||||
const key = 'testKey';
|
||||
const body = Buffer.from('testBody');
|
||||
await provider.put(key, body);
|
||||
|
||||
await provider.delete(key);
|
||||
|
||||
await t.throwsAsync(() => fs.access(join(config.path, provider.bucket, key)));
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
export interface FsStorageConfig {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface StorageProvidersConfig {
|
||||
fs?: FsStorageConfig;
|
||||
}
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
storageProviders: ModuleConfig<StorageProvidersConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('storageProviders', {
|
||||
fs: {
|
||||
path: join(homedir(), '.affine/storage'),
|
||||
},
|
||||
});
|
||||
|
||||
export type StorageProviderType = keyof StorageProvidersConfig;
|
||||
|
||||
export type StorageConfig<Ext = unknown> = {
|
||||
provider: StorageProviderType;
|
||||
bucket: string;
|
||||
} & Ext;
|
||||
|
||||
export interface StoragesConfig {
|
||||
avatar: StorageConfig<{ publicLinkFactory: (key: string) => string }>;
|
||||
blob: StorageConfig;
|
||||
copilot: StorageConfig;
|
||||
}
|
||||
|
||||
export interface AFFiNEStorageConfig {
|
||||
/**
|
||||
* All providers for object storage
|
||||
*
|
||||
* Support different providers for different usage at the same time.
|
||||
*/
|
||||
providers: StorageProvidersConfig;
|
||||
storages: StoragesConfig;
|
||||
}
|
||||
|
||||
export type StorageProviders = AFFiNEStorageConfig['providers'];
|
||||
export type Storages = keyof AFFiNEStorageConfig['storages'];
|
||||
|
||||
export function getDefaultAFFiNEStorageConfig(): AFFiNEStorageConfig {
|
||||
return {
|
||||
providers: {
|
||||
fs: {
|
||||
path: join(homedir(), '.affine/storage'),
|
||||
},
|
||||
},
|
||||
storages: {
|
||||
avatar: {
|
||||
provider: 'fs',
|
||||
bucket: 'avatars',
|
||||
publicLinkFactory: key => `/api/avatars/${key}`,
|
||||
},
|
||||
blob: {
|
||||
provider: 'fs',
|
||||
bucket: 'blobs',
|
||||
},
|
||||
copilot: {
|
||||
provider: 'fs',
|
||||
bucket: 'copilot',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import './config';
|
||||
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { registerStorageProvider, StorageProviderFactory } from './providers';
|
||||
import { FsStorageProvider } from './providers/fs';
|
||||
|
||||
registerStorageProvider('fs', (config, bucket) => {
|
||||
if (!config.storageProviders.fs) {
|
||||
throw new Error('Missing fs storage provider configuration');
|
||||
}
|
||||
|
||||
return new FsStorageProvider(config.storageProviders.fs, bucket);
|
||||
});
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageProviderFactory],
|
||||
exports: [StorageProviderFactory],
|
||||
})
|
||||
export class StorageProviderModule {}
|
||||
|
||||
export * from '../../native';
|
||||
export type { StorageProviderType } from './config';
|
||||
export type {
|
||||
BlobInputType,
|
||||
BlobOutputType,
|
||||
GetObjectMetadata,
|
||||
ListObjectsMetadata,
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from './providers';
|
||||
export { registerStorageProvider, StorageProviderFactory } from './providers';
|
||||
export { autoMetadata, toBuffer } from './providers/utils';
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
createReadStream,
|
||||
Dirent,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join, parse, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { FsStorageConfig } from '../config';
|
||||
import {
|
||||
BlobInputType,
|
||||
GetObjectMetadata,
|
||||
ListObjectsMetadata,
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from './provider';
|
||||
import { autoMetadata, toBuffer } from './utils';
|
||||
|
||||
function escapeKey(key: string): string {
|
||||
// avoid '../' and './' in key
|
||||
return key.replace(/\.?\.[/\\]/g, '%');
|
||||
}
|
||||
|
||||
export class FsStorageProvider implements StorageProvider {
|
||||
private readonly path: string;
|
||||
private readonly logger: Logger;
|
||||
|
||||
readonly type = 'fs';
|
||||
|
||||
constructor(
|
||||
config: FsStorageConfig,
|
||||
public readonly bucket: string
|
||||
) {
|
||||
this.path = resolve(config.path, bucket);
|
||||
this.ensureAvailability();
|
||||
|
||||
this.logger = new Logger(`${FsStorageProvider.name}:${bucket}`);
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: BlobInputType,
|
||||
metadata: PutObjectMetadata = {}
|
||||
): Promise<void> {
|
||||
key = escapeKey(key);
|
||||
const blob = await toBuffer(body);
|
||||
|
||||
// write object
|
||||
this.writeObject(key, blob);
|
||||
// write metadata
|
||||
await this.writeMetadata(key, blob, metadata);
|
||||
this.logger.verbose(`Object \`${key}\` put`);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<{
|
||||
body?: Readable;
|
||||
metadata?: GetObjectMetadata;
|
||||
}> {
|
||||
key = escapeKey(key);
|
||||
|
||||
try {
|
||||
const metadata = this.readMetadata(key);
|
||||
const stream = this.readObject(this.join(key));
|
||||
this.logger.verbose(`Read object \`${key}\``);
|
||||
return {
|
||||
body: stream,
|
||||
metadata,
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to read object \`${key}\``, e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async list(prefix?: string): Promise<ListObjectsMetadata[]> {
|
||||
// prefix cases:
|
||||
// - `undefined`: list all objects
|
||||
// - `a/b`: list objects under dir `a` with prefix `b`, `b` might be a dir under `a` as well.
|
||||
// - `a/b/` list objects under dir `a/b`
|
||||
|
||||
// read dir recursively and filter out '.metadata.json' files
|
||||
let dir = this.path;
|
||||
if (prefix) {
|
||||
prefix = escapeKey(prefix);
|
||||
const parts = prefix.split(/[/\\]/);
|
||||
// for prefix `a/b/c`, move `a/b` to dir and `c` to key prefix
|
||||
if (parts.length > 1) {
|
||||
dir = join(dir, ...parts.slice(0, -1));
|
||||
prefix = parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
const results: ListObjectsMetadata[] = [];
|
||||
async function getFiles(dir: string, prefix?: string): Promise<void> {
|
||||
try {
|
||||
const entries: Dirent[] = readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const res = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!prefix || entry.name.startsWith(prefix)) {
|
||||
await getFiles(res);
|
||||
}
|
||||
} else if (
|
||||
(!prefix || entry.name.startsWith(prefix)) &&
|
||||
!entry.name.endsWith('.metadata.json')
|
||||
) {
|
||||
const stat = statSync(res);
|
||||
results.push({
|
||||
key: res,
|
||||
lastModified: stat.mtime,
|
||||
contentLength: stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// failed to read dir, stop recursion
|
||||
}
|
||||
}
|
||||
|
||||
await getFiles(dir, prefix);
|
||||
|
||||
// trim path with `this.path` prefix
|
||||
results.forEach(r => (r.key = r.key.slice(this.path.length + 1)));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
key = escapeKey(key);
|
||||
|
||||
try {
|
||||
rmSync(this.join(key), { force: true });
|
||||
rmSync(this.join(`${key}.metadata.json`), { force: true });
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to delete object \`${key}\``, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.verbose(`Object \`${key}\` deleted`);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
ensureAvailability() {
|
||||
// check stats
|
||||
const stats = statSync(this.path, {
|
||||
throwIfNoEntry: false,
|
||||
});
|
||||
|
||||
// not existing, create it
|
||||
if (!stats) {
|
||||
try {
|
||||
mkdirSync(this.path, { recursive: true });
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to create target directory for fs storage provider: ${this.path}`,
|
||||
{
|
||||
cause: e,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (stats.isDirectory()) {
|
||||
// the target directory has already existed, check if it is readable & writable
|
||||
try {
|
||||
accessSync(this.path, constants.W_OK | constants.R_OK);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`The target directory for fs storage provider has already existed, but it is not readable & writable: ${this.path}`,
|
||||
{
|
||||
cause: e,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (stats.isFile()) {
|
||||
throw new Error(
|
||||
`The target directory for fs storage provider is a file: ${this.path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private join(...paths: string[]) {
|
||||
return join(this.path, ...paths);
|
||||
}
|
||||
|
||||
private readObject(file: string): Readable | undefined {
|
||||
const state = statSync(file, { throwIfNoEntry: false });
|
||||
|
||||
if (state?.isFile()) {
|
||||
return createReadStream(file);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private writeObject(key: string, blob: Buffer) {
|
||||
const path = this.join(key);
|
||||
mkdirSync(parse(path).dir, { recursive: true });
|
||||
writeFileSync(path, blob);
|
||||
}
|
||||
|
||||
private async writeMetadata(
|
||||
key: string,
|
||||
blob: Buffer,
|
||||
raw: PutObjectMetadata
|
||||
) {
|
||||
try {
|
||||
const metadata = autoMetadata(blob, raw);
|
||||
|
||||
if (raw.checksumCRC32 && metadata.checksumCRC32 !== raw.checksumCRC32) {
|
||||
throw new Error(
|
||||
'The checksum of the uploaded file is not matched with the one you provide, the file may be corrupted and the uploading will not be processed.'
|
||||
);
|
||||
}
|
||||
|
||||
if (raw.contentLength && metadata.contentLength !== raw.contentLength) {
|
||||
throw new Error(
|
||||
'The content length of the uploaded file is not matched with the one you provide, the file may be corrupted and the uploading will not be processed.'
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
this.join(`${key}.metadata.json`),
|
||||
JSON.stringify({
|
||||
...metadata,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to write metadata of object \`${key}\``, e);
|
||||
}
|
||||
}
|
||||
|
||||
private readMetadata(key: string): GetObjectMetadata | undefined {
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
readFileSync(this.join(`${key}.metadata.json`), {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...raw,
|
||||
lastModified: new Date(raw.lastModified),
|
||||
expires: raw.expires ? new Date(raw.expires) : undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to read metadata of object \`${key}\``, e);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { StorageConfig, StorageProviderType } from '../config';
|
||||
import type { StorageProvider } from './provider';
|
||||
|
||||
const availableProviders = new Map<
|
||||
StorageProviderType,
|
||||
(config: Config, bucket: string) => StorageProvider
|
||||
>();
|
||||
|
||||
export function registerStorageProvider(
|
||||
type: StorageProviderType,
|
||||
providerFactory: (config: Config, bucket: string) => StorageProvider
|
||||
) {
|
||||
availableProviders.set(type, providerFactory);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageProviderFactory {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
create(storage: StorageConfig): StorageProvider {
|
||||
const providerFactory = availableProviders.get(storage.provider);
|
||||
|
||||
if (!providerFactory) {
|
||||
throw new Error(`Unknown storage provider type: ${storage.provider}`);
|
||||
}
|
||||
|
||||
return providerFactory(this.config, storage.bucket);
|
||||
}
|
||||
}
|
||||
|
||||
export type * from './provider';
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import { StorageProviderType } from '../config';
|
||||
|
||||
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 StorageProvider {
|
||||
readonly type: StorageProviderType;
|
||||
put(
|
||||
key: string,
|
||||
body: BlobInputType,
|
||||
metadata?: PutObjectMetadata
|
||||
): Promise<void>;
|
||||
get(
|
||||
key: string
|
||||
): Promise<{ body?: BlobOutputType; metadata?: GetObjectMetadata }>;
|
||||
list(prefix?: string): Promise<ListObjectsMetadata[]>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { crc32 } from '@node-rs/crc32';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { getMime } from '../../../native';
|
||||
import { BlobInputType, PutObjectMetadata } from './provider';
|
||||
|
||||
export async function toBuffer(input: BlobInputType): Promise<Buffer> {
|
||||
return input instanceof Readable
|
||||
? await getStreamAsBuffer(input)
|
||||
: input instanceof Buffer
|
||||
? input
|
||||
: Buffer.from(input);
|
||||
}
|
||||
|
||||
export function autoMetadata(
|
||||
blob: Buffer,
|
||||
raw: PutObjectMetadata = {}
|
||||
): PutObjectMetadata {
|
||||
const metadata = {
|
||||
...raw,
|
||||
};
|
||||
|
||||
if (!metadata.contentLength) {
|
||||
metadata.contentLength = blob.byteLength;
|
||||
}
|
||||
|
||||
try {
|
||||
// checksum
|
||||
if (!metadata.checksumCRC32) {
|
||||
metadata.checksumCRC32 = crc32(blob).toString(16);
|
||||
}
|
||||
|
||||
// mime type
|
||||
if (!metadata.contentType) {
|
||||
metadata.contentType = getMime(blob);
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
export type ThrottlerType = 'default' | 'strict';
|
||||
|
||||
type ThrottlerStartupConfigurations = {
|
||||
[key in ThrottlerType]: {
|
||||
ttl: number;
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
throttler: ModuleConfig<ThrottlerStartupConfigurations>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('throttler', {
|
||||
default: {
|
||||
ttl: 60,
|
||||
limit: 120,
|
||||
},
|
||||
strict: {
|
||||
ttl: 60,
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
||||
import { SkipThrottle, 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: 10 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 })
|
||||
*
|
||||
*/
|
||||
export function Throttle(
|
||||
type: ThrottlerType | 'authenticated' = 'default',
|
||||
override: { limit?: number; ttl?: number } = {}
|
||||
): MethodDecorator & ClassDecorator {
|
||||
return applyDecorators(
|
||||
SetMetadata(THROTTLER_PROTECTED, type),
|
||||
RawThrottle({
|
||||
[type]: override,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export { SkipThrottle };
|
||||
@@ -0,0 +1,188 @@
|
||||
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 { getRequestResponseFromContext } from '../utils/request';
|
||||
import type { ThrottlerType } from './config';
|
||||
import { THROTTLER_PROTECTED, Throttlers } from './decorators';
|
||||
|
||||
@Injectable()
|
||||
export class ThrottlerStorage extends ThrottlerStorageService {}
|
||||
|
||||
@Injectable()
|
||||
class CustomOptionsFactory implements ThrottlerOptionsFactory {
|
||||
constructor(private readonly storage: ThrottlerStorage) {}
|
||||
|
||||
createThrottlerOptions() {
|
||||
const options: ThrottlerModuleOptions = {
|
||||
throttlers: Object.entries(AFFiNE.throttler).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> {
|
||||
return Promise.resolve(
|
||||
// ↓ prefer session id if available
|
||||
`throttler:${req.session?.sessionId ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
|
||||
// ^ throttler prefix make the key in store recognizable
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (this.config.node.dev) {
|
||||
limit = Number.MAX_SAFE_INTEGER;
|
||||
} else {
|
||||
// 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> {
|
||||
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,50 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 type { Socket } from 'socket.io';
|
||||
|
||||
export function getRequestResponseFromHost(host: ArgumentsHost) {
|
||||
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) {
|
||||
return getRequestResponseFromHost(host).req;
|
||||
}
|
||||
|
||||
export function getRequestResponseFromContext(ctx: ExecutionContext) {
|
||||
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) {
|
||||
cookies[decodeURIComponent(key.trim())] = val
|
||||
? decodeURIComponent(val.trim())
|
||||
: val;
|
||||
}
|
||||
|
||||
return cookies;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 {
|
||||
constructor() {}
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
Path 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
|
||||
: Join<K, LeafPaths<T[K], Path, MaxDepth, `${Depth}.`>>
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
export interface FileUpload {
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
encoding: string;
|
||||
createReadStream: () => Readable;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { GatewayMetadata } from '@nestjs/websockets';
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
websocket: ModuleConfig<
|
||||
GatewayMetadata & {
|
||||
canActivate?: (socket: Socket) => Promise<boolean>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('websocket', {
|
||||
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
|
||||
transports: ['websocket'],
|
||||
maxHttpBufferSize: 1e8, // 100 MB
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import './config';
|
||||
|
||||
import {
|
||||
FactoryProvider,
|
||||
INestApplicationContext,
|
||||
Module,
|
||||
Provider,
|
||||
} from '@nestjs/common';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { AuthenticationRequired } from '../error';
|
||||
|
||||
export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl');
|
||||
|
||||
export class SocketIoAdapter extends IoAdapter {
|
||||
constructor(protected readonly app: INestApplicationContext) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
override createIOServer(port: number, options?: any): Server {
|
||||
const config = this.app.get(WEBSOCKET_OPTIONS) as Config['websocket'];
|
||||
const server: Server = super.createIOServer(port, {
|
||||
...config,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (config.canActivate) {
|
||||
server.use((socket, next) => {
|
||||
// @ts-expect-error checked
|
||||
config
|
||||
.canActivate(socket)
|
||||
.then(pass => {
|
||||
if (pass) {
|
||||
next();
|
||||
} else {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
next(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
|
||||
const SocketIoAdapterImplProvider: Provider = {
|
||||
provide: SocketIoAdapterImpl,
|
||||
useValue: SocketIoAdapter,
|
||||
};
|
||||
|
||||
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
|
||||
|
||||
export const websocketOptionsProvider: FactoryProvider = {
|
||||
provide: WEBSOCKET_OPTIONS,
|
||||
useFactory: (config: Config) => {
|
||||
return config.websocket;
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
|
||||
@Module({
|
||||
providers: [SocketIoAdapterImplProvider, websocketOptionsProvider],
|
||||
exports: [SocketIoAdapterImplProvider, websocketOptionsProvider],
|
||||
})
|
||||
export class WebSocketModule {}
|
||||
Reference in New Issue
Block a user