snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { Cache, SessionCache } from './instances';
import { CacheInterceptor } from './interceptor';
@Global()
@Module({
providers: [Cache, SessionCache, CacheInterceptor],
exports: [Cache, SessionCache, CacheInterceptor],
})
export class CacheModule {}
export { Cache, SessionCache };
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
export { isValidCacheTtl } from './provider';

View File

@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { CacheRedis, SessionRedis } from '../redis';
import { CacheProvider } from './provider';
@Injectable()
export class Cache extends CacheProvider {
constructor(redis: CacheRedis) {
super(redis);
}
}
@Injectable()
export class SessionCache extends CacheProvider {
constructor(redis: SessionRedis) {
super(redis);
}
}

View File

@@ -0,0 +1,99 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import { mergeMap, Observable, of } from 'rxjs';
import { Cache } from './instances';
export const MakeCache = (key: string[], args?: string[]) =>
SetMetadata('cacheKey', [key, args]);
export const PreventCache = (key: string[], args?: string[]) =>
SetMetadata('preventCache', [key, args]);
type CacheConfig = [string[], string[]?];
@Injectable()
export class CacheInterceptor implements NestInterceptor {
private readonly logger = new Logger(CacheInterceptor.name);
constructor(
private readonly reflector: Reflector,
private readonly cache: Cache
) {}
async intercept(
ctx: ExecutionContext,
next: CallHandler<any>
): Promise<Observable<any>> {
const key = this.reflector.get<CacheConfig | undefined>(
'cacheKey',
ctx.getHandler()
);
const preventKey = this.reflector.get<CacheConfig | undefined>(
'preventCache',
ctx.getHandler()
);
if (preventKey) {
const key = await this.getCacheKey(ctx, preventKey);
if (key) {
this.logger.verbose(`cache ${key} staled`);
await this.cache.delete(key);
}
return next.handle();
} else if (!key) {
return next.handle();
}
const cacheKey = await this.getCacheKey(ctx, key);
if (!cacheKey) {
return next.handle();
}
const cachedData = await this.cache.get(cacheKey);
if (cachedData) {
this.logger.verbose(`cache ${cacheKey} hit`);
return of(cachedData);
} else {
this.logger.verbose(`cache ${cacheKey} miss`);
return next.handle().pipe(
mergeMap(async result => {
await this.cache.set(cacheKey, result);
return result;
})
);
}
}
private async getCacheKey(
ctx: ExecutionContext,
config: CacheConfig
): Promise<string | null> {
const [key, params] = config;
if (!params) {
return key.join(':');
} else if (ctx.getType<GqlContextType>() === 'graphql') {
const args = GqlExecutionContext.create(ctx).getArgs();
const cacheKey = params
.map(name => args[name])
.filter(v => v)
.join(':');
if (cacheKey) {
return [...key, cacheKey].join(':');
} else {
return key.join(':');
}
}
return null;
}
}

View File

@@ -0,0 +1,244 @@
import Redis from 'ioredis';
export interface CacheSetOptions {
/**
* in milliseconds
*/
ttl?: number;
}
const GET_AND_DELETE_LUA = `
local value = redis.call("GET", KEYS[1])
if value then
redis.call("DEL", KEYS[1])
end
return value
`;
const INCREASE_WITH_TTL_LUA = `
local value = redis.call("INCRBY", KEYS[1], ARGV[1])
if value == tonumber(ARGV[1]) or redis.call("PTTL", KEYS[1]) == -1 then
redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return value
`;
export function isValidCacheTtl(ttl: unknown): ttl is number {
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
}
export class CacheProvider {
constructor(private readonly redis: Redis) {}
// standard operation
async get<T = unknown>(key: string): Promise<T> {
return this.redis
.get(key)
.then(v => {
if (v) {
return JSON.parse(v);
}
return undefined;
})
.catch(() => undefined);
}
async set<T = unknown>(
key: string,
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
if (isValidCacheTtl(opts.ttl)) {
return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl)
.then(() => true)
.catch(() => false);
}
if (opts.ttl !== undefined) {
return false;
}
return this.redis
.set(key, JSON.stringify(value))
.then(() => true)
.catch(() => false);
}
async increase(key: string, count: number = 1): Promise<number> {
return this.redis.incrby(key, count).catch(() => 0);
}
async increaseWithTtl(
key: string,
ttl: number,
count: number = 1
): Promise<number> {
if (!isValidCacheTtl(ttl)) return 0;
return this.redis
.eval(INCREASE_WITH_TTL_LUA, 1, key, count, ttl)
.then(value => Number(value))
.catch(() => 0);
}
async decrease(key: string, count: number = 1): Promise<number> {
return this.redis.decrby(key, count).catch(() => 0);
}
async setnx<T = unknown>(
key: string,
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
if (isValidCacheTtl(opts.ttl)) {
return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX')
.then(v => !!v)
.catch(() => false);
}
if (opts.ttl !== undefined) {
return false;
}
return this.redis
.set(key, JSON.stringify(value), 'NX')
.then(v => !!v)
.catch(() => false);
}
async delete(key: string): Promise<boolean> {
return this.redis
.del(key)
.then(v => v > 0)
.catch(() => false);
}
async getAndDelete<T = unknown>(key: string): Promise<T | undefined> {
return this.redis
.eval(GET_AND_DELETE_LUA, 1, key)
.then(v => (typeof v === 'string' ? JSON.parse(v) : undefined))
.catch(() => undefined);
}
async has(key: string): Promise<boolean> {
return this.redis
.exists(key)
.then(v => v > 0)
.catch(() => false);
}
async ttl(key: string): Promise<number> {
return this.redis.ttl(key).catch(() => 0);
}
async expire(key: string, ttl: number): Promise<boolean> {
return this.redis
.pexpire(key, ttl)
.then(v => v > 0)
.catch(() => false);
}
// list operations
async pushBack<T = unknown>(key: string, ...values: T[]): Promise<number> {
return this.redis
.rpush(key, ...values.map(v => JSON.stringify(v)))
.catch(() => 0);
}
async pushFront<T = unknown>(key: string, ...values: T[]): Promise<number> {
return this.redis
.lpush(key, ...values.map(v => JSON.stringify(v)))
.catch(() => 0);
}
async len(key: string): Promise<number> {
return this.redis.llen(key).catch(() => 0);
}
async list<T = unknown>(
key: string,
start: number,
end: number
): Promise<T[]> {
return this.redis
.lrange(key, start, end)
.then(data => data.map(v => JSON.parse(v)))
.catch(() => []);
}
async popFront<T = unknown>(key: string, count: number = 1): Promise<T[]> {
return this.redis
.lpop(key, count)
.then(data => (data ?? []).map(v => JSON.parse(v)))
.catch(() => []);
}
async popBack<T = unknown>(key: string, count: number = 1): Promise<T[]> {
return this.redis
.rpop(key, count)
.then(data => (data ?? []).map(v => JSON.parse(v)))
.catch(() => []);
}
// map operations
async mapSet<T = unknown>(
map: string,
key: string,
value: T
): Promise<boolean> {
return this.redis
.hset(map, key, JSON.stringify(value))
.then(v => v > 0)
.catch(() => false);
}
async mapIncrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
return this.redis.hincrby(map, key, count);
}
async mapDecrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
return this.redis.hincrby(map, key, -count);
}
async mapGet<T = unknown>(map: string, key: string): Promise<T | undefined> {
return this.redis
.hget(map, key)
.then(v => (v ? JSON.parse(v) : undefined))
.catch(() => undefined);
}
async mapDelete(map: string, key: string): Promise<boolean> {
return this.redis
.hdel(map, key)
.then(v => v > 0)
.catch(() => false);
}
async mapKeys(map: string): Promise<string[]> {
return this.redis.hkeys(map).catch(() => []);
}
async mapRandomKey(map: string): Promise<string | undefined> {
return this.redis
.hrandfield(map, 1)
.then(v =>
typeof v === 'string'
? v
: Array.isArray(v)
? (v[0] as string)
: undefined
)
.catch(() => undefined);
}
async mapLen(map: string): Promise<number> {
return this.redis.hlen(map).catch(() => 0);
}
}

View File

@@ -0,0 +1,220 @@
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { InvalidAppConfig } from '../../error';
import { Config } from '../config';
import { ConfigFactory, ConfigModule } from '../index';
import { override } from '../register';
const module = await createModule();
test.after.always(async () => {
await module.close();
});
test('should create config', t => {
const config = module.get(Config);
t.is(typeof config.auth.passwordRequirements.max, 'number');
t.deepEqual(config.copilot.byok.allowedProviders, [
'openai',
'anthropic',
'gemini',
'fal',
]);
});
test('should override config', async t => {
await using module = await createModule({
imports: [
ConfigModule.override({
auth: {
passwordRequirements: {
max: 100,
min: 6,
},
},
}),
],
});
const config = module.get(Config);
const configFactory = module.get(ConfigFactory);
t.deepEqual(config.auth.passwordRequirements, {
max: 100,
min: 6,
});
configFactory.override({
auth: {
passwordRequirements: {
max: 10,
min: 1,
},
},
});
t.deepEqual(config.auth.passwordRequirements, {
max: 10,
min: 1,
});
});
test('should validate config', t => {
const config = module.get(ConfigFactory);
t.is(
config.validate([
{
module: 'auth',
key: 'passwordRequirements',
value: { max: 10, min: 6 },
},
]),
null
);
const [error] = config.validate([
{
module: 'auth',
key: 'passwordRequirements',
value: { max: 10, min: 10 },
},
])!;
t.true(error instanceof InvalidAppConfig);
t.is(
error.message,
'Invalid app config for module `auth` with key `passwordRequirements`. Minimum length of password must be less than maximum length.'
);
const [nativeError] = config.validate([
{
module: 'copilot',
key: 'byok.allowedProviders',
value: ['openai', 'openai'],
},
])!;
t.true(nativeError instanceof InvalidAppConfig);
t.regex(nativeError.message, /supported and unique/);
});
test('should override correctly', t => {
const config = {
auth: {
// object config
passwordRequirements: {
max: 10,
min: 6,
},
allowSignup: false,
// keyed config
// 'session.ttl', 'session.ttr'
session: {
ttl: 2000,
ttr: 1000,
},
},
storages: {
avatar: {
// keyed config
// "avatar.publicPath: String"
publicPath: '/',
// object config
// "avatar.storage => Object { }"
storage: {
provider: 'fs',
config: {
path: '/path/to/avatar',
},
},
},
},
} as AppConfig;
override(config, {
auth: {
passwordRequirements: {
max: 20,
},
allowSignup: true,
session: {
ttl: 3000,
},
},
storages: {
avatar: {
storage: {
provider: 'aws-s3',
config: {
credentials: {
accessKeyId: '1',
secretAccessKey: '1',
},
},
},
},
},
});
// simple value override
t.deepEqual(config.auth.allowSignup, true);
// right covered left
t.deepEqual(config.auth.passwordRequirements, {
max: 20,
});
// right merged to left
t.deepEqual(config.auth.session, {
ttl: 3000,
ttr: 1000,
});
// right covered left
t.deepEqual(config.storages.avatar.storage, {
provider: 'aws-s3',
config: {
credentials: {
accessKeyId: '1',
secretAccessKey: '1',
},
},
});
});
test('should clone from original config without modifications', t => {
const config = module.get(Config);
const configFactory = module.get(ConfigFactory);
config.auth.allowSignup = !config.auth.allowSignup;
const newConfig = configFactory.clone();
t.not(newConfig.auth.allowSignup, config.auth.allowSignup);
});
test('should override with undefined fields', async t => {
await using module = await createModule({
imports: [ConfigModule],
});
const config = module.get(Config);
const configFactory = module.get(ConfigFactory);
configFactory.override({
copilot: {
providers: {
// @ts-expect-error undefined field
unknown: {
apiKey: '123',
},
},
},
});
// @ts-expect-error undefined field
t.deepEqual(config.copilot.providers.unknown, {
apiKey: '123',
});
});

View File

@@ -0,0 +1,3 @@
import { ApplyType } from '../utils';
export class Config extends ApplyType<AppConfig>() {}

View File

@@ -0,0 +1,33 @@
export type EnvConfigType = 'string' | 'integer' | 'float' | 'boolean';
/**
* parse number value from environment variables
*/
function integer(value: string) {
const n = parseInt(value);
return Number.isNaN(n) ? undefined : n;
}
function float(value: string) {
const n = parseFloat(value);
return Number.isNaN(n) ? undefined : n;
}
function boolean(value: string) {
return value === '1' || value.toLowerCase() === 'true';
}
const envParsers: Record<EnvConfigType, (value: string) => unknown> = {
integer,
float,
boolean,
string: value => value,
};
export function parseEnvValue(value: string | undefined, type: EnvConfigType) {
if (value === undefined) {
return;
}
return envParsers[type](value);
}

View File

@@ -0,0 +1,73 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { InvalidAppConfig } from '../error';
import { APP_CONFIG_DESCRIPTORS, getDefaultConfig, override } from './register';
export const OVERRIDE_CONFIG_TOKEN = Symbol('OVERRIDE_CONFIG_TOKEN');
@Injectable()
export class ConfigFactory {
readonly #original: AppConfig;
readonly #config: AppConfig;
get config() {
return this.#config;
}
constructor(
@Inject(OVERRIDE_CONFIG_TOKEN)
@Optional()
private readonly overrides: DeepPartial<AppConfig> = {}
) {
this.#original = this.loadDefault();
this.#config = structuredClone(this.#original);
}
clone() {
// we did not freeze the #config object, it might be modified
return structuredClone(this.#original);
}
override(updates: DeepPartial<AppConfig>) {
override(this.#original, updates);
override(this.#config, updates);
}
validate(updates: Array<{ module: string; key: string; value: any }>) {
const errors: InvalidAppConfig[] = [];
updates.forEach(update => {
const descriptor = APP_CONFIG_DESCRIPTORS[update.module]?.[update.key];
if (!descriptor) {
errors.push(
new InvalidAppConfig({
module: update.module,
key: update.key,
hint: `Unknown config [${update.key}]`,
})
);
return;
}
const { success, error } = descriptor.validate(update.value);
if (!success) {
error.issues.forEach(issue => {
errors.push(
new InvalidAppConfig({
module: update.module,
key: update.key,
hint: issue.message,
})
);
});
}
});
return errors.length > 0 ? errors : null;
}
private loadDefault() {
const config = getDefaultConfig();
override(config, this.overrides);
return config;
}
}

View File

@@ -0,0 +1,34 @@
import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
import { Config } from './config';
import { ConfigFactory, OVERRIDE_CONFIG_TOKEN } from './factory';
import { ConfigProvider } from './provider';
@Global()
@Module({
providers: [ConfigProvider, ConfigFactory],
exports: [ConfigProvider, ConfigFactory],
})
export class ConfigModule {
static override(overrides: DeepPartial<AppConfigSchema> = {}): DynamicModule {
const provider: Provider = {
provide: OVERRIDE_CONFIG_TOKEN,
useValue: overrides,
};
return {
global: true,
module: class ConfigOverrideModule {},
providers: [provider],
exports: [provider],
};
}
}
export { Config, ConfigFactory };
export {
defineModuleConfig,
defineNativeModuleConfig,
type JSONSchema,
type NativeAppConfigDescriptor,
} from './register';

View File

@@ -0,0 +1,12 @@
import { FactoryProvider } from '@nestjs/common';
import { Config } from './config';
import { ConfigFactory } from './factory';
export const ConfigProvider: FactoryProvider = {
provide: Config,
useFactory: (factory: ConfigFactory) => {
return factory.config;
},
inject: [ConfigFactory],
};

View File

@@ -0,0 +1,394 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { mergeWith, once, set } from 'lodash-es';
import { z } from 'zod';
import { type EnvConfigType, parseEnvValue } from './env';
import { AppConfigByPath } from './types';
export type JSONSchema = {
$id?: string;
$ref?: string;
$schema?: string;
additionalProperties?: boolean | JSONSchema;
allOf?: JSONSchema[];
anyOf?: JSONSchema[];
definitions?: Record<string, JSONSchema>;
description?: string;
default?: unknown;
enum?: unknown[];
format?: string;
items?: JSONSchema;
minItems?: number;
minLength?: number;
oneOf?: JSONSchema[];
pattern?: string;
properties?: Record<string, JSONSchema>;
required?: string[];
title?: string;
type?:
| 'string'
| 'number'
| 'boolean'
| 'array'
| 'object'
| 'null'
| Array<'string' | 'number' | 'boolean' | 'array' | 'object' | 'null'>;
};
type ConfigType = EnvConfigType | 'array' | 'object' | 'any';
export type ConfigDescriptor<T> = {
desc: string;
type: ConfigType;
validate: (value: T) => z.SafeParseReturnType<T, T>;
schema: JSONSchema;
default: T;
env?: [string, EnvConfigType];
link?: string;
internal?: boolean;
};
type ConfigDefineDescriptor<T> = {
desc: string;
default: T;
validate?: (value: T) => z.SafeParseReturnType<T, T>;
shape?: z.ZodType<T>;
env?: string | [string, EnvConfigType];
link?: string;
schema?: JSONSchema;
internal?: boolean;
};
function typeFromShape(shape: z.ZodType<any>): ConfigType {
switch (shape.constructor) {
case z.ZodString:
return 'string';
case z.ZodNumber:
return 'float';
case z.ZodBoolean:
return 'boolean';
case z.ZodArray:
return 'array';
case z.ZodObject:
return 'object';
case z.ZodOptional:
case z.ZodNullable:
// @ts-expect-error checked
return typeFromShape(shape.unwrap());
default:
return 'any';
}
}
function shapeFromType(type: ConfigType): z.ZodType<any> {
switch (type) {
case 'string':
return z.string();
case 'float':
return z.number();
case 'boolean':
return z.boolean();
case 'integer':
return z.number().int();
case 'array':
return z.array(z.any());
case 'object':
return z.object({});
default:
return z.any();
}
}
function typeFromSchema(schema: JSONSchema): ConfigType {
switch (schema.type) {
case 'string':
return 'string';
case 'number':
return 'float';
case 'boolean':
return 'boolean';
case 'array':
return 'array';
case 'object':
return 'object';
}
return 'any';
}
function schemaFromType(type: ConfigType): JSONSchema['type'] {
switch (type) {
case 'any':
return undefined;
case 'float':
case 'integer':
return 'number';
default:
return type;
}
}
function typeFromDefault<T>(defaultValue: T): ConfigType {
if (Array.isArray(defaultValue)) {
return 'array';
}
switch (typeof defaultValue) {
case 'string':
return 'string';
case 'number':
return 'float';
case 'boolean':
return 'boolean';
case 'object':
return 'object';
default:
return 'any';
}
}
function standardizeDescriptor<T>(
desc: ConfigDefineDescriptor<T>
): ConfigDescriptor<T> {
const env = desc.env
? Array.isArray(desc.env)
? desc.env
: ([desc.env, 'string'] as [string, EnvConfigType])
: undefined;
let type: ConfigType = 'any';
if (desc.default !== undefined && desc.default !== null) {
type = typeFromDefault(desc.default);
} else if (env) {
type = env[1];
} else if (desc.shape) {
type = typeFromShape(desc.shape);
} else if (desc.schema) {
type = typeFromSchema(desc.schema);
}
const shape = desc.shape ?? shapeFromType(type);
return {
desc: desc.desc,
default: desc.default,
type,
validate: (value: T) => {
return desc.validate ? desc.validate(value) : shape.safeParse(value);
},
env,
link: desc.link,
internal: desc.internal,
schema: {
type: schemaFromType(type),
description: desc.desc,
...desc.schema,
},
};
}
type ModuleConfigDescriptors<T> = {
[K in keyof T]: ConfigDefineDescriptor<T[K]>;
};
export const APP_CONFIG_DESCRIPTORS: Record<
string,
Record<string, ConfigDescriptor<any>>
> = {};
export const getDescriptors = once(() => {
return Object.entries(APP_CONFIG_DESCRIPTORS).map(
([module, descriptors]) => ({
module,
descriptors: Object.entries(descriptors).map(([key, descriptor]) => ({
key,
descriptor,
})),
})
);
});
export function defineModuleConfig<T extends keyof AppConfigSchema>(
module: T,
defs: ModuleConfigDescriptors<AppConfigByPath<T>>
) {
registerModuleConfig(
module,
defs as Record<string, ConfigDefineDescriptor<unknown>>
);
}
function registerModuleConfig(
module: string,
defs: Record<string, ConfigDefineDescriptor<unknown>>
) {
const descriptors: Record<string, ConfigDescriptor<any>> = {};
Object.entries(defs).forEach(([key, desc]) => {
descriptors[key] = standardizeDescriptor(desc);
});
APP_CONFIG_DESCRIPTORS[module] = {
...APP_CONFIG_DESCRIPTORS[module],
...descriptors,
};
}
export type NativeAppConfigDescriptor = {
key: string;
description: string;
defaultValue: unknown;
schema: JSONSchema;
internal: boolean;
};
export function defineNativeModuleConfig<T extends keyof AppConfigSchema>(
module: T,
descriptors: NativeAppConfigDescriptor[],
validate: (module: string, key: string, value: unknown) => string[],
nodeDefinitions: Partial<ModuleConfigDescriptors<AppConfigByPath<T>>> = {}
) {
registerModuleConfig(module, {
...nodeDefinitions,
...Object.fromEntries(
descriptors.map(descriptor => [
descriptor.key,
{
desc: descriptor.description,
default: descriptor.defaultValue,
schema: descriptor.schema,
internal: descriptor.internal,
validate: (value: unknown) => {
const errors = validate(module, descriptor.key, value);
return errors.length
? {
success: false as const,
error: new z.ZodError(
errors.map(message => ({
code: z.ZodIssueCode.custom,
message,
path: [],
}))
),
}
: { success: true as const, data: value };
},
},
])
),
} as Record<string, ConfigDefineDescriptor<unknown>>);
}
export const CONFIG_JSON_PATHS = [
join(env.projectRoot, 'config.json'),
`${homedir()}/.affine/config/config.json`,
];
function readConfigJSONOverrides(path: string) {
const overrides: DeepPartial<AppConfig> = {};
if (existsSync(path)) {
try {
const config = JSON.parse(readFileSync(path, 'utf-8')) as AppConfig;
Object.entries(config).forEach(([key, value]) => {
if (key === '$schema') {
return;
}
Object.entries(value).forEach(([k, v]) => {
set(overrides, `${key}.${k}`, v);
});
});
} catch (e) {
console.error('Invalid json config file', e);
}
}
return overrides;
}
export function override(config: AppConfig, update: DeepPartial<AppConfig>) {
Object.keys(update).forEach(module => {
const moduleDescriptors = APP_CONFIG_DESCRIPTORS[module];
// ignore unknown config module
if (!moduleDescriptors) {
return;
}
const configKeys = new Set(Object.keys(moduleDescriptors));
const moduleConfig = config[module as keyof AppConfig];
const moduleOverrides = update[module as keyof AppConfig];
const merge = (left: any, right: any, path: string = '') => {
// if we found the key in the config keys
// we should use the override object instead of merge it with left
if (configKeys.has(path)) {
return right;
}
// EDGE CASE:
// the right value is primitive and we're still not finding the key in descriptors,
// which means the overrides has keys not defined
// that's where we should return
if (typeof right !== 'object') {
return left;
}
// go deeper
return mergeWith(left, right, (left, right, key) => {
return merge(left, right, path === '' ? key : `${path}.${key}`);
});
};
config[module as keyof AppConfig] = merge(moduleConfig, moduleOverrides);
});
}
export function getDefaultConfig(): AppConfig {
const config = {} as AppConfig;
const envs = process.env;
for (const [module, defs] of Object.entries(APP_CONFIG_DESCRIPTORS)) {
const modulizedConfig = {};
for (const [key, desc] of Object.entries(defs)) {
let defaultValue = desc.default;
if (desc.env) {
const [env, parser] = desc.env;
const envValue = envs[env];
if (envValue) {
defaultValue = parseEnvValue(envValue, parser);
}
}
const { success, error } = desc.validate(defaultValue);
if (!success) {
throw new Error(
error.issues
.map(issue => {
return `Invalid config for module [${module}] with key [${key}]
Value: ${JSON.stringify(defaultValue)}
Error: ${issue.message}`;
})
.join('\n')
);
}
set(modulizedConfig, key, defaultValue);
}
// @ts-expect-error all keys are known
config[module] = modulizedConfig;
}
CONFIG_JSON_PATHS.forEach(path => {
const overrides = readConfigJSONOverrides(path);
override(config, overrides);
});
return config as AppConfigSchema;
}

View File

@@ -0,0 +1,20 @@
import { LeafPaths, PathType } from '../utils';
declare global {
type ConfigItem<T> = Leaf<T>;
interface AppConfigSchema {}
type AppConfig = DeeplyEraseLeaf<AppConfigSchema>;
}
export type AppConfigByPath<Module extends keyof AppConfigSchema> =
AppConfigSchema[Module] extends infer Config
? {
[Path in LeafPaths<Config>]: Path extends string
? PathType<Config, Path> extends infer Item
? Item extends Leaf<infer V>
? V
: Item
: never
: never;
}
: never;

View File

@@ -0,0 +1,124 @@
import { URLHelper } from './helpers';
const DEV_LOOPBACK_PROTOCOLS = new Set(['http:', 'https:']);
const DEV_LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
const MOBILE_CLIENT_ORIGINS = new Set([
'https://localhost',
'capacitor://localhost',
'ionic://localhost',
]);
const DESKTOP_CLIENT_ORIGINS = new Set([
'assets://.',
'assets://another-host',
// for old versions of client, which use file:// as origin
'file://',
]);
export const CORS_ALLOWED_METHODS = [
'GET',
'HEAD',
'PUT',
'PATCH',
'POST',
'DELETE',
'OPTIONS',
];
export const CORS_ALLOWED_HEADERS = [
'accept',
'authorization',
'content-type',
'x-affine-version',
'x-affine-client-kind',
'x-operation-name',
'x-request-id',
'x-captcha-token',
'x-captcha-challenge',
'x-captcha-provider',
'x-affine-csrf-token',
'x-requested-with',
'range',
];
export const CORS_EXPOSED_HEADERS = [
'content-length',
'content-range',
'x-request-id',
];
function normalizeHostname(hostname: string) {
return hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
}
function isDevLoopbackOrigin(origin: string) {
try {
const parsed = new URL(origin);
return (
DEV_LOOPBACK_PROTOCOLS.has(parsed.protocol) &&
DEV_LOOPBACK_HOSTS.has(normalizeHostname(parsed.hostname))
);
} catch {
return false;
}
}
function normalizeCorsOrigin(origin: string) {
try {
const parsed = new URL(origin);
// Some websocket clients send ws:// or wss:// as Origin.
if (parsed.protocol === 'ws:' || parsed.protocol === 'wss:') {
parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:';
}
return parsed.origin;
} catch {
return null;
}
}
export function buildCorsAllowedOrigins(url: URLHelper) {
return new Set<string>([
...url.allowedOrigins,
...MOBILE_CLIENT_ORIGINS,
...DESKTOP_CLIENT_ORIGINS,
]);
}
export function isCorsOriginAllowed(
origin: string | undefined | null,
allowedOrigins: Set<string>
) {
if (!origin) {
return true;
}
if (allowedOrigins.has(origin)) {
return true;
}
const normalizedOrigin = normalizeCorsOrigin(origin);
if (normalizedOrigin && allowedOrigins.has(normalizedOrigin)) {
return true;
}
if ((env.dev || env.testing) && isDevLoopbackOrigin(origin)) {
return true;
}
return false;
}
export function corsOriginCallback(
origin: string | undefined,
allowedOrigins: Set<string>,
onBlocked: (origin: string) => void,
callback: (error: Error | null, allow?: boolean) => void
) {
if (isCorsOriginAllowed(origin, allowedOrigins)) {
callback(null, true);
return;
}
const blockedOrigin = origin ?? '<empty>';
onBlocked(blockedOrigin);
callback(null, false);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { generateUserFriendlyErrors } from './def';
import { ActionForbidden, ErrorDataUnionType, ErrorNames } from './errors.gen';
@Resolver(() => ErrorDataUnionType)
class ErrorResolver {
// only exists for type registering
@Query(() => ErrorDataUnionType)
error(@Args({ name: 'name', type: () => ErrorNames }) _name: ErrorNames) {
throw new ActionForbidden();
}
}
@Module({
providers: [ErrorResolver],
})
export class ErrorModule implements OnModuleInit {
logger = new Logger('ErrorModule');
onModuleInit() {
if (!env.dev) {
return;
}
this.logger.log('Generating UserFriendlyError classes');
const def = generateUserFriendlyErrors();
writeFileSync(
join(fileURLToPath(import.meta.url), '../errors.gen.ts'),
def
);
}
}
export { UserFriendlyError } from './def';
export * from './errors.gen';

View File

@@ -0,0 +1,48 @@
import { OnOptions } from 'eventemitter2';
import { PushMetadata, sliceMetadata } from '../nestjs/decorator';
declare global {
/**
* Event definitions can be extended by
*
* @example
*
* declare global {
* interface Events {
* 'user.subscription.created': {
* userId: User['id'];
* }
* }
* }
*/
interface Events {}
}
export type EventName = keyof Events;
export const EVENT_LISTENER_METADATA = Symbol('event_listener');
interface EventHandlerMetadata {
namespace: string;
event: EventName;
opts?: OnOptions;
}
export interface EventOptions extends OnOptions {
prepend?: boolean;
name?: string;
suppressError?: boolean;
}
export const OnEvent = (event: EventName, opts?: EventOptions) => {
const namespace = event.split('.')[0];
return PushMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, {
namespace,
event,
opts,
});
};
export function getEventHandlerMetadata(target: any): EventHandlerMetadata[] {
return sliceMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, target);
}

View File

@@ -0,0 +1,204 @@
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnModuleInit,
} from '@nestjs/common';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import EventEmitter2 from 'eventemitter2';
import { once } from 'lodash-es';
import { CLS_ID, ClsService, ClsServiceManager } from 'nestjs-cls';
import type { Server, Socket } from 'socket.io';
import { wrapCallMetric } from '../metrics';
import { genRequestId } from '../utils';
import { type EventName, type EventOptions } from './def';
import { EventHandlerScanner } from './scanner';
interface EventHandlerErrorPayload {
event: string;
payload: any;
error: Error;
}
/**
* We use socket.io system to auto pub/sub on server to server broadcast events
*/
@WebSocketGateway({
namespace: 's2s',
})
@Injectable()
export class EventBus
implements OnGatewayConnection, OnApplicationBootstrap, OnModuleInit
{
private readonly logger = new Logger(EventBus.name);
@WebSocketServer()
private readonly server?: Server;
constructor(
private readonly emitter: EventEmitter2,
private readonly cls: ClsService,
private readonly scanner: EventHandlerScanner
) {}
handleConnection(client: Socket) {
// for internal usage only, disallow any connection from client
this.logger.warn(
`EventBus get suspicious connection from client ${client.id}, disconnecting...`
);
client.disconnect();
}
async onModuleInit() {
this.bindEventHandlers();
this.emitter.on('error', ({ event, error }: EventHandlerErrorPayload) => {
this.logger.error(`Error happened when handling event ${event}`, error);
});
}
async onApplicationBootstrap() {
// Proxy all events received from server(trigger by `server.serverSideEmit`)
// to internal event system
this.server?.on('broadcast', (event, payload, requestId?: string) => {
this.cls.run(() => {
requestId = requestId ?? genRequestId('event');
this.cls.set(CLS_ID, requestId);
this.logger.debug(`Server Event: ${event} (Received)`);
this.emit(event, payload);
});
});
}
/**
* Emit event to trigger all listeners on current instance
*/
async emitAsync<T extends EventName>(event: T, payload: Events[T]) {
this.logger.debug(`Dispatch event: ${event} (async)`);
return await this.emitter.emitAsync(event, payload);
}
/**
* Emit event to trigger all listeners on current instance
*/
emit<T extends EventName>(event: T, payload: Events[T]) {
this.logger.debug(`Dispatch event: ${event}`);
this.dispatchAsync(event, payload);
return true;
}
/**
* Emit event in detached cls context to avoid inheriting current transaction.
*/
emitDetached<T extends EventName>(event: T, payload: Events[T]) {
this.logger.debug(`Dispatch event: ${event} (detached)`);
const requestId = this.cls.getId();
this.cls.run({ ifNested: 'override' }, () => {
this.cls.set(CLS_ID, requestId ?? genRequestId('event'));
this.dispatchAsync(event, payload);
});
return true;
}
/**
* Await listeners without inheriting the current transaction.
*/
async emitDetachedAsync<T extends EventName>(event: T, payload: Events[T]) {
const requestId = this.cls.getId();
return await this.cls.run({ ifNested: 'override' }, async () => {
this.cls.set(CLS_ID, requestId ?? genRequestId('event'));
return await this.emitAsync(event, payload);
});
}
/**
* Broadcast event to trigger all listeners on all instance in cluster
*/
broadcast<T extends EventName>(event: T, payload: Events[T]) {
this.logger.debug(`Server Event: ${event} (Send)`);
this.server?.serverSideEmit('broadcast', event, payload, this.cls.getId());
}
on<T extends EventName>(
event: T,
listener: (payload: Events[T]) => void | Promise<any>,
opts: EventOptions = {}
) {
const namespace = event.split('.')[0];
const { name, prepend, suppressError } = opts;
const handlerName = name ?? listener.name ?? 'anonymous fn';
let signature = `[${event}] (${handlerName})`;
const add = prepend ? this.emitter.prependListener : this.emitter.on;
const handler = wrapCallMetric(
async (payload: any) => {
this.logger.verbose(`Handle event ${signature}`);
const cls = ClsServiceManager.getClsService();
return await cls.run({ ifNested: 'reuse' }, async () => {
const requestId = cls.getId();
if (!requestId) {
cls.set(CLS_ID, genRequestId('event'));
}
try {
return await listener(payload);
} catch (e) {
if (suppressError) {
this.emitter.emit('error', {
event,
payload,
error: e,
} as EventHandlerErrorPayload);
} else {
throw e;
}
}
});
},
'event',
'event_handler',
{
event,
namespace,
handler: handlerName,
}
);
add.call(this.emitter, event, handler as any, opts);
this.logger.log(`Event handler registered ${signature}`);
return () => {
this.emitter.off(event, handler as any);
};
}
waitFor<T extends EventName>(name: T, timeout?: number) {
return this.emitter.waitFor(name, timeout);
}
private dispatchAsync<T extends EventName>(event: T, payload: Events[T]) {
// NOTE:
// Because all event handlers are wrapped in promisified metrics and cls context, they will always run in standalone tick.
// In which way, if handler throws, an unhandled rejection will be triggered and end up with process exiting.
// So we catch it here with `emitAsync`
this.emitter.emitAsync(event, payload).catch(e => {
this.emitter.emit('error', { event, payload, error: e });
});
}
private readonly bindEventHandlers = once(() => {
this.scanner.scan().forEach(({ event, handler, opts }) => {
this.on(event, handler, opts);
});
});
}

View File

@@ -0,0 +1,23 @@
import { Global, Module } from '@nestjs/common';
import EventEmitter2 from 'eventemitter2';
import { EventBus } from './eventbus';
import { EventHandlerScanner } from './scanner';
const EmitProvider = {
provide: EventEmitter2,
useFactory: () =>
new EventEmitter2({
maxListeners: 100,
}),
};
@Global()
@Module({
providers: [EventBus, EventHandlerScanner, EmitProvider],
exports: [EventBus],
})
export class EventModule {}
export { EventBus };
export { OnEvent } from './def';

View File

@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { once } from 'lodash-es';
import { ModuleScanner } from '../nestjs/scanner';
import {
type EventName,
type EventOptions,
getEventHandlerMetadata,
} from './def';
@Injectable()
export class EventHandlerScanner {
constructor(private readonly scanner: ModuleScanner) {}
scan = once(() => {
const handlers: Array<{
event: EventName;
handler: (payload: any) => any;
opts?: EventOptions;
}> = [];
const providers = this.scanner.getAtInjectables();
providers.forEach(wrapper => {
const { instance, name } = wrapper;
if (!instance || wrapper.isAlias) {
return;
}
const methods = this.scanner.getAllMethodNames(instance);
methods.forEach(method => {
const fn = instance[method];
let defs = getEventHandlerMetadata(instance[method]);
if (defs.length === 0) {
return;
}
const signature = `${name}.${method}`;
if (typeof fn !== 'function') {
throw new Error(`Event handler [${signature}] is not a function.`);
}
if (!wrapper.isDependencyTreeStatic()) {
throw new Error(
`Provider [${name}] could not be RequestScoped or TransientScoped injectable if it contains event handlers.`
);
}
defs.forEach(({ event, opts }) => {
handlers.push({
event,
handler: (payload: any) => {
// NOTE(@forehalo):
// we might create spies on the event handlers when testing,
// avoid reusing `fn` variable to fail the spies or stubs
return instance[method].bind(instance)(payload);
},
opts: {
name: signature,
...opts,
},
});
});
});
});
return handlers;
});
}

View File

@@ -0,0 +1,166 @@
# Snapshot report for `src/base/graphql/__tests__/pagination.spec.ts`
The actual snapshot is saved in `pagination.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should return encode pageInfo
> Snapshot 1
{
edges: [
{
cursor: 'MTE=',
node: {
id: 11,
},
},
{
cursor: 'MTI=',
node: {
id: 12,
},
},
{
cursor: 'MTM=',
node: {
id: 13,
},
},
{
cursor: 'MTQ=',
node: {
id: 14,
},
},
{
cursor: 'MTU=',
node: {
id: 15,
},
},
{
cursor: 'MTY=',
node: {
id: 16,
},
},
{
cursor: 'MTc=',
node: {
id: 17,
},
},
{
cursor: 'MTg=',
node: {
id: 18,
},
},
{
cursor: 'MTk=',
node: {
id: 19,
},
},
{
cursor: 'MjA=',
node: {
id: 20,
},
},
],
pageInfo: {
endCursor: 'MjA=',
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'MTE=',
},
totalCount: 105,
}
## should return encode pageInfo with custom cursor
> Snapshot 1
{
edges: [
{
cursor: '',
node: {
id: 11,
},
},
{
cursor: '',
node: {
id: 12,
},
},
{
cursor: '',
node: {
id: 13,
},
},
{
cursor: '',
node: {
id: 14,
},
},
{
cursor: '',
node: {
id: 15,
},
},
{
cursor: '',
node: {
id: 16,
},
},
{
cursor: '',
node: {
id: 17,
},
},
{
cursor: '',
node: {
id: 18,
},
},
{
cursor: '',
node: {
id: 19,
},
},
{
cursor: '',
node: {
id: 20,
},
},
],
pageInfo: {
endCursor: 'eyJpZCI6MjAsIm5hbWUiOiJ0ZXN0MiJ9',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0=',
},
totalCount: 105,
}
## should decode with json
> Snapshot 1
{
id: 10,
name: 'test',
}

View File

@@ -0,0 +1,155 @@
import { Args, Field, ObjectType, Query, Resolver } from '@nestjs/graphql';
import test from 'ava';
import Sinon from 'sinon';
import { createTestingApp } from '../../../__tests__/utils';
import { Public } from '../../../core/auth';
import {
decodeWithJson,
paginate,
Paginated,
paginateWithCustomCursor,
PaginationInput,
} from '../pagination';
const TOTAL_COUNT = 105;
const ITEMS = Array.from({ length: TOTAL_COUNT }, (_, i) => ({ id: i + 1 }));
const paginationStub = Sinon.stub().callsFake(input => {
const start = input.offset + (input.after ? parseInt(input.after) : 0);
return paginate(
ITEMS.slice(start, start + input.first),
'id',
input,
TOTAL_COUNT
);
});
const query = `query pagination($input: PaginationInput) {
pagination(paginationInput: $input) {
totalCount
edges {
cursor
node {
id
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}`;
@ObjectType()
class TestType {
@Field()
id!: number;
}
@ObjectType()
class PaginatedTestType extends Paginated(TestType) {}
@Public()
@Resolver(() => TestType)
class TestResolver {
@Query(() => PaginatedTestType)
async pagination(
@Args(
'paginationInput',
{
type: () => PaginationInput,
defaultValue: { first: 10, offset: 0 },
},
PaginationInput.decode
)
input: PaginationInput
) {
return paginationStub(input);
}
}
const app = await createTestingApp({
providers: [TestResolver],
});
test.after.always(async () => {
await app.close();
});
test('should decode pagination input', async t => {
await app.gql(query, {
input: {
first: 5,
offset: 0,
after: Buffer.from('4').toString('base64'),
},
});
t.true(
paginationStub.calledOnceWithExactly({
first: 5,
offset: 0,
after: '4',
})
);
});
test('should reject mixed pagination cursor and offset', async t => {
const res = await app.POST('/graphql').send({
query,
variables: {
input: {
first: 5,
offset: 1,
after: Buffer.from('4').toString('base64'),
},
},
});
t.is(res.status, 200);
t.truthy(res.body.errors?.length);
t.is(
res.body.errors[0].message,
'pagination.after and pagination.offset cannot be used together'
);
t.is(res.body.errors[0].extensions.status, 400);
t.is(res.body.errors[0].extensions.name, 'BAD_REQUEST');
});
test('should return encode pageInfo', async t => {
const result = paginate(
ITEMS.slice(10, 20),
'id',
{
first: 10,
offset: 0,
after: '9',
},
TOTAL_COUNT
);
t.snapshot(result);
});
test('should return encode pageInfo with custom cursor', async t => {
const result = paginateWithCustomCursor(
ITEMS.slice(10, 20),
TOTAL_COUNT,
{ id: 10, name: 'test' },
{ id: 20, name: 'test2' }
);
t.snapshot(result);
});
test('should decode with json', async t => {
const result = decodeWithJson<{ id: number; name: string }>(
'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0='
);
t.snapshot(result);
const result2 = decodeWithJson<{ id: number; name: string }>('');
t.is(result2, null);
});

View File

@@ -0,0 +1,22 @@
import { ApolloDriverConfig } from '@nestjs/apollo';
import { defineModuleConfig } from '../config';
declare global {
interface AppConfigSchema {
graphql: {
apolloDriverConfig: ConfigItem<ApolloDriverConfig>;
};
}
}
defineModuleConfig('graphql', {
apolloDriverConfig: {
desc: 'The config for underlying nestjs GraphQL and apollo driver engine.',
default: {
// @TODO(@forehalo): need a flag to tell user `Restart Required` configs
introspection: true,
},
link: 'https://docs.nestjs.com/graphql/quick-start',
},
});

View File

@@ -0,0 +1,77 @@
import './config';
import { join } from 'node:path';
import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo';
import { Global, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import type { Request, Response } from 'express';
import { NodeEnv } from '../../env';
import { Config } from '../config';
import { mapAnyError } from '../nestjs/exception';
import { GQLLoggerPlugin } from './logger-plugin';
export type GraphqlContext = {
req: Request;
res: Response;
isAdminQuery: boolean;
};
@Global()
@Module({
imports: [
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: (config: Config) => {
return {
...config.graphql.apolloDriverConfig,
buildSchemaOptions: {
numberScalarMode: 'integer',
},
useGlobalPrefix: true,
graphiql: env.NODE_ENV === NodeEnv.Development,
sortSchema: true,
autoSchemaFile: join(
env.projectRoot,
env.testing
? './node_modules/.cache/schema.gql'
: './src/schema.gql'
),
path: '/graphql',
csrfPrevention: {
requestHeaders: ['content-type'],
},
context: ({
req,
res,
}: {
req: Request;
res: Response;
}): GraphqlContext => ({
req,
res,
isAdminQuery: false,
}),
plugins: [new GQLLoggerPlugin()],
formatError: (formattedError, error) => {
let ufe = mapAnyError(error);
// @ts-expect-error allow assign
formattedError.extensions = ufe.toJSON();
if (env.namespaces.canary) {
formattedError.extensions.stacktrace = ufe.stacktrace;
}
return formattedError;
},
};
},
inject: [Config],
}),
],
})
export class GqlModule {}
export * from './pagination';
export { registerObjectType } from './register';

View File

@@ -0,0 +1,75 @@
import {
ApolloServerPlugin,
GraphQLRequestContext,
GraphQLRequestListener,
} from '@apollo/server';
import { Plugin } from '@nestjs/apollo';
import { Logger } from '@nestjs/common';
import { Response } from 'express';
import { metrics } from '../metrics/metrics';
import { mapAnyError } from '../nestjs';
export interface RequestContext {
req: Express.Request & {
res: Express.Response;
};
}
@Plugin()
export class GQLLoggerPlugin implements ApolloServerPlugin {
private readonly logger = new Logger(GQLLoggerPlugin.name);
requestDidStart(
ctx: GraphQLRequestContext<RequestContext>
): Promise<GraphQLRequestListener<GraphQLRequestContext<RequestContext>>> {
const res = ctx.contextValue.req.res as Response;
const headers = ctx.request.http?.headers;
const info = {
operation: ctx.request.operationName ?? headers?.get('x-operation-name'),
clientVersion: headers?.get('x-affine-version'),
};
if (!info.operation) {
this.logger.warn(
`GraphQL operation name is not provided (${JSON.stringify({
userAgent: headers?.get('user-agent'),
clientVersion: info.clientVersion,
rayId: headers?.get('cf-ray'),
country: headers?.get('cf-ipcountry'),
})})`
);
}
metrics.gql.counter('query_counter').add(1, info);
const start = Date.now();
function endTimer() {
return Date.now() - start;
}
return Promise.resolve({
willSendResponse: () => {
const time = endTimer();
res.setHeader('Server-Timing', `gql;dur=${time};desc="GraphQL"`);
metrics.gql.histogram('query_duration').record(time, info);
return Promise.resolve();
},
didEncounterErrors: ctx => {
ctx.errors.forEach(gqlErr => {
const error = mapAnyError(gqlErr);
error.log('GraphQL');
metrics.gql.counter('query_error_counter').add(1, {
...info,
code: error.status,
type: error.type,
error: error.name,
});
});
return Promise.resolve();
},
});
}
}

View File

@@ -0,0 +1,190 @@
import { PipeTransform, Type } from '@nestjs/common';
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { BadRequest } from '../error';
@InputType()
export class PaginationInput {
/**
* Because there is no resolver for GraphQL's InputTypes, we can't automatically decode the cursor input from base64 values.
* Use this helper as `PipeTransform` to transform input args
*
* @example
*
* paginate(@Args('input', PaginationInput.decode) PaginationInput) {}
*/
static decode: PipeTransform<PaginationInput, PaginationInput> = {
transform: value => {
const input = {
...value,
first: Math.min(Math.max(value?.first ?? 10, 1), 100),
offset: Math.max(value?.offset ?? 0, 0),
after: decode(value?.after),
// before: decode(value.before),
};
assertPaginationInput(input);
return input;
},
};
@Field(() => Int, {
nullable: true,
description: 'returns the first n elements from the list.',
defaultValue: 10,
})
first!: number;
@Field(() => Int, {
nullable: true,
description: 'ignore the first n elements from the list.',
defaultValue: 0,
})
offset!: number;
@Field(() => String, {
nullable: true,
description:
'returns the elements in the list that come after the specified cursor.',
})
after?: string | null;
// NOT IMPLEMENTED YET
// @Field(() => String, {
// nullable: true,
// description:
// 'returns the elements in the list that come before the specified cursor.',
// })
// before?: string | null;
}
export function assertPaginationInput(paginationInput?: PaginationInput) {
if (!paginationInput) {
return;
}
if (paginationInput.after && paginationInput.offset > 0) {
throw new BadRequest(
'pagination.after and pagination.offset cannot be used together'
);
}
}
const encode = (input: unknown) => {
let inputStr: string;
if (input instanceof Date) {
inputStr = input.toISOString();
} else if (typeof input === 'string') {
inputStr = input;
} else {
inputStr = String(input);
}
return Buffer.from(inputStr).toString('base64');
};
const decode = (base64String?: string | null) =>
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
export function encodeWithJson(input: unknown) {
return encode(JSON.stringify(input ?? null));
}
export function decodeWithJson<T>(base64String?: string | null): T | null {
const str = decode(base64String);
return str ? (JSON.parse(str) as T) : null;
}
export function paginate<T>(
list: T[],
cursorField: keyof T,
paginationInput: PaginationInput,
total: number
): PaginatedType<T> {
const edges = list.map(item => ({
node: item,
cursor: encode(item[cursorField]),
}));
return {
totalCount: total,
edges,
pageInfo: {
hasNextPage: edges.length >= paginationInput.first,
hasPreviousPage: !!paginationInput.after || paginationInput.offset > 0,
endCursor: edges.length ? edges[edges.length - 1].cursor : null,
startCursor: edges.length ? edges[0].cursor : null,
},
};
}
export function paginateWithCustomCursor<T>(
list: T[],
total: number,
startCursor: unknown,
endCursor: unknown,
hasPreviousPage = false
): PaginatedType<T> {
const edges = list.map(item => ({
node: item,
// set cursor to empty string for ignore it
cursor: '',
}));
return {
totalCount: total,
edges,
pageInfo: {
hasNextPage: list.length > 0,
hasPreviousPage,
endCursor: encodeWithJson(endCursor),
startCursor: encodeWithJson(startCursor),
},
};
}
export interface PaginatedType<T> {
totalCount: number;
edges: {
cursor: string;
node: T;
}[];
pageInfo: PageInfo;
}
@ObjectType()
export class PageInfo {
@Field(() => String, { nullable: true })
startCursor?: string | null;
@Field(() => String, { nullable: true })
endCursor?: string | null;
@Field()
hasNextPage!: boolean;
@Field()
hasPreviousPage!: boolean;
}
export function Paginated<T>(classRef: Type<T>) {
@ObjectType(`${classRef.name}Edge`)
abstract class EdgeType {
@Field(() => String)
cursor!: string;
@Field(() => classRef)
node!: T;
}
@ObjectType({ isAbstract: true })
abstract class PaginatedType {
@Field(() => Int)
totalCount!: number;
@Field(() => [EdgeType])
edges!: EdgeType[];
@Field(() => PageInfo)
pageInfo!: PageInfo;
}
return PaginatedType;
}

View File

@@ -0,0 +1,25 @@
import { Field, FieldOptions, ObjectType } from '@nestjs/graphql';
import { ApplyType } from '../utils/types';
export function registerObjectType<T>(
fields: Record<
string,
{
type: () => any;
options?: FieldOptions;
}
>,
options: {
name: string;
}
) {
const Inner = ApplyType<T>();
for (const [key, { type, options }] of Object.entries(fields)) {
Field(type, options)(Inner.prototype, key);
}
ObjectType(options.name)(Inner);
return Inner;
}

View File

@@ -0,0 +1,55 @@
import {
applyDecorators,
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GUARD_PROVIDER, NamedGuards } from './provider';
const BasicGuardSymbol = Symbol('BasicGuard');
@Injectable()
export class BasicGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
async canActivate(context: ExecutionContext) {
// get registered guard name
const providerName = this.reflector.get<string[]>(
BasicGuardSymbol,
context.getHandler()
);
if (Array.isArray(providerName) && providerName.length > 0) {
for (const name of providerName) {
const provider = GUARD_PROVIDER[name as NamedGuards];
if (provider) {
const ret = await provider.canActivate(context);
if (!ret) return false;
}
}
}
return true;
}
}
/**
* This guard is used to protect routes/queries/mutations that use a registered guard
*
* @example
*
* ```typescript
* \@UseNamedGuard('captcha') // use captcha guard
* \@Auth()
* \@Query(() => UserType)
* user(@CurrentUser() user: CurrentUser) {
* return user;
* }
* ```
*/
export const UseNamedGuard = (...name: NamedGuards[]) =>
applyDecorators(UseGuards(BasicGuard), SetMetadata(BasicGuardSymbol, name));

View File

@@ -0,0 +1,2 @@
export { UseNamedGuard } from './guard';
export { GuardProvider, type RegisterGuardName } from './provider';

View File

@@ -0,0 +1,26 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
OnModuleInit,
} from '@nestjs/common';
export interface RegisterGuardName {}
export type NamedGuards = keyof RegisterGuardName;
export const GUARD_PROVIDER: Partial<Record<NamedGuards, GuardProvider>> = {};
@Injectable()
export abstract class GuardProvider implements OnModuleInit, CanActivate {
private readonly logger = new Logger(GuardProvider.name);
abstract name: NamedGuards;
onModuleInit() {
GUARD_PROVIDER[this.name] = this;
this.logger.log(`Guard provider [${this.name}] registered`);
}
abstract canActivate(context: ExecutionContext): boolean | Promise<boolean>;
}

View File

@@ -0,0 +1,135 @@
import { generateKeyPairSync } from 'node:crypto';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { CryptoHelper } from '../crypto';
const test = ava as TestFn<{
crypto: CryptoHelper;
}>;
function generateTestPrivateKey(): string {
const { privateKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
});
return privateKey
.export({
type: 'pkcs8',
format: 'pem',
})
.toString();
}
const privateKey = generateTestPrivateKey();
const privateKey2 = generateTestPrivateKey();
test.beforeEach(async t => {
t.context.crypto = new CryptoHelper({
crypto: {
privateKey,
},
} as any);
t.context.crypto.onConfigInit();
});
test('should be able to sign and verify', t => {
const data = 'hello world';
const signature = t.context.crypto.sign(data);
t.true(t.context.crypto.verify(signature));
t.false(t.context.crypto.verify('fake-signature'));
t.false(t.context.crypto.verify(`${data},fake-signature`));
});
test('should verify signatures across key rotation', t => {
const data = 'hello world';
const signatureV1 = t.context.crypto.sign(data);
t.true(t.context.crypto.verify(signatureV1));
(t.context.crypto as any).config.crypto.privateKey = privateKey2;
t.context.crypto.onConfigChanged({
updates: { crypto: { privateKey: privateKey2 } },
} as any);
const signatureV2 = t.context.crypto.sign(data);
t.true(t.context.crypto.verify(signatureV1));
t.true(t.context.crypto.verify(signatureV2));
});
test('should same data should get different signature', t => {
const data = 'hello world';
const signature = t.context.crypto.sign(data);
const signature2 = t.context.crypto.sign(data);
t.not(signature2, signature);
t.true(t.context.crypto.verify(signature));
t.true(t.context.crypto.verify(signature2));
});
test('should be able to encrypt and decrypt', t => {
const data = 'top secret';
const stub = Sinon.stub(t.context.crypto, 'randomBytes').returns(
Buffer.alloc(12, 0)
);
const encrypted = t.context.crypto.encrypt(data);
const encrypted2 = t.context.crypto.encrypt(data);
const decrypted = t.context.crypto.decrypt(encrypted);
// we are using a stub to make sure the iv is always 0,
// the encrypted result will always be the same for the same key+data
t.is(encrypted2, encrypted);
t.is(decrypted, data);
stub.restore();
});
test('should be able to get random bytes', t => {
const bytes = t.context.crypto.randomBytes();
t.is(bytes.length, 12);
const bytes2 = t.context.crypto.randomBytes();
t.notDeepEqual(bytes, bytes2);
});
test('should be able to digest', t => {
const data = 'hello world';
const hash = t.context.crypto.sha256(data).toString('base64');
t.is(hash, 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=');
});
test('uses the native verifier public key', t => {
const deployment = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates deployment mode before the lifecycle hook
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
const loadKey = Sinon.stub(
t.context.crypto as unknown as {
loadAFFiNEProPublicKey(): Buffer | null;
},
'loadAFFiNEProPublicKey'
);
loadKey.onFirstCall().returns(null);
loadKey.onSecondCall().returns(Buffer.from('public-key'));
try {
t.throws(() => t.context.crypto.onModuleInit(), {
message:
'AFFINE_PRO_PUBLIC_KEY must be embedded in self-hosted server-native builds.',
});
t.context.crypto.onModuleInit();
t.is(t.context.crypto.AFFiNEProPublicKey?.toString(), 'public-key');
} finally {
// @ts-expect-error test restores deployment mode after the lifecycle hook
globalThis.env.DEPLOYMENT_TYPE = deployment;
}
});
test('should be able to safe compare', t => {
t.true(t.context.crypto.compare('abc', 'abc'));
t.false(t.context.crypto.compare('abc', 'def'));
});
test('should be able to hash password for native verification', async t => {
const password = 'mySecurePassword';
const hash = await t.context.crypto.encryptPassword(password);
t.true(hash.startsWith('$argon2id$'));
t.not(hash, password);
});

View File

@@ -0,0 +1,222 @@
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { buildCorsAllowedOrigins, isCorsOriginAllowed } from '../../cors';
import { ActionForbidden } from '../../error';
import { URLHelper } from '../url';
const test = ava as TestFn<{
url: URLHelper;
}>;
test.beforeEach(async t => {
t.context.url = new URLHelper({
server: {
externalUrl: '',
host: 'app.affine.local',
hosts: [],
port: 3010,
https: true,
path: '',
},
} as any);
});
test('can factor base url correctly without specified external url', t => {
t.is(t.context.url.baseUrl, 'https://app.affine.local');
});
test('can factor base url correctly with specified external url', t => {
const url = new URLHelper({
server: {
externalUrl: 'https://external.domain.com',
host: 'app.affine.local',
hosts: [],
port: 3010,
https: true,
path: '/ignored',
},
} as any);
t.is(url.baseUrl, 'https://external.domain.com');
});
test('can factor base url correctly with specified external url and path', t => {
const url = new URLHelper({
server: {
externalUrl: 'https://external.domain.com/anything',
host: 'app.affine.local',
hosts: [],
port: 3010,
https: true,
path: '/ignored',
},
} as any);
t.is(url.baseUrl, 'https://external.domain.com/anything');
});
test('can factor base url correctly with specified external url with port', t => {
const url = new URLHelper({
server: {
externalUrl: 'https://external.domain.com:123',
host: 'app.affine.local',
hosts: [],
port: 3010,
https: true,
},
} as any);
t.is(url.baseUrl, 'https://external.domain.com:123');
});
test('can stringify query', t => {
t.is(t.context.url.stringify({ a: 1, b: 2 }), 'a=1&b=2');
t.is(t.context.url.stringify({ a: 1, b: '/path' }), 'a=1&b=%2Fpath');
});
test('can create link', t => {
t.is(t.context.url.link('/path'), 'https://app.affine.local/path');
t.is(
t.context.url.link('/path', { a: 1, b: 2 }),
'https://app.affine.local/path?a=1&b=2'
);
t.is(
t.context.url.link('/path', { a: 1, b: '/path' }),
'https://app.affine.local/path?a=1&b=%2Fpath'
);
});
test('can create safe link', t => {
t.is(
t.context.url.safeLink('/path?existing=1&token=old', {
redirect_uri: '/next?a=1',
token: 'a b',
}),
'https://app.affine.local/path?existing=1&redirect_uri=%2Fnext%3Fa%3D1&token=a+b'
);
t.is(t.context.url.safeLink('/%5Cevil'), 'https://app.affine.local/%5Cevil');
for (const input of [
'/\\\\evil.example/path',
'\\\\evil.example/path',
'https://user@app.affine.local/path',
'javascript:alert(1)',
'https://evil.example/path',
]) {
t.throws(() => t.context.url.safeLink(input), {
instanceOf: ActionForbidden,
});
}
t.is(
t.context.url.canonicalRedirectUri('https://github.com/path?existing=1', {
error: 'a b',
}),
'https://github.com/path?existing=1&error=a+b'
);
});
test('can canonicalize redirect_uri', t => {
for (const [input, expected] of [
['/redirect-proxy', 'https://app.affine.local/redirect-proxy'],
['https://github.com', 'https://github.com/'],
['https://sub.github.com/path', 'https://sub.github.com/path'],
['https://github.com.:8443/path', 'https://github.com.:8443/path'],
]) {
t.is(t.context.url.canonicalRedirectUri(input), expected);
}
for (const input of [
'/\\\\evil.example/path',
'https://app.affine.local:444/path',
'https://evilgithub.com',
'https://github.com.evil.example',
'https://user@github.com',
'javascript:alert(1)',
]) {
t.throws(() => t.context.url.canonicalRedirectUri(input), {
instanceOf: ActionForbidden,
});
}
});
test('can safe redirect', t => {
const res = {
redirect: (to: string) => to,
} as any;
const spy = Sinon.spy(res, 'redirect');
function allow(to: string, canonical: string) {
t.context.url.safeRedirect(res, to);
t.true(spy.calledOnceWith(canonical));
spy.resetHistory();
}
function deny(to: string) {
t.context.url.safeRedirect(res, to);
t.true(spy.calledOnceWith(t.context.url.baseUrl));
spy.resetHistory();
}
allow('https://app.affine.local', 'https://app.affine.local/');
allow('/path?query=1', 'https://app.affine.local/path?query=1');
allow('/%5Cevil', 'https://app.affine.local/%5Cevil');
[
'https://other.domain.com',
'a://invalid.uri',
'/\\\\other.domain.com',
].forEach(deny);
t.context.url.redirectAllowHosts = ['https://app.affine.local/base'];
allow('/base/child', 'https://app.affine.local/base/child');
['/base-sibling', '/other'].forEach(deny);
});
test('can get request origin', t => {
t.is(t.context.url.requestOrigin, 'https://app.affine.local');
});
test('can get request base url', t => {
t.is(t.context.url.requestBaseUrl, 'https://app.affine.local');
});
test('can get request base url with multiple hosts', t => {
// mock cls
const cls = new Map<string, string>();
const url = new URLHelper(
{
server: {
externalUrl: '',
host: 'app.affine.local1',
hosts: ['app.affine.local1', 'app.affine.local2'],
port: 3010,
https: true,
path: '',
},
} as any,
cls as any
);
// no cls, use default origin
t.is(url.requestOrigin, 'https://app.affine.local1');
t.is(url.requestBaseUrl, 'https://app.affine.local1');
// set cls
cls.set(CLS_REQUEST_HOST, 'app.affine.local2');
t.is(url.requestOrigin, 'https://app.affine.local2');
t.is(url.requestBaseUrl, 'https://app.affine.local2');
});
test('should allow websocket secure origin by normalizing wss to https', t => {
const allowedOrigins = buildCorsAllowedOrigins({
allowedOrigins: ['https://app.affine.pro'],
} as any);
t.true(isCorsOriginAllowed('wss://app.affine.pro', allowedOrigins));
});
test('should allow desktop file origin', t => {
const allowedOrigins = buildCorsAllowedOrigins({
allowedOrigins: [],
} as any);
t.true(isCorsOriginAllowed('file://', allowedOrigins));
});

View File

@@ -0,0 +1,18 @@
import { defineModuleConfig } from '../config';
declare global {
interface AppConfigSchema {
crypto: {
privateKey: string;
};
}
}
defineModuleConfig('crypto', {
privateKey: {
desc: 'The private key for used by the crypto module to create signed tokens or encrypt data.',
env: 'AFFINE_PRIVATE_KEY',
default: '',
schema: { type: 'string' },
},
});

View File

@@ -0,0 +1,245 @@
import {
createCipheriv,
createDecipheriv,
createHash,
createPrivateKey,
createPublicKey,
createSign,
createVerify,
generateKeyPairSync,
type KeyObject,
randomBytes,
randomInt,
sign,
timingSafeEqual,
verify,
} from 'node:crypto';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { hash as hashPassword } from '@node-rs/argon2';
import { AFFINE_PRO_PUBLIC_KEY } from '../../native';
import { Config } from '../config';
import { OnEvent } from '../event';
const NONCE_LENGTH = 12;
const AUTH_TAG_LENGTH = 12;
function generatePrivateKey(): string {
const { privateKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
});
// Export EC private key as PKCS#8 PEM. This avoids OpenSSL 3.x decoder issues
// in Node.js 22 when later deriving the public key via createPublicKey.
const key = privateKey.export({
type: 'pkcs8',
format: 'pem',
});
return key.toString('utf8');
}
function parseKey(privateKey: string) {
const keyBuf = Buffer.from(privateKey);
let priv: KeyObject;
try {
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'pkcs8' });
} catch {
try {
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'sec1' });
} catch {
// As a last resort rely on auto-detection
priv = createPrivateKey(keyBuf);
}
}
const pub = createPublicKey(priv);
return { priv, pub };
}
@Injectable()
export class CryptoHelper implements OnModuleInit {
keyPair!: {
publicKey: KeyObject;
privateKey: KeyObject;
sha256: {
publicKey: Buffer;
privateKey: Buffer;
};
};
private previousPublicKeys: KeyObject[] = [];
AFFiNEProPublicKey: Buffer | null = null;
onModuleInit() {
if (env.selfhosted) {
this.AFFiNEProPublicKey = this.loadAFFiNEProPublicKey();
if (!this.AFFiNEProPublicKey) {
throw new Error(
'AFFINE_PRO_PUBLIC_KEY must be embedded in self-hosted server-native builds.'
);
}
}
}
constructor(private readonly config: Config) {}
@OnEvent('config.init')
onConfigInit() {
this.setup();
}
@OnEvent('config.changed')
onConfigChanged(event: Events['config.changed']) {
if (event.updates.crypto?.privateKey) {
this.setup();
}
}
private setup() {
const prevPublicKey = this.keyPair?.publicKey;
const privateKey = this.config.crypto.privateKey || generatePrivateKey();
const { priv, pub } = parseKey(privateKey);
const publicKey = pub
.export({ format: 'pem', type: 'spki' })
.toString('utf8');
if (prevPublicKey) {
const prevPem = prevPublicKey
.export({ format: 'pem', type: 'spki' })
.toString('utf8');
if (prevPem !== publicKey) {
this.previousPublicKeys.unshift(prevPublicKey);
this.previousPublicKeys = this.previousPublicKeys.slice(0, 2);
}
}
this.keyPair = {
publicKey: pub,
privateKey: priv,
sha256: {
publicKey: this.sha256(publicKey),
privateKey: this.sha256(privateKey),
},
};
}
private get keyType() {
return (this.keyPair.privateKey.asymmetricKeyType as string) || 'ec';
}
sign(data: string) {
const input = Buffer.from(data, 'utf-8');
if (this.keyType === 'ed25519') {
// Ed25519 signs the message directly (no pre-hash)
const sig = sign(null, input, this.keyPair.privateKey);
return `${data},${sig.toString('base64')}`;
} else {
// ECDSA with SHA-256 for EC keys
const sign = createSign('sha256');
sign.update(input);
sign.end();
return `${data},${sign.sign(this.keyPair.privateKey, 'base64')}`;
}
}
verify(signatureWithData: string) {
const [data, signature] = signatureWithData.split(',');
if (!signature) {
return false;
}
const input = Buffer.from(data, 'utf-8');
const sigBuf = Buffer.from(signature, 'base64');
const keys = [this.keyPair.publicKey, ...this.previousPublicKeys];
return keys.some(publicKey => {
const keyType = (publicKey.asymmetricKeyType as string) || 'ec';
if (keyType === 'ed25519') {
// Ed25519 verifies the message directly
return verify(null, input, publicKey, sigBuf);
} else {
// ECDSA with SHA-256
const verifier = createVerify('sha256');
verifier.update(input);
verifier.end();
return verifier.verify(publicKey, sigBuf);
}
});
}
encrypt(data: string) {
const iv = this.randomBytes();
const cipher = createCipheriv(
'aes-256-gcm',
this.keyPair.sha256.privateKey,
iv,
{
authTagLength: AUTH_TAG_LENGTH,
}
);
const encrypted = Buffer.concat([
cipher.update(data, 'utf-8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
decrypt(encrypted: string) {
const buf = Buffer.from(encrypted, 'base64');
const iv = buf.subarray(0, NONCE_LENGTH);
const authTag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + AUTH_TAG_LENGTH);
const encryptedToken = buf.subarray(NONCE_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv(
'aes-256-gcm',
this.keyPair.sha256.privateKey,
iv,
{ authTagLength: AUTH_TAG_LENGTH }
);
decipher.setAuthTag(authTag);
const decrepted = decipher.update(encryptedToken, void 0, 'utf8');
return decrepted + decipher.final('utf8');
}
encryptPassword(password: string) {
return hashPassword(password);
}
compare(lhs: string, rhs: string) {
if (lhs.length !== rhs.length) {
return false;
}
return timingSafeEqual(Buffer.from(lhs), Buffer.from(rhs));
}
randomBytes(length = NONCE_LENGTH) {
return randomBytes(length);
}
randomInt(min: number, max: number) {
return randomInt(min, max);
}
otp(length = 6) {
let otp = '';
for (let i = 0; i < length; i++) {
otp += this.randomInt(0, 10).toString();
}
return otp;
}
sha256(data: string) {
return createHash('sha256').update(data).digest();
}
private loadAFFiNEProPublicKey() {
if (AFFINE_PRO_PUBLIC_KEY) {
return Buffer.from(AFFINE_PRO_PUBLIC_KEY);
}
return null;
}
}

View File

@@ -0,0 +1,15 @@
import './config';
import { Global, Module } from '@nestjs/common';
import { CryptoHelper } from './crypto';
import { URLHelper } from './url';
@Global()
@Module({
providers: [URLHelper, CryptoHelper],
exports: [URLHelper, CryptoHelper],
})
export class HelpersModule {}
export { CryptoHelper, URLHelper };

View File

@@ -0,0 +1,189 @@
import { isIP } from 'node:net';
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import {
buildSafeCallbackUrl,
evaluateLocalRedirect,
evaluateRedirectUri,
} from '../../native';
import { Config } from '../config';
import { ActionForbidden } from '../error';
import { OnEvent } from '../event';
// Keep in sync with frontend /redirect-proxy allowlist.
const TRUSTED_REDIRECT_DOMAINS = [
'google.com',
'stripe.com',
'github.com',
'twitter.com',
'discord.gg',
'youtube.com',
't.me',
'reddit.com',
'affine.pro',
].map(d => d.toLowerCase());
@Injectable()
export class URLHelper {
redirectAllowHosts!: string[];
origin!: string;
allowedOrigins!: string[];
baseUrl!: string;
constructor(
private readonly config: Config,
private readonly cls?: ClsService
) {
this.init();
}
@OnEvent('config.changed')
@OnEvent('config.init')
init() {
if (this.config.server.externalUrl) {
if (!this.verify(this.config.server.externalUrl)) {
throw new Error(
'Invalid `server.externalUrl` configured. It must be a valid url.'
);
}
const externalUrl = new URL(this.config.server.externalUrl);
this.origin = externalUrl.origin;
this.baseUrl =
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
} else {
this.origin = this.convertHostToOrigin(this.config.server.host);
this.baseUrl = this.origin + this.config.server.path;
}
this.redirectAllowHosts = [this.baseUrl];
this.allowedOrigins = [this.origin];
if (this.config.server.hosts.length > 0) {
for (const host of this.config.server.hosts) {
this.allowedOrigins.push(this.convertHostToOrigin(host));
}
}
}
get requestOrigin() {
if (this.config.server.hosts.length === 0) {
return this.origin;
}
// support multiple hosts
const requestHost = this.cls?.get<string | undefined>(CLS_REQUEST_HOST);
if (!requestHost || !this.config.server.hosts.includes(requestHost)) {
return this.origin;
}
return this.convertHostToOrigin(requestHost);
}
get requestBaseUrl() {
if (this.config.server.hosts.length === 0) {
return this.baseUrl;
}
return this.requestOrigin + this.config.server.path;
}
stringify(query: Record<string, any>) {
return new URLSearchParams(query).toString();
}
url(path: string, query: Record<string, any> = {}) {
const url = new URL(path, this.requestOrigin);
for (const key in query) {
url.searchParams.set(key, query[key]);
}
return url;
}
link(path: string, query: Record<string, any> = {}) {
return this.url(path, query).toString();
}
safeLink(path: string, query: Record<string, any> = {}) {
try {
return buildSafeCallbackUrl(
path,
this.requestOrigin,
this.allowedOrigins,
Object.entries(query).map(([name, value]) => ({
name,
value: String(value),
}))
);
} catch {
throw new ActionForbidden();
}
}
safeRedirect(res: Response, to: string) {
try {
const canonical = evaluateLocalRedirect(
to,
this.requestBaseUrl,
this.redirectAllowHosts
);
return res.redirect(canonical);
} catch {
return res.redirect(this.baseUrl);
}
}
canonicalRedirectUri(redirectUri: string, query: Record<string, any> = {}) {
try {
return evaluateRedirectUri(
redirectUri,
this.requestOrigin,
this.allowedOrigins,
TRUSTED_REDIRECT_DOMAINS,
Object.entries(query).map(([name, value]) => ({
name,
value: String(value),
}))
);
} catch {
throw new ActionForbidden();
}
}
redirectPolicy() {
return {
redirectBaseUrl: this.requestOrigin,
redirectAllowedOrigins: this.allowedOrigins,
redirectTrustedDomains: TRUSTED_REDIRECT_DOMAINS,
};
}
verify(url: string | URL) {
try {
if (typeof url === 'string') {
url = new URL(url);
}
if (!['http:', 'https:'].includes(url.protocol)) return false;
if (!url.hostname) return false;
return true;
} catch {
return false;
}
}
private convertHostToOrigin(host: string) {
return [
this.config.server.https ? 'https' : 'http',
'://',
host,
host === 'localhost' || isIP(host) ? `:${this.config.server.port}` : '',
].join('');
}
}

View File

@@ -0,0 +1,36 @@
export {
Cache,
CacheInterceptor,
isValidCacheTtl,
MakeCache,
PreventCache,
SessionCache,
} from './cache';
export {
Config,
ConfigFactory,
defineModuleConfig,
defineNativeModuleConfig,
type JSONSchema,
type NativeAppConfigDescriptor,
} from './config';
export * from './cors';
export * from './error';
export { EventBus, OnEvent } from './event';
export {
paginate,
Paginated,
PaginationInput,
registerObjectType,
} from './graphql';
export * from './guard';
export { CryptoHelper, URLHelper } from './helpers';
export { AFFiNELogger } from './logger';
export { CallMetric, metrics } from './metrics';
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
export * from './nestjs';
export { type PrismaTransaction } from './prisma';
export * from './storage';
export { type StorageProviderConfig } from './storage';
export { CloudThrottlerGuard, SkipThrottle, Throttle } from './throttler';
export * from './utils';

View File

@@ -0,0 +1,75 @@
import { mock } from 'node:test';
import { TestingModule } from '@nestjs/testing';
import ava, { TestFn } from 'ava';
import { createTestingModule } from '../../../__tests__/utils';
import { AFFiNELogger } from '../service';
export const test = ava as TestFn<{
module: TestingModule;
logger: AFFiNELogger;
}>;
test.before(async t => {
const m = await createTestingModule({
providers: [AFFiNELogger],
});
const logger = m.get(AFFiNELogger);
t.context.module = m;
t.context.logger = logger;
});
test.afterEach(() => {
mock.reset();
});
test.after(async t => {
await t.context.module.close();
});
test('should auto print error stack when stack argument is an error instance', t => {
const logger = t.context.logger;
const error = new Error('test error');
// @ts-expect-error private method
const fake = mock.method(logger, 'printMessages', () => {
return;
});
logger.error('test message', error);
t.is(fake.mock.callCount(), 1);
const printStackTraceArgs: any = fake.mock.calls[0].arguments[0];
t.is(printStackTraceArgs[0], 'test message');
t.is(printStackTraceArgs[1], error.stack);
});
test('should auto print error stack when stack argument is a string', t => {
const logger = t.context.logger;
const error = new Error('test error');
// @ts-expect-error private method
const fake = mock.method(logger, 'printMessages', () => {
return;
});
logger.error('test message', error.stack);
t.is(fake.mock.callCount(), 1);
const printStackTraceArgs: any = fake.mock.calls[0].arguments[0];
t.is(printStackTraceArgs[0], 'test message');
t.is(printStackTraceArgs[1], error.stack);
});
test('should print error stack with cause', t => {
const logger = t.context.logger;
const error = new Error('test error');
error.cause = new Error('cause error');
// @ts-expect-error private method
const fake = mock.method(logger, 'printMessages', () => {
return;
});
logger.error('test message', error);
t.is(fake.mock.callCount(), 1);
const printStackTraceArgs: any = fake.mock.calls[0].arguments[0];
t.is(printStackTraceArgs[0], 'test message');
t.is(
printStackTraceArgs[1],
`${error.stack}\n\nCaused by:\n\n${(error.cause as any).stack}`
);
});

View File

@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '../config';
import { AFFiNELogger } from './service';
@Global()
@Module({
imports: [ConfigModule],
providers: [AFFiNELogger],
exports: [AFFiNELogger],
})
export class LoggerModule {}
export { AFFiNELogger } from './service';

View File

@@ -0,0 +1,58 @@
import { ConsoleLogger, Injectable, type LogLevel } from '@nestjs/common';
import { ClsServiceManager } from 'nestjs-cls';
import { UserFriendlyError } from '../error';
// DO NOT use this Logger directly
// Use it via this way: `private readonly logger = new Logger(MyService.name)`
@Injectable()
export class AFFiNELogger extends ConsoleLogger {
override stringifyMessage(message: unknown, logLevel: LogLevel) {
const messageString = super.stringifyMessage(message, logLevel);
const requestId = AFFiNELogger.getRequestId();
if (!requestId) {
return messageString;
}
return `<${requestId}> ${messageString}`;
}
static getRequestId(): string | undefined {
return ClsServiceManager.getClsService()?.getId();
}
static formatStack(stackOrError?: Error | string | unknown) {
if (stackOrError instanceof Error) {
let err = stackOrError;
// most of the internal error are caught and created by `GlobalExceptionFilter`,
// and their error stack is helpless
if (err instanceof UserFriendlyError) {
return err.stacktrace;
}
let stack = err.stack ?? '';
if (err.cause instanceof Error && err.cause.stack) {
stack += `\n\nCaused by:\n\n${err.cause.stack}`;
}
return stack;
}
return stackOrError;
}
/**
* Nestjs ConsoleLogger.error() will not print the stack trace if the error is an instance of Error
* This method is a workaround to print the stack trace
*
* Usage:
* ```
* this.logger.error('some error happens', errInstance);
* ```
*/
override error(
message: any,
stackOrError?: Error | string | unknown,
context?: string
) {
super.error(message, AFFiNELogger.formatStack(stackOrError), context);
}
}

View File

@@ -0,0 +1,16 @@
import { defineModuleConfig } from '../config';
declare global {
interface AppConfigSchema {
metrics: {
enabled: boolean;
};
}
}
defineModuleConfig('metrics', {
enabled: {
desc: 'Enable metric and tracing collection',
default: false,
},
});

View File

@@ -0,0 +1,19 @@
import './config';
import { Global, Module } from '@nestjs/common';
import {
OpentelemetryOptionsFactory,
OpentelemetryProvider,
} from './opentelemetry';
@Global()
@Module({
providers: [OpentelemetryOptionsFactory, OpentelemetryProvider],
exports: [OpentelemetryOptionsFactory],
})
export class MetricsModule {}
export * from './metrics';
export * from './utils';
export { OpentelemetryOptionsFactory };

View File

@@ -0,0 +1,149 @@
import {
Gauge,
Histogram,
Meter,
MeterProvider,
MetricOptions,
metrics as otelMetrics,
UpDownCounter,
} from '@opentelemetry/api';
import { HostMetrics } from '@opentelemetry/host-metrics';
function getMeterProvider() {
return otelMetrics.getMeterProvider();
}
export function registerCustomMetrics() {
const hostMetricsMonitoring = new HostMetrics({
name: 'instance-host-metrics',
meterProvider: getMeterProvider() as MeterProvider,
});
hostMetricsMonitoring.start();
}
export function getMeter(name = 'business') {
return getMeterProvider().getMeter(name);
}
type MetricType = 'counter' | 'gauge' | 'histogram';
type Metric<T extends MetricType> = T extends 'counter'
? UpDownCounter
: T extends 'gauge'
? Gauge
: T extends 'histogram'
? Histogram
: never;
export type ScopedMetrics = {
counter: (name: string, opts?: MetricOptions) => UpDownCounter;
gauge: (name: string, opts?: MetricOptions) => Gauge;
histogram: (name: string, opts?: MetricOptions) => Histogram;
};
type MetricCreators = {
[T in MetricType]: (
meter: Meter,
name: string,
opts?: MetricOptions
) => Metric<T>;
};
export type KnownMetricScopes =
| 'socketio'
| 'gql'
| 'jwst'
| 'auth'
| 'controllers'
| 'doc'
| 'sse'
| 'mail'
| 'ai'
| 'event'
| 'queue'
| 'storage'
| 'process'
| 'permission'
| 'quota'
| 'license'
| 'invalidation'
| 'search'
| 'workspace';
const metricCreators: MetricCreators = {
counter(meter: Meter, name: string, opts?: MetricOptions) {
return meter.createCounter(name, opts);
},
gauge(meter: Meter, name: string, opts?: MetricOptions) {
return meter.createGauge(name, opts);
},
histogram(meter: Meter, name: string, opts?: MetricOptions) {
return meter.createHistogram(name, opts);
},
};
const scopes = new Map<string, ScopedMetrics>();
function make(scope: string) {
const meter = getMeter();
const metrics = new Map<string, { type: MetricType; metric: any }>();
const prefix = scope + '/';
function getOrCreate<T extends MetricType>(
type: T,
name: string,
opts?: MetricOptions
): Metric<T> {
name = prefix + name;
const metric = metrics.get(name);
if (metric) {
if (type !== metric.type) {
throw new Error(
`Metric ${name} has already been registered as ${metric.type} mode, but get as ${type} again.`
);
}
return metric.metric;
} else {
const metric = metricCreators[type](meter, name, opts);
metrics.set(name, { type, metric });
return metric;
}
}
return {
counter(name, opts) {
return getOrCreate('counter', name, opts);
},
gauge(name, opts) {
return getOrCreate('gauge', name, opts);
},
histogram(name, opts) {
return getOrCreate('histogram', name, opts);
},
} satisfies ScopedMetrics;
}
/**
* @example
*
* ```
* metrics.scope.counter('example_count').add(1, {
* attr1: 'example-event'
* })
* ```
*/
export const metrics = new Proxy<Record<KnownMetricScopes, ScopedMetrics>>(
// @ts-expect-error proxied
{},
{
get(_, scopeName: string) {
let scope = scopes.get(scopeName);
if (!scope) {
scope = make(scopeName);
scopes.set(scopeName, scope);
}
return scope;
},
}
);

View File

@@ -0,0 +1,145 @@
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
CompositePropagator,
W3CBaggagePropagator,
W3CTraceContextPropagator,
} from '@opentelemetry/core';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { ZipkinExporter } from '@opentelemetry/exporter-zipkin';
import { Instrumentation } from '@opentelemetry/instrumentation';
import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { IMetricReader, MetricProducer } from '@opentelemetry/sdk-metrics';
import { NodeSDK, NodeSDKConfiguration } from '@opentelemetry/sdk-node';
import {
BatchSpanProcessor,
SpanExporter,
TraceIdRatioBasedSampler,
} from '@opentelemetry/sdk-trace-node';
import {
ATTR_K8S_NAMESPACE_NAME,
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions/incubating';
import { PrismaInstrumentation } from '@prisma/instrumentation';
import { Config } from '../config';
import { OnEvent } from '../event/def';
import { registerCustomMetrics } from './metrics';
import { PrismaMetricProducer } from './prisma';
export abstract class BaseOpentelemetryOptionsFactory {
abstract getMetricReader(): IMetricReader;
abstract getSpanExporter(): SpanExporter;
getInstractions(): Instrumentation[] {
return [
new NestInstrumentation(),
new IORedisInstrumentation(),
new SocketIoInstrumentation({ traceReserved: true }),
new GraphQLInstrumentation({ mergeItems: true }),
new HttpInstrumentation(),
new PrismaInstrumentation(),
];
}
getMetricsProducers(): MetricProducer[] {
return [new PrismaMetricProducer()];
}
getResource() {
return resourceFromAttributes({
[ATTR_K8S_NAMESPACE_NAME]: env.NAMESPACE,
[ATTR_SERVICE_NAME]: env.FLAVOR,
[ATTR_SERVICE_VERSION]: env.version,
});
}
create(): Partial<NodeSDKConfiguration> {
const traceExporter = this.getSpanExporter();
return {
resource: this.getResource(),
sampler: new TraceIdRatioBasedSampler(0.1),
traceExporter,
metricReader: this.getMetricReader(),
spanProcessor: new BatchSpanProcessor(traceExporter),
textMapPropagator: new CompositePropagator({
propagators: [
new W3CBaggagePropagator(),
new W3CTraceContextPropagator(),
],
}),
instrumentations: this.getInstractions(),
serviceName: 'affine-cloud',
};
}
}
@Injectable()
export class OpentelemetryOptionsFactory extends BaseOpentelemetryOptionsFactory {
override getMetricReader(): IMetricReader {
return new PrometheusExporter({
metricProducers: this.getMetricsProducers(),
});
}
override getSpanExporter(): SpanExporter {
return new ZipkinExporter();
}
}
@Injectable()
export class OpentelemetryProvider {
readonly #logger = new Logger(OpentelemetryProvider.name);
#sdk: NodeSDK | null = null;
constructor(
private readonly config: Config,
private readonly ref: ModuleRef
) {}
@OnEvent('config.init')
async init(event: Events['config.init']) {
if (env.flavors.script) {
return;
}
if (event.config.metrics.enabled) {
await this.setup();
registerCustomMetrics();
}
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if ('metrics' in event.updates) {
await this.setup();
}
}
async onModuleDestroy() {
await this.#sdk?.shutdown();
}
private async setup() {
if (this.config.metrics.enabled) {
if (!this.#sdk) {
const factory = this.ref.get(OpentelemetryOptionsFactory, {
strict: false,
});
this.#sdk = new NodeSDK(factory.create());
}
this.#sdk.start();
this.#logger.log('OpenTelemetry SDK started');
} else {
await this.#sdk?.shutdown();
this.#sdk = null;
this.#logger.log('OpenTelemetry SDK stopped');
}
}
}

View File

@@ -0,0 +1,128 @@
import { HrTime, ValueType } from '@opentelemetry/api';
import { hrTime } from '@opentelemetry/core';
import { emptyResource } from '@opentelemetry/resources';
import {
AggregationTemporality,
CollectionResult,
DataPointType,
MetricProducer,
ScopeMetrics,
} from '@opentelemetry/sdk-metrics';
import { PrismaFactory } from '../prisma/factory';
function transformPrismaKey(key: string) {
// replace first '_' to '/' as a scope prefix
// example: prisma_client_query_duration_seconds_sum -> prisma/client_query_duration_seconds_sum
return key.replace(/_/, '/');
}
export class PrismaMetricProducer implements MetricProducer {
private readonly startTime: HrTime = hrTime();
async collect(): Promise<CollectionResult> {
const result: CollectionResult = {
resourceMetrics: {
resource: emptyResource(),
scopeMetrics: [],
},
errors: [],
};
if (!PrismaFactory.INSTANCE) {
return result;
}
const prisma = PrismaFactory.INSTANCE;
const endTime = hrTime();
const metrics = await prisma.$metrics.json();
const scopeMetrics: ScopeMetrics = {
scope: {
name: '',
},
metrics: [],
};
for (const counter of metrics.counters) {
scopeMetrics.metrics.push({
descriptor: {
name: transformPrismaKey(counter.key),
description: counter.description,
unit: '1',
valueType: ValueType.INT,
},
dataPointType: DataPointType.SUM,
aggregationTemporality: AggregationTemporality.CUMULATIVE,
dataPoints: [
{
startTime: this.startTime,
endTime: endTime,
value: counter.value,
attributes: counter.labels,
},
],
isMonotonic: true,
});
}
for (const gauge of metrics.gauges) {
scopeMetrics.metrics.push({
descriptor: {
name: transformPrismaKey(gauge.key),
description: gauge.description,
unit: '1',
valueType: ValueType.INT,
},
dataPointType: DataPointType.GAUGE,
aggregationTemporality: AggregationTemporality.CUMULATIVE,
dataPoints: [
{
startTime: this.startTime,
endTime: endTime,
value: gauge.value,
attributes: gauge.labels,
},
],
});
}
for (const histogram of metrics.histograms) {
const boundaries = [];
const counts = [];
for (const [boundary, count] of histogram.value.buckets) {
boundaries.push(boundary);
counts.push(count);
}
scopeMetrics.metrics.push({
descriptor: {
name: transformPrismaKey(histogram.key),
description: histogram.description,
unit: 'ms',
valueType: ValueType.DOUBLE,
},
dataPointType: DataPointType.HISTOGRAM,
aggregationTemporality: AggregationTemporality.CUMULATIVE,
dataPoints: [
{
startTime: this.startTime,
endTime: endTime,
value: {
buckets: {
boundaries,
counts,
},
count: histogram.value.count,
sum: histogram.value.sum,
},
attributes: histogram.labels,
},
],
});
}
result.resourceMetrics.scopeMetrics.push(scopeMetrics);
return result;
}
}

View File

@@ -0,0 +1,50 @@
import type { Attributes } from '@opentelemetry/api';
import { makeMethodDecorator } from '../nestjs/decorator';
import { type KnownMetricScopes, metrics } from './metrics';
/**
* Decorator for measuring the call time, record call count and if is throw of a function call
* @param scope metric scope
* @param name metric event name
* @param attrs attributes
* @returns
*/
export const CallMetric = makeMethodDecorator(
(scope: KnownMetricScopes, name: string, attrs?: Attributes) => {
return (_target, _key, fn) => {
return wrapCallMetric(fn, scope, name, attrs);
};
}
);
export function wrapCallMetric<Fn extends (...args: any[]) => any>(
fn: Fn,
scope: KnownMetricScopes,
name: string,
attrs?: Attributes
) {
return async function (this: any, ...args: any[]) {
const start = Date.now();
let error = false;
try {
return await fn.call(this, ...args);
} catch (err) {
error = true;
throw err;
} finally {
const count = metrics[scope].counter('function_calls', {
description: 'function call counter',
});
const timer = metrics[scope].histogram('function_timer', {
description: 'function call time costs',
unit: 'ms',
});
count.add(1, { ...attrs, name, error });
timer.record(Date.now() - start, { ...attrs, name, error });
}
};
}

View File

@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { Locker } from './locker';
import { Mutex, RequestMutex } from './mutex';
@Global()
@Module({
providers: [Mutex, RequestMutex, Locker],
exports: [Mutex, RequestMutex],
})
export class MutexModule {}
export { Locker, Mutex, RequestMutex };
export { Lock } from './lock';

View File

@@ -0,0 +1,19 @@
import { Logger } from '@nestjs/common';
import { retryable } from '../utils/promise';
export class Lock implements AsyncDisposable {
private readonly logger = new Logger(Lock.name);
constructor(private readonly dispose: () => Promise<void>) {}
async release() {
await retryable(() => this.dispose()).catch(e => {
this.logger.error('Failed to release lock', e);
});
}
async [Symbol.asyncDispose]() {
await this.release();
}
}

View File

@@ -0,0 +1,67 @@
import { Injectable, Logger } from '@nestjs/common';
import { Command } from 'ioredis';
import { SessionRedis } from '../redis';
import { Lock } from './lock';
// === atomic mutex lock ===
// acquire lock
// return 1 if lock is acquired
// return 0 if lock is not acquired
const lockScript = `local key = KEYS[1]
local owner = ARGV[1]
-- if lock is not exists then set lock to the owner and return 1, otherwise return 0
-- if the lock is not released correctly due to unexpected reasons
-- lock will be released after 60 seconds
if redis.call("get", key) == owner then
return 0
elseif redis.call("set", key, owner, "NX", "EX", 60) then
return 1
else
return 0
end`;
// release lock
// return 1 if lock is released or lock is not exists
// return 0 if lock is not owned by the owner
const unlockScript = `local key = KEYS[1]
local owner = ARGV[1]
local value = redis.call("get", key)
if value == owner then
return redis.call("del", key)
elseif value == nil then
return 1
else
return 0
end`;
@Injectable()
export class Locker {
private readonly logger = new Logger(Locker.name);
constructor(private readonly redis: SessionRedis) {}
async lock(owner: string, key: string): Promise<Lock> {
const lockKey = `MutexLock:${key}`;
this.logger.verbose(`Client ${owner} is trying to lock resource ${key}`);
const success = await this.redis.sendCommand(
new Command('EVAL', [lockScript, '1', lockKey, owner])
);
if (success === 1) {
return new Lock(async () => {
const result = await this.redis.sendCommand(
new Command('EVAL', [unlockScript, '1', lockKey, owner])
);
if (result === 0) {
throw new Error(`Failed to release lock ${key}`);
}
});
}
throw new Error(`Failed to acquire lock for resource [${key}]`);
}
}

View File

@@ -0,0 +1,94 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable, Logger, Scope } from '@nestjs/common';
import { ModuleRef, REQUEST } from '@nestjs/core';
import type { Request } from 'express';
import { nanoid } from 'nanoid';
import { GraphqlContext } from '../graphql';
import { retryable } from '../utils/promise';
import { Locker } from './locker';
export const MUTEX_RETRY = 5;
export const MUTEX_WAIT = 100;
@Injectable()
export class Mutex {
protected logger = new Logger(Mutex.name);
private readonly clusterIdentifier = `cluster:${nanoid()}`;
constructor(protected readonly locker: Locker) {}
/**
* lock an resource and return a lock guard, which will release the lock when disposed
*
* if the lock is not available, it will retry for [MUTEX_RETRY] times
*
* usage:
* ```typescript
* {
* // lock is acquired here
* await using lock = await mutex.acquire('resource-key');
* if (lock) {
* // do something
* } else {
* // failed to lock
* }
* }
* // lock is released here
* ```
* @param key resource key
* @returns LockGuard
*/
async acquire(
key: string,
owner: string = `${this.clusterIdentifier}:${nanoid()}`
) {
try {
return await retryable(
() => this.locker.lock(owner, key),
MUTEX_RETRY,
MUTEX_WAIT
);
} catch (e) {
this.logger.error(
`Failed to lock resource [${key}] after retry ${MUTEX_RETRY} times`,
e
);
return undefined;
}
}
}
@Injectable({ scope: Scope.REQUEST })
export class RequestMutex extends Mutex {
constructor(
@Inject(REQUEST) private readonly request: Request | GraphqlContext,
ref: ModuleRef
) {
// nestjs will always find and injecting the locker from local module
// so the RedisLocker implemented by the plugin mechanism will not be able to overwrite the internal locker
// we need to use find and get the locker from the `ModuleRef` manually
//
// NOTE: when a `constructor` execute in normal service, the Locker module we expect may not have been initialized
// but in the Service with `Scope.REQUEST`, we will create a separate Service instance for each request
// at this time, all modules have been initialized, so we able to get the correct Locker instance in `constructor`
super(ref.get(Locker));
}
protected getId() {
const req = 'req' in this.request ? this.request.req : this.request;
let id = req.headers['x-transaction-id'] as string;
if (!id) {
id = randomUUID();
req.headers['x-transaction-id'] = id;
}
return id;
}
override acquire(key: string) {
return super.acquire(key, this.getId());
}
}

View File

@@ -0,0 +1,45 @@
export function makeMethodDecorator<
T extends any[],
Fn extends (...args: any[]) => any,
>(
decorator: (...args: T) => (target: any, key: string | symbol, fn: Fn) => Fn
) {
return (...args: T) => {
return (
target: any,
key: string | symbol,
desc: TypedPropertyDescriptor<any>
) => {
const originalFn = desc.value;
if (!originalFn || typeof originalFn !== 'function') {
throw new Error(
`MethodDecorator must be applied to a function but got ${typeof originalFn}`
);
}
const decoratedFn = decorator(...args)(target, key, originalFn);
desc.value = decoratedFn;
return desc;
};
};
}
export function PushMetadata<T>(key: string | symbol, value: T) {
const decorator: ClassDecorator | MethodDecorator = (
target,
_,
descriptor
) => {
const metadataTarget = descriptor?.value ?? target;
const metadataArray = Reflect.getMetadata(key, metadataTarget) || [];
metadataArray.push(value);
Reflect.defineMetadata(key, metadataArray, metadataTarget);
};
return decorator;
}
export function sliceMetadata<T>(key: string | symbol, target: any): T[] {
return Reflect.getMetadata(key, target) || [];
}

View File

@@ -0,0 +1,186 @@
import { ApolloServerErrorCode } from '@apollo/server/errors';
import {
ArgumentsHost,
Catch,
Logger,
NotFoundException,
} from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { GqlContextType } from '@nestjs/graphql';
import { ThrottlerException } from '@nestjs/throttler';
import { BaseWsExceptionFilter } from '@nestjs/websockets';
import { Response } from 'express';
import { GraphQLError } from 'graphql';
import { HttpError } from 'http-errors';
import { of } from 'rxjs';
import { Socket } from 'socket.io';
import { ZodError } from 'zod';
import {
GraphqlBadRequest,
HttpRequestError,
InternalServerError,
NotFound,
TooManyRequest,
UserFriendlyError,
ValidationError,
} from '../error';
import { metrics } from '../metrics';
import { getRequestIdFromHost } from '../utils';
export function isGraphQLBadRequest(error: GraphQLError) {
// https://www.apollographql.com/docs/apollo-server/data/errors
const code = error.extensions?.code;
return (
code === ApolloServerErrorCode.GRAPHQL_PARSE_FAILED ||
code === ApolloServerErrorCode.GRAPHQL_VALIDATION_FAILED ||
code === ApolloServerErrorCode.BAD_USER_INPUT ||
code === ApolloServerErrorCode.BAD_REQUEST
);
}
export function mapAnyError(error: any): UserFriendlyError {
if (error instanceof GraphQLError) {
if (isGraphQLBadRequest(error)) {
return new GraphqlBadRequest({
code: error.extensions.code as string,
message: error.message,
});
}
error = error.originalError ?? error;
}
if (error instanceof UserFriendlyError) {
return error;
} else if (error instanceof ThrottlerException) {
return new TooManyRequest();
} else if (error instanceof NotFoundException) {
return new NotFound();
} else if (error instanceof ZodError) {
return new ValidationError({
errors: error.message,
});
} else if (
error instanceof HttpError &&
error.status >= 400 &&
error.status < 500
) {
const e = new HttpRequestError({
message: error.message,
});
e.status = error.status;
return e;
} else {
const e = new InternalServerError();
e.cause = error;
return e;
}
}
@Catch()
export class GlobalExceptionFilter extends BaseExceptionFilter {
logger = new Logger('GlobalExceptionFilter');
override catch(exception: Error, host: ArgumentsHost) {
const error = mapAnyError(exception);
// with useGlobalFilters, the context is always HTTP
if (host.getType<GqlContextType>() === 'graphql') {
// let Graphql LoggerPlugin handle it
// see '../graphql/logger-plugin.ts'
throw error;
} else {
error.log('HTTP', {
requestId: error.requestId ?? getRequestIdFromHost(host),
});
metrics.controllers
.counter('error')
.add(1, { status: error.status, type: error.type, error: error.name });
const res = host.switchToHttp().getResponse<Response>();
const respondText = res.getHeader('content-type') === 'text/plain';
if (respondText) {
res
.setHeader('content-type', 'text/plain')
.status(error.status)
.send(error.toText());
} else {
res.status(error.status).send(error.toJSON());
}
return;
}
}
}
export class GlobalWsExceptionFilter extends BaseWsExceptionFilter {
// @ts-expect-error satisfies the override
override handleError(client: Socket, exception: any): void {
const error = mapAnyError(exception);
error.log('Websocket');
metrics.socketio
.counter('unhandled_error')
.add(1, { status: error.status });
client.emit('error', {
error: toWebsocketError(error),
});
}
}
/**
* Only exists for websocket error body backward compatibility
*
* relay on `code` field instead of `name`
*
* @TODO(@forehalo): remove
*/
function toWebsocketError(error: UserFriendlyError) {
// should be `error.toJSON()` after backward compatibility removed
return {
status: error.status,
code: error.name.toUpperCase(),
type: error.type.toUpperCase(),
name: error.name.toUpperCase(),
message: error.message,
data: error.data,
requestId: error.requestId,
};
}
export const GatewayErrorWrapper = (event: string): MethodDecorator => {
// @ts-expect-error allow
return (
_target,
_key,
desc: TypedPropertyDescriptor<(...args: any[]) => any>
) => {
const originalMethod = desc.value;
if (!originalMethod) {
return desc;
}
desc.value = async function (...args: any[]) {
try {
return await originalMethod.apply(this, args);
} catch (error) {
const mappedError = mapAnyError(error);
mappedError.log('Websocket');
metrics.socketio
.counter('error')
.add(1, { event, status: mappedError.status });
return {
error: toWebsocketError(mappedError),
};
}
};
return desc;
};
};
export function mapSseError(originalError: any, info: object) {
const error = mapAnyError(originalError);
error.log('Sse', info);
metrics.sse.counter('error').add(1, { status: error.status });
return of({
type: 'error' as const,
data: error.toJSON(),
});
}

View File

@@ -0,0 +1,3 @@
export * from './decorator';
export * from './exception';
export * from './scanner';

View File

@@ -0,0 +1,60 @@
import { Global, Injectable, Module } from '@nestjs/common';
import {
DiscoveryModule,
DiscoveryService,
MetadataScanner,
} from '@nestjs/core';
import { RESOLVER_TYPE_METADATA } from '@nestjs/graphql';
@Injectable()
export class ModuleScanner {
constructor(
private readonly discovery: DiscoveryService,
private readonly scanner: MetadataScanner
) {}
getClassProviders() {
return this.discovery
.getProviders()
.filter(
wrapper =>
wrapper.instance && !wrapper.isAlias && !wrapper.isNotMetatype
);
}
getAtInjectables() {
return this.getClassProviders().filter(
wrapper => !this.isResolver(wrapper.instance)
);
}
getControllers() {
return this.discovery.getControllers();
}
getResolvers() {
return this.getClassProviders().filter(wrapper =>
this.isResolver(wrapper.instance)
);
}
getAllMethodNames(instance: any) {
return this.scanner.getAllMethodNames(Object.getPrototypeOf(instance));
}
isResolver(instance: any) {
if (typeof instance !== 'object') {
return false;
}
const metadata = Reflect.getMetadata(RESOLVER_TYPE_METADATA, instance);
return metadata !== undefined;
}
}
@Global()
@Module({
imports: [DiscoveryModule],
providers: [ModuleScanner],
exports: [ModuleScanner],
})
export class ScannerModule {}

View File

@@ -0,0 +1,27 @@
import type { Prisma } from '@prisma/client';
import { z } from 'zod';
import { defineModuleConfig } from '../config';
declare global {
interface AppConfigSchema {
db: {
datasourceUrl: string;
prisma: ConfigItem<Prisma.PrismaClientOptions>;
};
}
}
defineModuleConfig('db', {
datasourceUrl: {
desc: 'The datasource url for the prisma client.',
default: 'postgresql://localhost:5432/affine',
env: 'DATABASE_URL',
shape: z.string().url(),
},
prisma: {
desc: 'The config for the prisma client.',
default: {},
link: 'https://www.prisma.io/docs/reference/api-reference/prisma-client-reference',
},
});

View File

@@ -0,0 +1,25 @@
import type { OnModuleDestroy } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Config } from '../config';
@Injectable()
export class PrismaFactory implements OnModuleDestroy {
static INSTANCE: PrismaClient | null = null;
readonly #instance: PrismaClient;
constructor(config: Config) {
this.#instance = new PrismaClient(config.db.prisma);
PrismaFactory.INSTANCE = this.#instance;
}
get() {
return this.#instance;
}
async onModuleDestroy() {
await PrismaFactory.INSTANCE?.$disconnect();
PrismaFactory.INSTANCE = null;
}
}

View File

@@ -0,0 +1,27 @@
import './config';
import { Global, Module, Provider } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaFactory } from './factory';
// only `PrismaClient` can be injected
const clientProvider: Provider = {
provide: PrismaClient,
useFactory: (factory: PrismaFactory) => {
return factory.get();
},
inject: [PrismaFactory],
};
@Global()
@Module({
providers: [PrismaFactory, clientProvider],
exports: [clientProvider],
})
export class PrismaModule {}
export { PrismaFactory };
export type PrismaTransaction = Parameters<
Parameters<PrismaClient['$transaction']>[0]
>[0];

View File

@@ -0,0 +1,54 @@
import { RedisOptions } from 'ioredis';
import { z } from 'zod';
import { defineModuleConfig } from '../config';
declare global {
interface AppConfigSchema {
redis: {
host: string;
port: number;
db: number;
username: string;
password: string;
ioredis: ConfigItem<
Omit<RedisOptions, 'host' | 'port' | 'db' | 'username' | 'password'>
>;
};
}
}
defineModuleConfig('redis', {
db: {
desc: 'The database index of redis server to be used(Must be less than 10).',
default: 0,
env: ['REDIS_SERVER_DATABASE', 'integer'],
shape: z.number().int().nonnegative().max(10),
},
host: {
desc: 'The host of the redis server.',
default: 'localhost',
env: ['REDIS_SERVER_HOST', 'string'],
},
port: {
desc: 'The port of the redis server.',
default: 6379,
env: ['REDIS_SERVER_PORT', 'integer'],
shape: z.number().positive(),
},
username: {
desc: 'The username of the redis server.',
default: '',
env: ['REDIS_SERVER_USERNAME', 'string'],
},
password: {
desc: 'The password of the redis server.',
default: '',
env: ['REDIS_SERVER_PASSWORD', 'string'],
},
ioredis: {
desc: 'The config for the ioredis client.',
default: {},
link: 'https://github.com/luin/ioredis',
},
});

View File

@@ -0,0 +1,14 @@
import './config';
import { Global, Module } from '@nestjs/common';
import { CacheRedis, SessionRedis, SocketIoRedis } from './instances';
@Global()
@Module({
providers: [CacheRedis, SessionRedis, SocketIoRedis],
exports: [CacheRedis, SessionRedis, SocketIoRedis],
})
export class RedisModule {}
export { CacheRedis, SessionRedis, SocketIoRedis };

View File

@@ -0,0 +1,85 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Redis as IORedis, RedisOptions } from 'ioredis';
import { Config } from '../config';
function redisOptions(options: RedisOptions) {
return {
...(env.testing ? { lazyConnect: true } : {}),
...options,
};
}
class Redis extends IORedis implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(this.constructor.name);
errorHandler = (err: Error) => {
this.logger.error(err);
};
onModuleInit() {
this.on('error', this.errorHandler);
}
async onModuleDestroy() {
try {
await this.quit();
} catch {
this.disconnect();
}
}
override duplicate(override?: Partial<RedisOptions>): IORedis {
const client = super.duplicate(override);
client.on('error', this.errorHandler);
return client;
}
assertValidDBIndex(db: number) {
if (db && db > 15) {
throw new Error(
// Redis allows [0..16) by default
// we separate the db for different usages by `this.options.db + [0..4]`
`Invalid database index: ${db}, must be between 0 and 11`
);
}
}
}
@Injectable()
export class CacheRedis extends Redis {
constructor(config: Config) {
super(redisOptions({ ...config.redis, ...config.redis.ioredis }));
}
}
@Injectable()
export class SessionRedis extends Redis {
constructor(config: Config) {
super(
redisOptions({
...config.redis,
...config.redis.ioredis,
db: (config.redis.db ?? 0) + 2,
})
);
}
}
@Injectable()
export class SocketIoRedis extends Redis {
constructor(config: Config) {
super(
redisOptions({
...config.redis,
...config.redis.ioredis,
db: (config.redis.db ?? 0) + 3,
})
);
}
}

View File

@@ -0,0 +1,3 @@
export * from './providers';
export type * from './types';
export * from './utils';

View File

@@ -0,0 +1,250 @@
import { JSONSchema } from '../../config';
export type StorageProviderName =
| 'fs'
| 'aws-s3'
| 'cloudflare-r2'
| 'assetpack';
export interface FsStorageConfig {
path: string;
}
export type AssetpackStorageConfig = FsStorageConfig;
export interface S3StorageConfig {
endpoint?: string;
region: string;
credentials?: {
accessKeyId?: string;
secretAccessKey?: string;
sessionToken?: string;
};
forcePathStyle?: boolean;
requestTimeoutMs?: number;
minPartSize?: number;
presign?: {
expiresInSeconds?: number;
signContentTypeForPut?: boolean;
};
usePresignedURL?: {
enabled: boolean;
urlPrefix?: string;
signKey?: string;
};
}
export const R2_JURISDICTIONS = ['default', 'eu'] as const;
export interface R2StorageConfig extends Omit<S3StorageConfig, 'endpoint'> {
accountId: string;
jurisdiction?: (typeof R2_JURISDICTIONS)[number];
}
export type StorageProviderConfig = { bucket: string } & (
| {
provider: 'fs';
config: FsStorageConfig;
}
| {
provider: 'aws-s3';
config: S3StorageConfig;
}
| {
provider: 'cloudflare-r2';
config: R2StorageConfig;
}
| {
provider: 'assetpack';
config: AssetpackStorageConfig;
}
);
const S3ConfigSchema: JSONSchema = {
type: 'object',
description: 'The config for the S3 compatible storage provider.',
properties: {
endpoint: {
type: 'string',
description:
'The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region.',
},
region: {
type: 'string',
description:
'The region for the storage provider. Example: "us-east-1" or "auto" for R2.',
},
forcePathStyle: {
type: 'boolean',
description: 'Whether to use path-style bucket addressing.',
},
requestTimeoutMs: {
type: 'number',
description: 'Request timeout in milliseconds.',
},
minPartSize: {
type: 'number',
description: 'Minimum multipart part size in bytes.',
},
presign: {
type: 'object',
description: 'Presigned URL behavior configuration.',
properties: {
expiresInSeconds: {
type: 'number',
description: 'Expiration time in seconds for presigned URLs.',
},
signContentTypeForPut: {
type: 'boolean',
description: 'Whether to sign Content-Type for presigned PUT.',
},
},
},
credentials: {
type: 'object',
description: 'The credentials for the s3 compatible storage provider.',
properties: {
accessKeyId: {
type: 'string',
},
secretAccessKey: {
type: 'string',
},
sessionToken: {
type: 'string',
},
},
},
usePresignedURL: {
type: 'object',
description:
'Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.',
properties: {
enabled: {
type: 'boolean',
description: 'Whether browser upload URLs are enabled.',
},
urlPrefix: {
type: 'string',
description:
'Optional base URL for proxied uploads and signed custom GET URLs. Provider presigned uploads require an origin-only value when signKey is not configured.',
},
signKey: {
type: 'string',
description:
'Optional HMAC key for signed upload and custom GET URLs. Custom GET URLs require both signKey and urlPrefix.',
},
},
},
},
};
const S3ConfigPropertiesWithoutEndpoint = Object.fromEntries(
Object.entries(
(
S3ConfigSchema as {
type: 'object';
properties?: Record<string, JSONSchema>;
}
).properties ?? {}
).filter(([key]) => key !== 'endpoint')
) as Record<string, JSONSchema>;
export const StorageJSONSchema: JSONSchema = {
oneOf: [
{
type: 'object',
properties: {
provider: {
type: 'string',
enum: ['fs'],
},
bucket: {
type: 'string',
},
config: {
type: 'object',
properties: {
path: {
type: 'string',
},
},
},
},
},
{
type: 'object',
properties: {
provider: {
type: 'string',
enum: ['aws-s3'],
},
bucket: {
type: 'string',
},
config: S3ConfigSchema,
},
},
{
type: 'object',
properties: {
provider: {
type: 'string',
enum: ['cloudflare-r2'],
},
bucket: {
type: 'string',
},
config: {
...S3ConfigSchema,
properties: {
...S3ConfigPropertiesWithoutEndpoint,
accountId: {
type: 'string' as const,
description:
'The account id for the cloudflare r2 storage provider.',
},
jurisdiction: {
type: 'string' as const,
enum: [...R2_JURISDICTIONS],
description:
'Optional jurisdiction for the cloudflare r2 endpoint. Set to "eu" for EU buckets.',
},
},
},
},
},
{
type: 'object',
properties: {
provider: {
type: 'string',
enum: ['assetpack'],
},
bucket: {
type: 'string',
},
config: {
type: 'object',
properties: {
path: {
type: 'string',
},
},
required: ['path'],
},
},
required: ['provider', 'bucket', 'config'],
},
],
};
export type * from '../types';
export {
applyAttachHeaders,
PROXY_MULTIPART_PATH,
PROXY_UPLOAD_PATH,
sniffMime,
STORAGE_PROXY_ROOT,
toBuffer,
} from '../utils';

View File

@@ -0,0 +1,32 @@
import type { Readable } from 'node:stream';
export interface GetObjectMetadata {
/**
* @default 'application/octet-stream'
*/
contentType: string;
contentLength: number;
lastModified: Date;
checksumCRC32?: string;
}
export interface PutObjectMetadata {
contentType?: string;
contentLength?: number;
checksumCRC32?: string;
}
export interface ListObjectsMetadata {
key: string;
lastModified: Date;
contentLength: number;
}
export type BlobInputType = Buffer | Readable | string;
export type BlobOutputType = Readable;
export interface PresignedUpload {
url: string;
headers?: Record<string, string>;
expiresAt: Date;
}

View File

@@ -0,0 +1,88 @@
import { createHmac } from 'node:crypto';
import { Readable } from 'node:stream';
import type { Response } from 'express';
import { getStreamAsBuffer } from 'get-stream';
import { getMime } from '../../native';
import type { BlobInputType } from './types';
export async function toBuffer(input: BlobInputType): Promise<Buffer> {
return input instanceof Readable
? await getStreamAsBuffer(input)
: input instanceof Buffer
? input
: Buffer.from(input as string);
}
const DANGEROUS_INLINE_MIME_PREFIXES = [
'text/html',
'application/xhtml+xml',
'image/svg+xml',
'application/xml',
'text/xml',
'text/javascript',
];
export function isDangerousInlineMime(mime: string | undefined) {
if (!mime) return false;
const lower = mime.toLowerCase();
return DANGEROUS_INLINE_MIME_PREFIXES.some(p => lower.startsWith(p));
}
export function applyAttachHeaders(
res: Response,
options: { filename?: string; buffer?: Buffer; contentType?: string }
) {
let { filename, buffer, contentType } = options;
res.setHeader('X-Content-Type-Options', 'nosniff');
if (!contentType && buffer) contentType = sniffMime(buffer);
if (contentType && isDangerousInlineMime(contentType)) {
const safeName = (filename || 'download')
.replace(/[\r\n]/g, '')
.replace(/[^\w\s.-]/g, '_');
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(safeName)}"; filename*=UTF-8''${encodeURIComponent(
safeName
)}`
);
}
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', contentType || 'application/octet-stream');
}
}
export function sniffMime(
buffer: Buffer,
declared?: string
): string | undefined {
try {
const detected = getMime(buffer);
if (detected) return detected;
} catch {}
return declared;
}
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
export const STORAGE_PROXY_ROOT = '/api/storage';
export const PROXY_UPLOAD_PATH = `${STORAGE_PROXY_ROOT}/upload`;
export const PROXY_MULTIPART_PATH = `${STORAGE_PROXY_ROOT}/multipart`;
export function createStorageUploadToken(
path: string,
fields: (string | number)[],
expiresAt: number,
signKey: string
) {
const canonical = JSON.stringify([
'affine-storage-upload',
1,
'PUT',
path,
...fields,
expiresAt,
]);
return createHmac('sha256', signKey).update(canonical).digest('base64url');
}

View File

@@ -0,0 +1,38 @@
import { defineModuleConfig } from '../config';
export type ThrottlerType = 'default' | 'strict';
declare global {
interface AppConfigSchema {
throttle: {
enabled: boolean;
throttlers: {
[key in ThrottlerType]: ConfigItem<{
ttl: number;
limit: number;
}>;
};
};
}
}
defineModuleConfig('throttle', {
enabled: {
desc: 'Whether the throttler is enabled.',
default: true,
},
'throttlers.default': {
desc: 'The config for the default throttler.',
default: {
ttl: 60_000,
limit: 120,
},
},
'throttlers.strict': {
desc: 'The config for the strict throttler.',
default: {
ttl: 60_000,
limit: 20,
},
},
});

View File

@@ -0,0 +1,51 @@
import { applyDecorators, SetMetadata } from '@nestjs/common';
import {
SkipThrottle as RawSkipThrottle,
Throttle as RawThrottle,
} from '@nestjs/throttler';
import { ThrottlerType } from './config';
export type Throttlers = 'default' | 'strict' | 'authenticated';
export const THROTTLER_PROTECTED = 'affine_throttler:protected';
/**
* Choose what throttler to use
*
* If a Controller or Query do not protected behind a Throttler,
* it will never be rate limited.
*
* - default: 120 calls within 60 seconds
* - strict: 20 calls within 60 seconds
* - authenticated: no rate limit for authenticated users, apply [default] throttler for unauthenticated users
*
* @example
*
* \@Throttle()
* \@Throttle('strict')
*
* // the config call be override by the second parameter,
* // and the call count will be calculated separately
* \@Throttle('default', { limit: 10, ttl: 10_000 })
*
*/
export function Throttle(
type: ThrottlerType | 'authenticated' = 'default',
override: { limit?: number; ttl?: number } = {}
): MethodDecorator & ClassDecorator {
return applyDecorators(
SetMetadata(THROTTLER_PROTECTED, type),
RawThrottle({
[type]: override,
})
);
}
export function SkipThrottle(
skip: Partial<Record<ThrottlerType, boolean>> = {
default: true,
strict: true,
}
): MethodDecorator & ClassDecorator {
return RawSkipThrottle(skip);
}

View File

@@ -0,0 +1,277 @@
import './config';
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
InjectThrottlerOptions,
InjectThrottlerStorage,
ThrottlerGuard,
ThrottlerModule,
type ThrottlerModuleOptions,
ThrottlerOptionsFactory,
ThrottlerRequest,
ThrottlerStorageService,
} from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Config } from '../config';
import { CacheRedis } from '../redis';
import { getRequestResponseFromContext } from '../utils/request';
import { getRequestTrackerId } from '../utils/request-tracker';
import type { ThrottlerType } from './config';
import { THROTTLER_PROTECTED, Throttlers } from './decorators';
const REDIS_THROTTLE_SCRIPT = `
local now = redis.call("TIME")
local nowMs = now[1] * 1000 + math.floor(now[2] / 1000)
local blockedUntil = tonumber(redis.call("HGET", KEYS[1], "blockedUntil")) or 0
if blockedUntil > nowMs then
return {
tonumber(redis.call("HGET", KEYS[1], "hits")) or 0,
redis.call("PTTL", KEYS[1]),
blockedUntil - nowMs
}
end
if blockedUntil > 0 then
redis.call("HDEL", KEYS[1], "blockedUntil")
redis.call("HSET", KEYS[1], "hits", 0)
end
local hits = redis.call("HINCRBY", KEYS[1], "hits", 1)
if hits == 1 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
local blockTtl = 0
if hits > tonumber(ARGV[2]) then
blockedUntil = nowMs + tonumber(ARGV[3])
redis.call("HSET", KEYS[1], "blockedUntil", blockedUntil)
if redis.call("PTTL", KEYS[1]) < tonumber(ARGV[3]) then
redis.call("PEXPIRE", KEYS[1], ARGV[3])
end
blockTtl = tonumber(ARGV[3])
end
return { hits, redis.call("PTTL", KEYS[1]), blockTtl }
`;
@Injectable()
export class ThrottlerStorage extends ThrottlerStorageService {
constructor(private readonly redis: CacheRedis) {
super();
}
override async increment(
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string
) {
if (env.testing) {
return super.increment(key, ttl, limit, blockDuration, throttlerName);
}
try {
const result = await this.redis.eval(
REDIS_THROTTLE_SCRIPT,
1,
key,
ttl,
limit,
Math.max(blockDuration, 1)
);
if (!Array.isArray(result) || result.length !== 3) {
throw new Error('Unexpected Redis throttler response');
}
const totalHits = Number(result[0]);
const timeToExpire = Math.max(0, Math.ceil(Number(result[1]) / 1000));
const timeToBlockExpire = Math.max(
0,
Math.ceil(Number(result[2]) / 1000)
);
return {
totalHits,
timeToExpire,
isBlocked: timeToBlockExpire > 0,
timeToBlockExpire,
};
} catch {
// Preserve availability if Redis is unavailable. The inherited local
// storage still protects each process while the shared limiter recovers.
return super.increment(key, ttl, limit, blockDuration, throttlerName);
}
}
}
@Injectable()
class CustomOptionsFactory implements ThrottlerOptionsFactory {
constructor(
private readonly config: Config,
private readonly storage: ThrottlerStorage
) {}
createThrottlerOptions() {
const options: ThrottlerModuleOptions = {
throttlers: Object.entries(this.config.throttle.throttlers).map(
([name, config]) => ({
name,
...config,
})
),
storage: this.storage,
};
return options;
}
}
@Injectable()
export class CloudThrottlerGuard extends ThrottlerGuard {
constructor(
@InjectThrottlerOptions() options: ThrottlerModuleOptions,
@InjectThrottlerStorage() storageService: ThrottlerStorage,
reflector: Reflector,
private readonly config: Config
) {
super(options, storageService, reflector);
}
override getRequestResponse(context: ExecutionContext): {
req: Request;
res: Response;
} {
return getRequestResponseFromContext(context) as any;
}
override getTracker(req: Request): Promise<string> {
// throttler prefix make the key in store recognizable
return Promise.resolve(`throttler:${getRequestTrackerId(req)}`);
}
override generateKey(
context: ExecutionContext,
tracker: string,
throttler: string
) {
if (tracker.endsWith(';custom')) {
return `${tracker};${throttler}:${context.getClass().name}.${context.getHandler().name}`;
}
return `${tracker};${throttler}`;
}
override async handleRequest(request: ThrottlerRequest) {
const {
context,
throttler: throttlerOptions,
ttl,
blockDuration,
} = request;
let limit = request.limit;
// give it 'default' if no throttler is specified,
// so the unauthenticated users visits will always hit default throttler
// authenticated users will directly bypass unprotected APIs in [CloudThrottlerGuard.canActivate]
const throttler = this.getSpecifiedThrottler(context) ?? 'default';
// by pass unmatched throttlers
if (throttlerOptions.name !== throttler) {
return true;
}
const { req, res } = this.getRequestResponse(context);
const ignoreUserAgents =
throttlerOptions.ignoreUserAgents ?? this.commonOptions.ignoreUserAgents;
if (Array.isArray(ignoreUserAgents)) {
for (const pattern of ignoreUserAgents) {
const ua = req.headers['user-agent'];
if (ua && pattern.test(ua)) {
return true;
}
}
}
let tracker = await this.getTracker(req);
// custom limit or ttl APIs will be treated standalone
if (limit !== throttlerOptions.limit || ttl !== throttlerOptions.ttl) {
tracker += ';custom';
}
const key = this.generateKey(
context,
tracker,
throttlerOptions.name ?? 'default'
);
const { timeToExpire, totalHits, isBlocked, timeToBlockExpire } =
await this.storageService.increment(key, ttl, limit, blockDuration, key);
if (isBlocked) {
res.header('Retry-After', timeToBlockExpire.toString());
await this.throwThrottlingException(context, {
limit,
ttl,
key,
tracker,
totalHits,
timeToExpire,
isBlocked,
timeToBlockExpire,
});
}
res.header(`${this.headerPrefix}-Limit`, limit.toString());
res.header(
`${this.headerPrefix}-Remaining`,
(limit - totalHits).toString()
);
res.header(`${this.headerPrefix}-Reset`, timeToExpire.toString());
return true;
}
override async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.config.throttle.enabled) {
return true;
}
const { req } = this.getRequestResponse(context);
const throttler = this.getSpecifiedThrottler(context);
// if user is logged in, bypass non-protected handlers
if (!throttler && req.session?.user) {
return true;
}
return super.canActivate(context);
}
getSpecifiedThrottler(context: ExecutionContext): ThrottlerType | undefined {
const throttler = this.reflector.getAllAndOverride<Throttlers | undefined>(
THROTTLER_PROTECTED,
[context.getHandler(), context.getClass()]
);
return throttler === 'authenticated' ? undefined : throttler;
}
}
@Global()
@Module({
imports: [
ThrottlerModule.forRootAsync({
useClass: CustomOptionsFactory,
}),
],
providers: [ThrottlerStorage, CloudThrottlerGuard],
exports: [ThrottlerStorage, CloudThrottlerGuard],
})
export class RateLimiterModule {}
export * from './decorators';

View File

@@ -0,0 +1,135 @@
# Snapshot report for `src/base/utils/__tests__/duration.spec.ts`
The actual snapshot is saved in `duration.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should parse duration strings correctly
> parser - 1ms
'{"ms":1}'
> ms - 1ms
1
> parser - 1s
'{"s":1}'
> ms - 1s
1000
> parser - 1m
'{"m":1}'
> ms - 1m
60000
> parser - 1h
'{"h":1}'
> ms - 1h
3600000
> parser - 1d
'{"d":1}'
> ms - 1d
86400000
> parser - 1w
'{"w":1}'
> ms - 1w
604800000
> parser - 1M
'{"M":1}'
> ms - 1M
2592000000
> parser - 1y
'{"y":1}'
> ms - 1y
31536000000
> parser - 1000ms
'{"ms":1000}'
> ms - 1000ms
1000
> parser - 60s
'{"s":60}'
> ms - 60s
60000
> parser - 30m
'{"m":30}'
> ms - 30m
1800000
> parser - 1h30m
'{"h":1,"m":30}'
> ms - 1h30m
5400000
> parser - 15d
'{"d":15}'
> ms - 15d
1296000000
> parser - 1y
'{"y":1}'
> ms - 1y
31536000000
> parser - 12M
'{"M":12}'
> ms - 12M
31104000000
> parser - 1y1M1d1h1m1s1ms
'{"y":1,"M":1,"d":1,"h":1,"m":1,"s":1,"ms":1}'
> ms - 1y1M1d1h1m1s1ms
34218061001

View File

@@ -0,0 +1,37 @@
import test from 'ava';
import { Due } from '../duration';
test('should parse duration strings correctly', t => {
const testcases = [
'1ms',
'1s',
'1m',
'1h',
'1d',
'1w',
'1M',
'1y',
'1000ms',
'60s',
'30m',
'1h30m',
'15d',
'1y',
'12M',
'1y1M1d1h1m1s1ms',
];
for (const str of testcases) {
t.snapshot(JSON.stringify(Due.parse(str)), `parser - ${str}`);
t.snapshot(Due.ms(str), `ms - ${str}`);
}
});
test('should calc relative time correctly', t => {
const date = new Date();
t.is(Due.before('1d', date).getTime(), date.getTime() - 1000 * 60 * 60 * 24);
const date2 = new Date();
t.is(Due.after('1d', date2).getTime(), date2.getTime() + 1000 * 60 * 60 * 24);
});

View File

@@ -0,0 +1,75 @@
import test from 'ava';
import Sinon from 'sinon';
import {
exponentialBackoffDelay,
ExponentialBackoffScheduler,
} from '../promise';
test('exponentialBackoffDelay should cap exponential growth at maxDelayMs', t => {
t.is(exponentialBackoffDelay(0, { baseDelayMs: 100, maxDelayMs: 500 }), 100);
t.is(exponentialBackoffDelay(1, { baseDelayMs: 100, maxDelayMs: 500 }), 200);
t.is(exponentialBackoffDelay(3, { baseDelayMs: 100, maxDelayMs: 500 }), 500);
});
test('ExponentialBackoffScheduler should track pending callback and increase delay per attempt', async t => {
const clock = Sinon.useFakeTimers();
t.teardown(() => {
clock.restore();
});
const calls: number[] = [];
const scheduler = new ExponentialBackoffScheduler({
baseDelayMs: 100,
maxDelayMs: 500,
});
t.is(
scheduler.schedule(() => {
calls.push(1);
}),
100
);
t.true(scheduler.pending);
t.is(
scheduler.schedule(() => {
calls.push(2);
}),
null
);
await clock.tickAsync(100);
t.deepEqual(calls, [1]);
t.false(scheduler.pending);
t.is(
scheduler.schedule(() => {
calls.push(3);
}),
200
);
await clock.tickAsync(200);
t.deepEqual(calls, [1, 3]);
});
test('ExponentialBackoffScheduler reset should clear pending work and restart from the base delay', t => {
const scheduler = new ExponentialBackoffScheduler({
baseDelayMs: 100,
maxDelayMs: 500,
});
t.is(
scheduler.schedule(() => {}),
100
);
t.true(scheduler.pending);
scheduler.reset();
t.false(scheduler.pending);
t.is(
scheduler.schedule(() => {}),
100
);
scheduler.clear();
});

View File

@@ -0,0 +1,33 @@
import test from 'ava';
import { Request } from 'express';
import { getClientVersionFromRequest } from '../request';
test('should get client version from x-affine-version header', t => {
const req = {
headers: {
'x-affine-version': '0.22.2',
},
} as unknown as Request;
t.is(getClientVersionFromRequest(req), '0.22.2');
const req2 = {
headers: {
'x-affine-version': ['0.22.2', '0.23.0-beta.2'],
},
} as unknown as Request;
t.is(getClientVersionFromRequest(req2), '0.22.2');
});
test('should not get client version from x-affine-version header', t => {
const req = {
headers: {
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.205 Safari/537.36',
},
} as unknown as Request;
t.is(getClientVersionFromRequest(req), undefined);
});

View File

@@ -0,0 +1,116 @@
import semver from 'semver';
const DAY_MS = 24 * 60 * 60 * 1000;
// Example: 2026.2.6-canary.015
const CANARY_DATE_VERSION_RE =
/^v?(\d{4})\.(\d{1,2})\.(\d{1,2})-canary\.(\d+)(?:\+.*)?$/i;
export const CANARY_CLIENT_VERSION_MAX_AGE_DAYS = 62; // ~2 months
export const CANARY_CLIENT_VERSION_MAX_FUTURE_SKEW_DAYS = 2;
export type CanaryDateClientVersion = {
raw: string;
normalized: string;
dateMs: number;
};
export function parseCanaryDateClientVersion(
version: string
): CanaryDateClientVersion | null {
const raw = version.trim();
const match = CANARY_DATE_VERSION_RE.exec(raw);
if (!match) {
return null;
}
const year = Number.parseInt(match[1], 10);
const month = Number.parseInt(match[2], 10);
const day = Number.parseInt(match[3], 10);
const build = match[4].replace(/^0+(?=\d)/, '');
if (
!Number.isInteger(year) ||
!Number.isInteger(month) ||
!Number.isInteger(day) ||
year < 0 ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return null;
}
const dateMs = Date.UTC(year, month - 1, day);
const date = new Date(dateMs);
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return {
raw,
normalized: `${year}.${month}.${day}-canary.${build}`,
dateMs,
};
}
export type CanaryClientVersionCheckResult =
| { matched: false }
| { matched: true; allowed: boolean; normalized: string };
export function checkCanaryDateClientVersion(
version: string,
options?: {
nowMs?: number;
maxAgeDays?: number;
maxFutureSkewDays?: number;
}
): CanaryClientVersionCheckResult {
const parsed = parseCanaryDateClientVersion(version);
if (!parsed) {
return { matched: false };
}
const nowMs = options?.nowMs ?? Date.now();
const maxAgeDays = options?.maxAgeDays ?? CANARY_CLIENT_VERSION_MAX_AGE_DAYS;
const maxFutureSkewDays =
options?.maxFutureSkewDays ?? CANARY_CLIENT_VERSION_MAX_FUTURE_SKEW_DAYS;
const ageMs = nowMs - parsed.dateMs;
const maxAgeMs = maxAgeDays * DAY_MS;
const maxFutureSkewMs = maxFutureSkewDays * DAY_MS;
return {
matched: true,
allowed: ageMs <= maxAgeMs && ageMs >= -maxFutureSkewMs,
normalized: parsed.normalized,
};
}
function normalizeComparableVersion(version: string): string | null {
const canary = parseCanaryDateClientVersion(version);
return semver.valid(canary?.normalized ?? version.trim(), {
loose: true,
});
}
export function hasNewerVersion(
currentVersion: string,
nextVersion: string
): boolean {
const current = normalizeComparableVersion(currentVersion);
const next = normalizeComparableVersion(nextVersion);
if (!current || !next) {
return currentVersion.trim() !== nextVersion.trim();
}
return semver.gt(next, current, {
loose: true,
});
}

View File

@@ -0,0 +1,78 @@
type DurationUnit = 'd' | 'w' | 'M' | 'y' | 'h' | 'm' | 's' | 'ms';
type DurationInput = Partial<Record<DurationUnit, number>>;
const UnitToSecMap: Record<DurationUnit, number> = {
ms: 0.001,
s: 1,
m: 60,
h: 3600,
d: 24 * 3600,
w: 7 * 24 * 3600,
M: 30 * 24 * 3600,
y: 365 * 24 * 3600,
};
const KnownCharCodeToCharMap: Record<number, DurationUnit> = {
100: 'd',
119: 'w',
77: 'M',
121: 'y',
104: 'h',
109: 'm',
115: 's',
};
function parse(str: string): DurationInput {
let input: DurationInput = {};
let acc = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
const code = ch.charCodeAt(0);
// number [0..9]
if (code >= 48 && code <= 57) {
acc = acc * 10 + code - 48;
} else {
let unit = KnownCharCodeToCharMap[code];
if (!unit) {
throw new Error(`Invalid duration string unit ${ch}`);
}
// look ahead a char for 'ms' checking if unit met 'm'
if (unit === 'm' && str[i + 1] === 's') {
unit = 'ms';
i++;
}
input[unit] = acc;
acc = 0;
}
}
return input;
}
export const Due = {
ms: (dueStr: string | DurationInput) => {
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
return Object.entries(input).reduce((duration, [unit, val]) => {
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0) * 1000;
}, 0);
},
s: (dueStr: string | DurationInput) => {
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
return Object.entries(input).reduce((duration, [unit, val]) => {
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0);
}, 0);
},
parse,
after: (dueStr: string | number | DurationInput, date?: Date) => {
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
return new Date((date?.getTime() ?? Date.now()) + timestamp);
},
before: (dueStr: string | number | DurationInput, date?: Date) => {
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
return new Date((date?.getTime() ?? Date.now()) - timestamp);
},
};

View File

@@ -0,0 +1,9 @@
export * from './client-version';
export * from './duration';
export * from './promise';
export * from './request';
export * from './request-tracker';
export * from './ssrf';
export * from './stream';
export * from './types';
export * from './unit';

View File

@@ -0,0 +1,112 @@
import { setTimeout as delay } from 'node:timers/promises';
import { defer as rxjsDefer, retry } from 'rxjs';
export class RetryablePromise<T> extends Promise<T> {
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void
) => void,
retryTimes: number = 3,
retryIntervalInMs: number = 300
) {
super((resolve, reject) => {
rxjsDefer(() => new Promise<T>(executor))
.pipe(
retry({
count: retryTimes,
delay: retryIntervalInMs,
})
)
.subscribe({
next: v => {
resolve(v);
},
error: e => {
reject(e);
},
});
});
}
}
export function retryable<Ret = unknown>(
asyncFn: () => Promise<Ret>,
retryTimes = 3,
retryIntervalInMs = 300
): Promise<Ret> {
return new RetryablePromise<Ret>(
(resolve, reject) => {
asyncFn().then(resolve).catch(reject);
},
retryTimes,
retryIntervalInMs
);
}
export function defer(dispose: () => Promise<void>) {
return {
[Symbol.asyncDispose]: dispose,
};
}
export function sleep(ms: number): Promise<void> {
return delay(ms);
}
export function exponentialBackoffDelay(
attempt: number,
{
baseDelayMs,
maxDelayMs,
factor = 2,
}: { baseDelayMs: number; maxDelayMs: number; factor?: number }
): number {
return Math.min(
baseDelayMs * Math.pow(factor, Math.max(0, attempt)),
maxDelayMs
);
}
export class ExponentialBackoffScheduler {
#attempt = 0;
#timer: ReturnType<typeof globalThis.setTimeout> | null = null;
constructor(
private readonly options: {
baseDelayMs: number;
maxDelayMs: number;
factor?: number;
}
) {}
get pending() {
return this.#timer !== null;
}
clear() {
if (this.#timer) {
clearTimeout(this.#timer);
this.#timer = null;
}
}
reset() {
this.#attempt = 0;
this.clear();
}
schedule(callback: () => void) {
if (this.#timer) return null;
const timeout = exponentialBackoffDelay(this.#attempt, this.options);
this.#timer = globalThis.setTimeout(() => {
this.#timer = null;
callback();
}, timeout);
this.#attempt += 1;
return timeout;
}
}

View File

@@ -0,0 +1,47 @@
import type { Request } from 'express';
function firstForwardedForIp(value?: string) {
if (!value) {
return;
}
const [first] = value.split(',', 1);
const ip = first?.trim();
return ip || undefined;
}
function firstNonEmpty(...values: Array<string | undefined>) {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
}
return;
}
export function getRequestClientIp(req: Request) {
return (
firstNonEmpty(
req.get('CF-Connecting-IP'),
firstForwardedForIp(req.get('X-Forwarded-For')),
req.get('X-Real-IP'),
req.ip
) ?? ''
);
}
export function getRequestTrackerId(req: Request) {
return (
req.session?.sessionId ??
firstNonEmpty(
req.get('CF-Connecting-IP'),
firstForwardedForIp(req.get('X-Forwarded-For')),
req.get('X-Real-IP'),
req.get('CF-Ray'),
req.ip
) ??
''
);
}

View File

@@ -0,0 +1,172 @@
import { randomUUID } from 'node:crypto';
import { IncomingMessage } from 'node:http';
import type { ArgumentsHost, ExecutionContext } from '@nestjs/common';
import type { GqlContextType } from '@nestjs/graphql';
import { GqlArgumentsHost } from '@nestjs/graphql';
import type { Request, Response } from 'express';
import { ClsServiceManager } from 'nestjs-cls';
import type { Socket } from 'socket.io';
import { z } from 'zod';
type RequestResponse = {
req: Request;
res?: Response;
};
const RequestCookieValueSchema = z.string().min(1);
const RequestHeaderValueSchema = z.string().min(1);
export function getRequestResponseFromHost(
host: ArgumentsHost
): RequestResponse {
switch (host.getType<GqlContextType>()) {
case 'graphql': {
const gqlContext = GqlArgumentsHost.create(host).getContext<{
req: Request;
}>();
return {
req: gqlContext.req,
res: gqlContext.req.res,
};
}
case 'http': {
const http = host.switchToHttp();
return {
req: http.getRequest<Request>(),
res: http.getResponse<Response>(),
};
}
case 'ws': {
const ws = host.switchToWs();
const req = ws.getClient<Socket>().request as Request;
parseCookies(req);
return { req };
}
case 'rpc': {
const rpc = host.switchToRpc();
const { req } = rpc.getContext<{ req: Request }>();
return {
req,
res: req.res,
};
}
}
}
export function getRequestFromHost(host: ArgumentsHost): Request {
return getRequestResponseFromHost(host).req;
}
export function getRequestResponseFromContext(
ctx: ExecutionContext
): RequestResponse {
return getRequestResponseFromHost(ctx);
}
/**
* simple patch for request not protected by `cookie-parser`
* only take effect if `req.cookies` is not defined
*/
export function parseCookies(
req: IncomingMessage & { cookies?: Record<string, string> }
) {
if (req.cookies) return;
const cookieStr = req.headers.cookie ?? '';
req.cookies = cookieStr.split(';').reduce(
(cookies, cookie) => {
const [key, val] = cookie.split('=');
if (key) {
const rawKey = key.trim();
const rawVal = val ? val.trim() : val;
let safeKey = rawKey;
let safeVal = rawVal;
try {
safeKey = decodeURIComponent(rawKey);
} catch {}
if (rawVal) {
try {
safeVal = decodeURIComponent(rawVal);
} catch {}
}
cookies[safeKey] = safeVal;
}
return cookies;
},
{} as Record<string, string>
);
}
export function getRequestCookie(
req: IncomingMessage & { cookies?: Record<string, unknown> },
name: string
) {
parseCookies(req as IncomingMessage & { cookies?: Record<string, string> });
const value = req.cookies?.[name];
const parsed = RequestCookieValueSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
export function getRequestHeader(req: IncomingMessage, name: string) {
const value = req.headers[name.toLowerCase()];
const parsed = RequestHeaderValueSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
/**
* Request type
*
* @description
* - `graphql`: graphql request
* - `http`: http request
* - `ws`: websocket request
* - `event`: event
* - `job`: cron job
* - `rpc`: rpc request
*/
export type RequestType = GqlContextType | 'event' | 'job';
export function genRequestId(type: RequestType) {
return `${env.DEPLOYMENT_TYPE}:${type}:${randomUUID()}`;
}
export function getOrGenRequestId(type: RequestType) {
// The request id must exist in a cls context,
// but it can be lost in unexpected scenarios, such as unit tests, where it is automatically generated.
return ClsServiceManager.getClsService()?.getId() ?? genRequestId(type);
}
export function getRequestIdFromRequest(req: Request, type: RequestType) {
const traceContext = req.headers['x-cloud-trace-context'] as string;
const traceId = traceContext ? traceContext.split('/', 1)[0] : undefined;
if (traceId) return traceId;
return genRequestId(type);
}
export function getRequestIdFromHost(host: ArgumentsHost) {
const type = host.getType<GqlContextType>();
if (type === 'ws') {
return genRequestId(type);
}
const req = getRequestFromHost(host);
return getRequestIdFromRequest(req, type);
}
export function getClientVersionFromRequest(req: Request) {
let version = req.headers['x-affine-version'];
if (Array.isArray(version)) {
version = version[0];
}
return version;
}

View File

@@ -0,0 +1,153 @@
import {
assertSafeUrl as assertSafeUrlFromNative,
safeFetch as safeFetchFromNative,
type SafeFetchRequest,
} from '../../native';
import { ResponseTooLargeError, SsrfBlockedError } from '../error/errors.gen';
export type SSRFBlockReason =
| 'invalid_url'
| 'disallowed_protocol'
| 'url_has_credentials'
| 'blocked_hostname'
| 'host_not_allowed'
| 'unresolvable_hostname'
| 'blocked_ip'
| 'too_many_redirects';
const SSRF_REASONS = new Set<string>([
'invalid_url',
'disallowed_protocol',
'url_has_credentials',
'blocked_hostname',
'host_not_allowed',
'unresolvable_hostname',
'blocked_ip',
'too_many_redirects',
]);
function createSsrfBlockedError(reason: SSRFBlockReason) {
return new SsrfBlockedError({ reason });
}
function mapNativeFetchError(error: unknown, limitBytes?: number) {
const message = error instanceof Error ? error.message : String(error);
const reason = [...SSRF_REASONS].find(reason => message.includes(reason));
if (reason) {
return createSsrfBlockedError(reason as SSRFBlockReason);
}
if (message.includes('response_too_large')) {
return new ResponseTooLargeError({
limitBytes: limitBytes ?? 0,
receivedBytes: limitBytes ? limitBytes + 1 : 0,
});
}
return error;
}
export interface SafeFetchOptions {
timeoutMs?: number;
maxRedirects?: number;
maxBytes?: number;
allowedHeaders?: string[];
allowedHosts?: string[];
allowHttp?: boolean;
allowPrivateTargetOrigin?: boolean;
enableEch?: boolean;
echConfigList?: Buffer;
}
export async function assertSsrFSafeUrl(rawUrl: string | URL): Promise<URL> {
let url: URL;
try {
url = rawUrl instanceof URL ? rawUrl : new URL(rawUrl);
} catch {
throw createSsrfBlockedError('invalid_url');
}
try {
assertSafeUrlFromNative({ url: url.toString() });
return url;
} catch (error) {
throw mapNativeFetchError(error);
}
}
export async function safeFetch(
rawUrl: string | URL,
init: RequestInit = {},
options: SafeFetchOptions = {}
): Promise<Response> {
const url = rawUrl.toString();
const method = String(init.method ?? 'GET').toUpperCase();
if (!['GET', 'HEAD', 'POST', 'PUT', 'PROPFIND', 'REPORT'].includes(method)) {
throw new Error(`Unsupported safeFetch method: ${method}`);
}
try {
const response = await safeFetchFromNative({
url,
method: method.toLowerCase() as NonNullable<SafeFetchRequest['method']>,
headers: normalizeHeaders(init.headers),
body: normalizeBody(init.body),
timeoutMs: options.timeoutMs,
maxRedirects: options.maxRedirects,
maxBytes: options.maxBytes,
allowedHeaders: options.allowedHeaders,
allowedHosts: options.allowedHosts,
allowHttp: options.allowHttp,
allowPrivateTargetOrigin: options.allowPrivateTargetOrigin,
enableEch: options.enableEch,
echConfigList: options.echConfigList,
});
const body =
method === 'HEAD' || [204, 205, 304].includes(response.status)
? null
: response.body;
const webResponse = new Response(body, {
status: response.status,
headers: response.headers,
});
Object.defineProperty(webResponse, 'url', {
value: response.finalUrl,
});
return webResponse;
} catch (error) {
throw mapNativeFetchError(error, options.maxBytes);
}
}
function normalizeHeaders(headers: RequestInit['headers'] | undefined) {
if (!headers) return undefined;
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, String(value)])
);
}
function normalizeBody(body: RequestInit['body'] | null | undefined) {
if (body === null || body === undefined) {
return undefined;
}
if (typeof body === 'string') {
return Buffer.from(body);
}
if (body instanceof ArrayBuffer) {
return Buffer.from(body);
}
if (ArrayBuffer.isView(body)) {
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
}
throw new Error('Unsupported safeFetch body type.');
}
export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
const copy = new Uint8Array(buffer.byteLength);
copy.set(buffer);
return copy.buffer;
}

View File

@@ -0,0 +1,71 @@
import { Readable } from 'node:stream';
import { BlobQuotaExceeded, StorageQuotaExceeded } from '../error';
import { OneKB } from './unit';
export type CheckExceededResult =
| {
storageQuotaExceeded: boolean;
blobQuotaExceeded: boolean;
}
| undefined;
export async function readBuffer(
readable: Readable,
checkExceeded: (recvSize: number) => CheckExceededResult
): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const chunks: Uint8Array[] = [];
let totalSize = 0;
let result: CheckExceededResult;
readable.on('data', chunk => {
totalSize += chunk.length;
// check size after receive each chunk to avoid unnecessary memory usage
result = checkExceeded(totalSize);
if (result?.blobQuotaExceeded) {
reject(new BlobQuotaExceeded());
} else if (result?.storageQuotaExceeded) {
reject(new StorageQuotaExceeded());
}
if (checkExceeded(totalSize)) {
reject(new BlobQuotaExceeded());
readable.destroy(new BlobQuotaExceeded());
return;
}
chunks.push(chunk);
});
readable.on('error', reject);
readable.on('end', () => {
const buffer = Buffer.concat(chunks, totalSize);
if (checkExceeded(buffer.length)) {
reject(new BlobQuotaExceeded());
} else {
resolve(buffer);
}
});
});
}
export async function readBufferWithLimit(
readable: Readable,
limit: number = 500 * OneKB
): Promise<Buffer> {
return readBuffer(readable, size =>
size > limit
? { blobQuotaExceeded: true, storageQuotaExceeded: false }
: undefined
);
}
export async function readableToBuffer(readable: Readable) {
const chunks: Buffer[] = [];
for await (const chunk of readable) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}

View File

@@ -0,0 +1,61 @@
import { Readable } from 'node:stream';
export function ApplyType<T>(): ConstructorOf<T> {
// @ts-expect-error used to fake the type of config
return class Inner implements T {};
}
export type PathType<T, Path extends string> =
T extends Record<string, any>
? string extends Path
? unknown
: Path extends keyof T
? T[Path]
: Path extends `${infer K}.${infer R}`
? K extends keyof T
? PathType<T[K], R>
: unknown
: unknown
: unknown;
export type Join<Prefix, Suffixes> = Prefix extends string | number
? Suffixes extends string | number
? Prefix extends ''
? Suffixes
: `${Prefix}.${Suffixes}`
: never
: never;
export type LeafPaths<
T,
Prefix extends string = '',
MaxDepth extends string = '.....',
Depth extends string = '',
> = Depth extends MaxDepth
? never
: T extends Record<string | number, any>
? {
[K in keyof T]-?: K extends string | number
? T[K] extends PrimitiveType
? K
: T[K] extends { __leaf: true }
? K
: Join<K, LeafPaths<T[K], Prefix, MaxDepth, `${Depth}.`>>
: never;
}[keyof T]
: never;
export type LeafVisitor<T, P extends string = ''> = {
[K in keyof T]: T[K] extends object
? LeafVisitor<T[K], Join<P, K>>
: P extends ''
? K
: Join<P, K>;
};
export interface FileUpload {
filename: string;
mimetype: string;
encoding: string;
createReadStream: () => Readable;
}

View File

@@ -0,0 +1,9 @@
export const OneKB = 1024;
export const OneMB = OneKB * OneKB;
export const OneGB = OneKB * OneMB;
export const OneMinute = 1000 * 60;
export const OneHour = OneMinute * 60;
export const OneDay = OneHour * 24;
export const OneWeek = OneDay * 7;
export const OneMonth = OneDay * 30;
export const OneYear = OneDay * 365;

View File

@@ -0,0 +1,89 @@
import { INestApplication } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Server, Socket } from 'socket.io';
import { Config } from '../config';
import {
buildCorsAllowedOrigins,
CORS_ALLOWED_HEADERS,
CORS_ALLOWED_METHODS,
corsOriginCallback,
} from '../cors';
import { AuthenticationRequired } from '../error';
import { URLHelper } from '../helpers';
import { AFFiNELogger } from '../logger';
import { SocketIoRedis } from '../redis';
import { WEBSOCKET_OPTIONS } from './options';
export class SocketIoAdapter extends IoAdapter {
constructor(private readonly app: INestApplication) {
super(app);
}
override createIOServer(port: number, options?: any): Server {
const logger = this.app.get(AFFiNELogger);
const config = this.app.get(WEBSOCKET_OPTIONS) as Config['websocket'] & {
canActivate: (socket: Socket) => Promise<boolean>;
};
const url = this.app.get(URLHelper);
const allowedOrigins = buildCorsAllowedOrigins(url);
const server: Server = super.createIOServer(port, {
...config,
...options,
cors: {
origin: (
origin: string | undefined,
callback: (error: Error | null, allow?: boolean) => void
) => {
corsOriginCallback(
origin,
allowedOrigins,
blockedOrigin =>
logger.warn(
`Blocked WebSocket CORS request from origin: ${blockedOrigin}`
),
callback
);
},
credentials: true,
methods: CORS_ALLOWED_METHODS,
allowedHeaders: CORS_ALLOWED_HEADERS,
},
});
if (config.canActivate) {
server.use((socket, next) => {
config
.canActivate(socket)
.then(pass => {
if (pass) {
next();
} else {
throw new AuthenticationRequired();
}
})
.catch(e => {
next(e);
});
});
}
const pubClient = this.app.get(SocketIoRedis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
const close = server.close;
server.close = async fn => {
await close.call(server, fn);
// NOTE(@forehalo):
// the lifecycle of duplicated redis client will not be controlled by nestjs lifecycle
// we've got to manually disconnect it
subClient.disconnect();
};
return server;
}
}

View File

@@ -0,0 +1,34 @@
import { GatewayMetadata } from '@nestjs/websockets';
import { z } from 'zod';
import { defineModuleConfig } from '../config';
declare global {
interface AppConfigSchema {
websocket: {
transports: ConfigItem<GatewayMetadata['transports']>;
maxHttpBufferSize: number;
};
}
}
defineModuleConfig('websocket', {
transports: {
desc: 'The enabled transports for accepting websocket traffics.',
default: ['websocket', 'polling'],
shape: z.array(z.enum(['websocket', 'polling'])),
schema: {
type: 'array',
items: {
type: 'string',
enum: ['websocket', 'polling'],
},
},
link: 'https://docs.nestjs.com/websockets/gateways#transports',
},
maxHttpBufferSize: {
desc: 'How many bytes or characters a message can be, before closing the session (to avoid DoS).',
default: 1e8, // 100 MB
shape: z.number().int().positive(),
},
});

View File

@@ -0,0 +1,14 @@
import './config';
import { Module } from '@nestjs/common';
import { WEBSOCKET_OPTIONS, websocketOptionsProvider } from './options';
@Module({
providers: [websocketOptionsProvider],
exports: [websocketOptionsProvider],
})
export class WebSocketModule {}
export { WEBSOCKET_OPTIONS };
export { SocketIoAdapter } from './adapter';

View File

@@ -0,0 +1,13 @@
import { FactoryProvider } from '@nestjs/common';
import { Config } from '../config';
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
export const websocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config) => {
return config.websocket;
},
inject: [Config],
};