refactor(server): config system (#11081)

This commit is contained in:
forehalo
2025-03-27 12:32:28 +00:00
parent 7091111f85
commit 0ea38680fa
274 changed files with 7583 additions and 5841 deletions
+42 -73
View File
@@ -1,100 +1,69 @@
import {
defineRuntimeConfig,
defineStartupConfig,
ModuleConfig,
} from '../../base/config';
import { z } from 'zod';
export interface AuthStartupConfigurations {
/**
* auth session config
*/
import { defineModuleConfig } from '../../base';
export interface AuthConfig {
session: {
/**
* Application auth expiration time in seconds
*/
ttl: number;
/**
* Application auth time to refresh in seconds
*/
ttr: number;
};
/**
* Application access token config
*/
accessToken: {
/**
* Application access token expiration time in seconds
*/
ttl: number;
/**
* Application refresh token expiration time in seconds
*/
refreshTokenTtl: number;
};
}
export interface AuthRuntimeConfigurations {
/**
* Whether allow anonymous users to sign up
*/
allowSignup: boolean;
/**
* Whether require email domain record verification before access restricted resources
*/
requireEmailDomainVerification: boolean;
/**
* Whether require email verification before access restricted resources
*/
requireEmailVerification: boolean;
/**
* The minimum and maximum length of the password when registering new users
*/
password: {
passwordRequirements: ConfigItem<{
min: number;
max: number;
};
}>;
}
declare module '../../base/config' {
interface AppConfig {
auth: ModuleConfig<AuthStartupConfigurations, AuthRuntimeConfigurations>;
declare global {
interface AppConfigSchema {
auth: AuthConfig;
}
}
defineStartupConfig('auth', {
session: {
ttl: 60 * 60 * 24 * 15, // 15 days
ttr: 60 * 60 * 24 * 7, // 7 days
},
accessToken: {
ttl: 60 * 60 * 24 * 7, // 7 days
refreshTokenTtl: 60 * 60 * 24 * 30, // 30 days
},
});
defineRuntimeConfig('auth', {
defineModuleConfig('auth', {
allowSignup: {
desc: 'Whether allow new registrations',
desc: 'Whether allow new registrations.',
default: true,
},
requireEmailDomainVerification: {
desc: 'Whether require email domain record verification before accessing restricted resources',
desc: 'Whether require email domain record verification before accessing restricted resources.',
default: false,
},
requireEmailVerification: {
desc: 'Whether require email verification before accessing restricted resources',
desc: 'Whether require email verification before accessing restricted resources(not implemented).',
default: true,
},
'password.min': {
desc: 'The minimum length of user password',
default: 8,
passwordRequirements: {
desc: 'The password strength requirements when set new password.',
default: {
min: 8,
max: 32,
},
shape: z
.object({
min: z.number().min(1),
max: z.number().max(100),
})
.strict()
.refine(data => data.min < data.max, {
message: 'Minimum length of password must be less than maximum length',
}),
schema: {
type: 'object',
properties: {
min: { type: 'number' },
max: { type: 'number' },
},
},
},
'password.max': {
desc: 'The maximum length of user password',
default: 32,
'session.ttl': {
desc: 'Application auth expiration time in seconds.',
default: 60 * 60 * 24 * 15, // 15 days
},
'session.ttr': {
desc: 'Application auth time to refresh in seconds.',
default: 60 * 60 * 24 * 7, // 7 days
},
});
@@ -23,7 +23,6 @@ import {
InvalidAuthState,
InvalidEmail,
InvalidEmailToken,
Runtime,
SignUpForbidden,
Throttle,
URLHelper,
@@ -66,11 +65,10 @@ export class AuthController {
private readonly auth: AuthService,
private readonly models: Models,
private readonly config: Config,
private readonly runtime: Runtime,
private readonly cache: Cache,
private readonly crypto: CryptoHelper
) {
if (config.node.dev) {
if (env.dev) {
// set DNS servers in dev mode
// NOTE: some network debugging software uses DNS hijacking
// to better debug traffic, but their DNS servers may not
@@ -93,7 +91,7 @@ export class AuthController {
const user = await this.models.user.getUserByEmail(params.email);
const magicLinkAvailable = !!this.config.mailer.host;
const magicLinkAvailable = this.config.mailer.enabled;
if (!user) {
return {
@@ -171,15 +169,11 @@ export class AuthController {
// send email magic link
const user = await this.models.user.getUserByEmail(email);
if (!user) {
const allowSignup = await this.runtime.fetch('auth/allowSignup');
if (!allowSignup) {
if (!this.config.auth.allowSignup) {
throw new SignUpForbidden();
}
const requireEmailDomainVerification = await this.runtime.fetch(
'auth/requireEmailDomainVerification'
);
if (requireEmailDomainVerification) {
if (this.config.auth.requireEmailDomainVerification) {
// verify domain has MX, SPF, DMARC records
const [name, domain, ...rest] = email.split('@');
if (rest.length || !domain) {
@@ -229,7 +223,7 @@ export class AuthController {
}
: {}),
});
if (this.config.node.dev) {
if (env.dev) {
// make it easier to test in dev mode
this.logger.debug(`Magic link: ${magicLink}`);
}
@@ -49,7 +49,7 @@ export class AuthService implements OnApplicationBootstrap {
) {}
async onApplicationBootstrap() {
if (this.config.node.dev) {
if (env.dev) {
await createDevUsers(this.models);
}
}
@@ -59,10 +59,12 @@ export class AuthService implements OnApplicationBootstrap {
}
/**
* @deprecated
*
* This is a test only helper to quickly signup a user, do not use in production
*/
async signUp(email: string, password: string): Promise<CurrentUser> {
if (!this.config.node.test) {
if (!env.testing) {
throw new SignUpForbidden(
'sign up helper is forbidden for non-test environment'
);
@@ -0,0 +1,136 @@
import { faker } from '@faker-js/faker';
import test from 'ava';
import Sinon from 'sinon';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Models } from '../../../models';
import { ServerService } from '../service';
const module = await createModule({
providers: [ServerService],
});
const service = module.get(ServerService);
const user = await module.create(Mockers.User);
const models = module.get(Models);
test.afterEach(async () => {
Sinon.reset();
});
test.after.always(async () => {
await module.close();
});
test('should update config', async t => {
const oldValue = service.config.server.externalUrl;
const newValue = faker.internet.url();
await service.updateConfig(user.id, [
{
module: 'server',
key: 'externalUrl',
value: newValue,
},
]);
t.not(service.config.server.externalUrl, oldValue);
t.is(service.config.server.externalUrl, newValue);
});
test('should validate config before update', async t => {
await t.throwsAsync(
service.updateConfig(user.id, [
{
module: 'server',
key: 'externalUrl',
value: 'invalid-url@some-domain.com',
},
]),
{
message: `Invalid config for module [server] with key [externalUrl]
Value: "invalid-url@some-domain.com"
Error: Invalid url`,
}
);
t.not(service.config.server.externalUrl, 'invalid-url');
await t.throwsAsync(
service.updateConfig(user.id, [
{
module: 'auth',
key: 'unknown-key',
value: 'invalid-value',
},
]),
{
message: `Invalid config for module [auth] with unknown key [unknown-key]`,
}
);
// @ts-expect-error allow
t.is(service.config.auth['unknown-key'], undefined);
});
test('should emit config.init event', async t => {
await service.onApplicationBootstrap();
const event = module.event.last('config.init');
t.is(event.name, 'config.init');
t.deepEqual(event.payload, {
config: service.config,
});
});
test('should revalidate config', async t => {
const outdatedValue = service.config.server.externalUrl;
const newValue = faker.internet.url();
await models.appConfig.save(user.id, [
{
key: 'server.externalUrl',
value: newValue,
},
]);
await service.revalidateConfig();
t.not(service.config.server.externalUrl, outdatedValue);
t.is(service.config.server.externalUrl, newValue);
});
test('should emit config changed event', async t => {
const newUrl = faker.internet.url();
await service.updateConfig(user.id, [
{
module: 'server',
key: 'externalUrl',
value: newUrl,
},
{
module: 'auth',
key: 'allowSignup',
value: false,
},
]);
const updates = {
server: {
externalUrl: newUrl,
},
auth: {
allowSignup: false,
},
};
t.true(
module.event.emit.calledOnceWith('config.changed', {
updates,
})
);
t.true(
module.event.broadcast.calledOnceWith('config.changed.broadcast', {
updates,
})
);
});
@@ -1,23 +1,64 @@
import { defineRuntimeConfig, ModuleConfig } from '../../base/config';
import { z } from 'zod';
import { defineModuleConfig } from '../../base';
export interface ServerFlags {
earlyAccessControl: boolean;
syncClientVersionCheck: boolean;
}
declare module '../../base/config' {
interface AppConfig {
flags: ModuleConfig<never, ServerFlags>;
declare global {
interface AppConfigSchema {
server: {
externalUrl: string;
https: boolean;
host: string;
port: number;
path: string;
name: string | undefined;
};
flags: ServerFlags;
}
}
defineRuntimeConfig('flags', {
defineModuleConfig('server', {
name: {
desc: 'A recognizable name for the server. Will be shown when connected with AFFiNE Desktop.',
default: 'AFFiNE Cloud',
},
externalUrl: {
desc: `Base url of AFFiNE server, used for generating external urls.
Default to be \`[server.protocol]://[server.host][:server.port]\` if not specified.
`,
default: 'http://localhost:3010',
env: 'AFFINE_SERVER_EXTERNAL_URL',
shape: z.string().url(),
},
https: {
desc: 'Whether the server is hosted on a ssl enabled domain (https://).',
default: false,
env: ['AFFINE_SERVER_HTTPS', 'boolean'],
shape: z.boolean(),
},
host: {
desc: 'Where the server get deployed(FQDN).',
default: 'localhost',
env: 'AFFINE_SERVER_HOST',
},
port: {
desc: 'Which port the server will listen on.',
default: 3010,
env: ['AFFINE_SERVER_PORT', 'integer'],
},
path: {
desc: 'Subpath where the server get deployed if there is.',
default: '',
env: 'AFFINE_SERVER_SUB_PATH',
},
});
defineModuleConfig('flags', {
earlyAccessControl: {
desc: 'Only allow users with early access features to access the app',
default: false,
},
syncClientVersionCheck: {
desc: 'Only allow client with exact the same version with server to establish sync connections',
default: false,
},
});
@@ -3,24 +3,25 @@ import './config';
import { Module } from '@nestjs/common';
import {
AppConfigResolver,
ServerConfigResolver,
ServerFeatureConfigResolver,
ServerRuntimeConfigResolver,
ServerServiceConfigResolver,
} from './resolver';
import { ServerService } from './service';
@Module({
providers: [
ServerService,
ServerConfigResolver,
ServerFeatureConfigResolver,
ServerRuntimeConfigResolver,
ServerServiceConfigResolver,
],
providers: [ServerService],
exports: [ServerService],
})
export class ServerConfigModule {}
@Module({
imports: [ServerConfigModule],
providers: [
ServerConfigResolver,
ServerFeatureConfigResolver,
AppConfigResolver,
],
})
export class ServerConfigResolverModule {}
export { ServerService };
export { ADD_ENABLED_FEATURES } from './server-feature';
export { ServerFeature } from './types';
@@ -3,23 +3,21 @@ import {
Args,
Field,
GraphQLISODateTime,
InputType,
Mutation,
ObjectType,
Query,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { RuntimeConfig, RuntimeConfigType } from '@prisma/client';
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { Config, Runtime, URLHelper } from '../../base';
import { Config, URLHelper } from '../../base';
import { Namespace } from '../../env';
import { Feature } from '../../models';
import { Public } from '../auth';
import { CurrentUser, Public } from '../auth';
import { Admin } from '../common';
import { AvailableUserFeatureConfig } from '../features';
import { ServerFlags } from './config';
import { ENABLED_FEATURES } from './server-feature';
import { ServerService } from './service';
import { ServerConfigType } from './types';
@@ -37,10 +35,6 @@ export class CredentialsRequirementType {
password!: PasswordLimitsType;
}
registerEnumType(RuntimeConfigType, {
name: 'RuntimeConfigType',
});
@ObjectType()
export class ReleaseVersionType {
@Field()
@@ -56,43 +50,11 @@ export class ReleaseVersionType {
changelog!: string;
}
const RELEASE_CHANNEL_MAP = new Map<Config['AFFINE_ENV'], string>([
['dev', 'canary'],
['beta', 'beta'],
['production', 'stable'],
const RELEASE_CHANNEL_MAP = new Map<Namespace, string>([
[Namespace.Dev, 'canary'],
[Namespace.Beta, 'beta'],
[Namespace.Production, 'stable'],
]);
@ObjectType()
export class ServerRuntimeConfigType implements Partial<RuntimeConfig> {
@Field()
id!: string;
@Field()
module!: string;
@Field()
key!: string;
@Field()
description!: string;
@Field(() => GraphQLJSON)
value!: any;
@Field(() => RuntimeConfigType)
type!: RuntimeConfigType;
@Field(() => GraphQLISODateTime)
updatedAt!: Date;
}
@ObjectType()
export class ServerFlagsType implements ServerFlags {
@Field()
earlyAccessControl!: boolean;
@Field()
syncClientVersionCheck!: boolean;
}
@Resolver(() => ServerConfigType)
export class ServerConfigResolver {
@@ -100,7 +62,6 @@ export class ServerConfigResolver {
constructor(
private readonly config: Config,
private readonly runtime: Runtime,
private readonly url: URLHelper,
private readonly server: ServerService
) {}
@@ -111,16 +72,19 @@ export class ServerConfigResolver {
})
serverConfig(): ServerConfigType {
return {
name: this.config.serverName,
version: this.config.version,
name:
this.config.server.name ??
(env.selfhosted
? 'AFFiNE Selfhosted Cloud'
: env.namespaces.canary
? 'AFFiNE Canary Cloud'
: env.namespaces.beta
? 'AFFiNE Beta Cloud'
: 'AFFiNE Cloud'),
version: env.version,
baseUrl: this.url.home,
type: this.config.type,
// BACKWARD COMPATIBILITY
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
// this field should be removed after frontend feature flags implemented
flavor: this.config.type,
features: Array.from(ENABLED_FEATURES),
enableTelemetry: this.config.metrics.telemetry.enabled,
type: env.DEPLOYMENT_TYPE,
features: this.server.features,
};
}
@@ -128,31 +92,14 @@ export class ServerConfigResolver {
description: 'credentials requirement',
})
async credentialsRequirement() {
const config = await this.runtime.fetchAll({
'auth/password.max': true,
'auth/password.min': true,
});
return {
password: {
minLength: config['auth/password.min'],
maxLength: config['auth/password.max'],
minLength: this.config.auth.passwordRequirements.min,
maxLength: this.config.auth.passwordRequirements.max,
},
};
}
@ResolveField(() => ServerFlagsType, {
description: 'server flags',
})
async flags(): Promise<ServerFlagsType> {
const records = await this.runtime.list('flags');
return records.reduce((flags, record) => {
flags[record.key as keyof ServerFlagsType] = record.value as any;
return flags;
}, {} as ServerFlagsType);
}
@ResolveField(() => Boolean, {
description: 'whether server has been initialized',
})
@@ -161,10 +108,15 @@ export class ServerConfigResolver {
}
@ResolveField(() => ReleaseVersionType, {
nullable: true,
description: 'fetch latest available upgradable release of server',
})
async availableUpgrade(): Promise<ReleaseVersionType | null> {
const channel = RELEASE_CHANNEL_MAP.get(this.config.AFFINE_ENV) ?? 'stable';
if (!env.selfhosted) {
return null;
}
const channel = RELEASE_CHANNEL_MAP.get(env.NAMESPACE) ?? 'stable';
const url = `https://affine.pro/api/worker/releases?channel=${channel}`;
try {
@@ -191,7 +143,7 @@ export class ServerConfigResolver {
}>;
const latest = releases.at(0);
if (!latest || latest.name === this.config.version) {
if (!latest || latest.name === env.version) {
return null;
}
@@ -218,124 +170,38 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
}
}
@ObjectType()
class ServerServiceConfig {
@InputType()
class UpdateAppConfigInput {
@Field()
name!: string;
module!: string;
@Field(() => GraphQLJSONObject)
config!: any;
}
@Field()
key!: string;
interface ServerServeConfig {
https: boolean;
host: string;
port: number;
externalUrl: string;
}
interface ServerMailerConfig {
host?: string | null;
port?: number | null;
secure?: boolean | null;
service?: string | null;
sender?: string | null;
}
interface ServerDatabaseConfig {
host: string;
port: number;
user?: string | null;
database: string;
@Field(() => GraphQLJSON)
value!: any;
}
@Admin()
@Resolver(() => ServerRuntimeConfigType)
export class ServerRuntimeConfigResolver {
constructor(private readonly runtime: Runtime) {}
@Resolver(() => GraphQLJSONObject)
export class AppConfigResolver {
constructor(private readonly service: ServerService) {}
@Query(() => [ServerRuntimeConfigType], {
description: 'get all server runtime configurable settings',
@Query(() => GraphQLJSONObject, {
description: 'get the whole app configuration',
})
serverRuntimeConfig(): Promise<ServerRuntimeConfigType[]> {
return this.runtime.list();
appConfig() {
return this.service.config;
}
@Mutation(() => ServerRuntimeConfigType, {
description: 'update server runtime configurable setting',
@Mutation(() => GraphQLJSONObject, {
description: 'update app configuration',
})
async updateRuntimeConfig(
@Args('id') id: string,
@Args({ type: () => GraphQLJSON, name: 'value' }) value: any
): Promise<ServerRuntimeConfigType> {
return await this.runtime.set(id as any, value);
}
@Mutation(() => [ServerRuntimeConfigType], {
description: 'update multiple server runtime configurable settings',
})
async updateRuntimeConfigs(
@Args({ type: () => GraphQLJSONObject, name: 'updates' }) updates: any
): Promise<ServerRuntimeConfigType[]> {
const keys = Object.keys(updates);
const results = await Promise.all(
keys.map(key => this.runtime.set(key as any, updates[key]))
);
return results;
}
}
@Admin()
@Resolver(() => ServerServiceConfig)
export class ServerServiceConfigResolver {
constructor(private readonly config: Config) {}
@Query(() => [ServerServiceConfig])
serverServiceConfigs() {
return [
{
name: 'server',
config: this.serve(),
},
{
name: 'mailer',
config: this.mail(),
},
{
name: 'database',
config: this.database(),
},
];
}
serve(): ServerServeConfig {
return this.config.server;
}
mail(): ServerMailerConfig {
const sender =
typeof this.config.mailer.from === 'string'
? this.config.mailer.from
: this.config.mailer.from?.address;
return {
host: this.config.mailer.host,
port: this.config.mailer.port,
secure: this.config.mailer.secure,
service: this.config.mailer.service,
sender,
};
}
database(): ServerDatabaseConfig {
const url = new URL(this.config.prisma.datasourceUrl);
return {
host: url.hostname,
port: Number(url.port),
user: url.username,
database: url.pathname.slice(1) ?? url.username,
};
async updateAppConfig(
@CurrentUser() me: CurrentUser,
@Args('updates', { type: () => [UpdateAppConfigInput] })
updates: UpdateAppConfigInput[]
): Promise<DeepPartial<AppConfig>> {
return await this.service.updateConfig(me.id, updates);
}
}
@@ -1,7 +0,0 @@
import { ServerFeature } from './types';
export const ENABLED_FEATURES: Set<ServerFeature> = new Set();
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
export { ServerFeature };
@@ -1,17 +1,120 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { set } from 'lodash-es';
import { ConfigFactory, EventBus, OnEvent } from '../../base';
import { Models } from '../../models';
import { ServerFeature } from './types';
declare global {
interface Events {
'config.init': {
config: DeepReadonly<AppConfig>;
};
'config.changed': {
updates: DeepPartial<AppConfig>;
};
'config.changed.broadcast': {
updates: DeepPartial<AppConfig>;
};
}
}
@Injectable()
export class ServerService {
export class ServerService implements OnApplicationBootstrap {
private _initialized: boolean | null = null;
constructor(private readonly db: PrismaClient) {}
readonly #features = new Set<ServerFeature>();
readonly #logger = new Logger(ServerService.name);
constructor(
private readonly models: Models,
private readonly configFactory: ConfigFactory,
private readonly event: EventBus
) {}
async onApplicationBootstrap() {
await this.setup();
}
get features() {
return Array.from(this.#features);
}
async initialized() {
if (!this._initialized) {
const userCount = await this.db.user.count();
const userCount = await this.models.user.count();
this._initialized = userCount > 0;
}
return this._initialized;
}
enableFeature(feature: ServerFeature) {
this.#features.add(feature);
}
disableFeature(feature: ServerFeature) {
this.#features.delete(feature);
}
get config() {
return this.configFactory.config;
}
async updateConfig(
user: string,
updates: Array<{ module: string; key: string; value: any }>
): Promise<DeepPartial<AppConfig>> {
this.configFactory.validate(updates);
const promises = await this.models.appConfig.save(
user,
updates.map(update => ({
key: `${update.module}.${update.key}`,
value: update.value,
}))
);
const overrides: DeepPartial<AppConfig> = {};
// only take successfully saved configs
promises.forEach(promise => {
if (promise.status === 'fulfilled') {
set(overrides, promise.value.id, promise.value.value);
} else {
this.#logger.error(`Failed to save app config`, promise.reason);
}
});
this.configFactory.override(overrides);
this.event.emit('config.changed', { updates: overrides });
this.event.broadcast('config.changed.broadcast', { updates: overrides });
return overrides;
}
@OnEvent('config.changed.broadcast')
onConfigChangedBroadcast(updates: DeepPartial<AppConfig>) {
this.configFactory.override(updates);
this.event.emit('config.changed', { updates });
}
async revalidateConfig() {
const overrides = await this.loadDbOverrides();
this.configFactory.override(overrides);
this.event.emit('config.changed', { updates: overrides });
}
private async setup() {
const overrides = await this.loadDbOverrides();
this.configFactory.override(overrides);
this.event.emit('config.init', { config: this.configFactory.config });
}
private async loadDbOverrides() {
const configs = await this.models.appConfig.load();
const overrides: DeepPartial<AppConfig> = {};
configs.forEach(config => {
set(overrides, config.id, config.value);
});
return overrides;
}
}
@@ -1,6 +1,6 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { DeploymentType } from '../../base';
import { DeploymentType } from '../../env';
export enum ServerFeature {
Captcha = 'captcha',
@@ -34,15 +34,6 @@ export class ServerConfigType {
@Field(() => DeploymentType, { description: 'server type' })
type!: DeploymentType;
/**
* @deprecated
*/
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
flavor!: string;
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
@Field({ description: 'enable telemetry' })
enableTelemetry!: boolean;
}
@@ -5,36 +5,23 @@ import ava, { TestFn } from 'ava';
import { Doc as YDoc } from 'yjs';
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { AppModule } from '../../../app.module';
import { Config } from '../../../base';
import { ConfigModule } from '../../../base/config';
import { ConfigFactory } from '../../../base';
import { Flavor } from '../../../env';
import { Models } from '../../../models';
import { PgWorkspaceDocStorageAdapter } from '../../doc';
const test = ava as TestFn<{
models: Models;
app: TestingApp;
config: Config;
adapter: PgWorkspaceDocStorageAdapter;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [
ConfigModule.forRoot({
flavor: {
doc: false,
},
docService: {
endpoint: '',
},
}),
AppModule,
],
});
// @ts-expect-error testing
env.FLAVOR = Flavor.Renderer;
const app = await createTestingApp();
t.context.models = app.get(Models);
t.context.config = app.get(Config);
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
t.context.app = app;
});
@@ -43,7 +30,11 @@ let user: User;
let workspace: Workspace;
test.beforeEach(async t => {
t.context.config.docService.endpoint = t.context.app.url();
t.context.app.get(ConfigFactory).override({
docService: {
endpoint: t.context.app.url(),
},
});
await t.context.app.initTestingDB();
user = await t.context.models.user.create({
email: 'test@affine.pro',
@@ -5,7 +5,7 @@ import { Controller, Get, Logger, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import isMobile from 'is-mobile';
import { Config, metrics } from '../../base';
import { metrics } from '../../base';
import { Models } from '../../models';
import { htmlSanitize } from '../../native';
import { Public } from '../auth';
@@ -51,14 +51,11 @@ export class DocRendererController {
constructor(
private readonly doc: DocReader,
private readonly config: Config,
private readonly models: Models
) {
this.webAssets = this.readHtmlAssets(
join(this.config.projectRoot, 'static')
);
this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static'));
this.mobileAssets = this.readHtmlAssets(
join(this.config.projectRoot, 'static/mobile')
join(env.projectRoot, 'static/mobile')
);
}
@@ -66,7 +63,7 @@ export class DocRendererController {
@Get('/*')
async render(@Req() req: Request, @Res() res: Response) {
const assets: HtmlAssets =
this.config.affine.canary &&
env.namespaces.canary &&
isMobile({
ua: req.headers['user-agent'] ?? undefined,
})
@@ -141,13 +138,13 @@ export class DocRendererController {
// @TODO(@forehalo): pre-compile html template to accelerate serializing
_render(opts: RenderOptions | null, assets: HtmlAssets): string {
// TODO(@forehalo): how can we enable the type reference to @affine/env
const env: Record<string, any> = {
const envMeta: Record<string, any> = {
publicPath: assets.publicPath,
renderer: 'ssr',
};
if (this.config.isSelfhosted) {
env.isSelfHosted = true;
if (env.selfhosted) {
envMeta.isSelfHosted = true;
}
const title = opts?.title
@@ -192,7 +189,7 @@ export class DocRendererController {
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${summary}" />
<meta property="og:image" content="${image}" />
${Object.entries(env)
${Object.entries(envMeta)
.map(([key, val]) => `<meta name="env:${key}" content="${val}" />`)
.join('\n')}
${assets.css.map(url => `<link rel="stylesheet" href="${url}" />`).join('\n')}
@@ -216,7 +213,7 @@ export class DocRendererController {
readFileSync(manifestPath, 'utf-8')
);
const publicPath = this.config.isSelfhosted ? '/' : assets.publicPath;
const publicPath = env.selfhosted ? '/' : assets.publicPath;
assets.publicPath = publicPath;
assets.js = assets.js.map(path => publicPath + path);
@@ -224,7 +221,7 @@ export class DocRendererController {
return assets;
} catch (e) {
if (this.config.node.prod) {
if (env.prod) {
throw e;
} else {
return defaultAssets;
@@ -5,9 +5,7 @@ import { User, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { AppModule } from '../../../app.module';
import { CryptoHelper } from '../../../base';
import { ConfigModule } from '../../../base/config';
import { Models } from '../../../models';
import { DatabaseDocReader } from '../../doc';
@@ -19,9 +17,7 @@ const test = ava as TestFn<{
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [ConfigModule.forRoot(), AppModule],
});
const app = await createTestingApp();
t.context.models = app.get(Models);
t.context.crypto = app.get(CryptoHelper);
@@ -1,19 +1,16 @@
import { defineStartupConfig, ModuleConfig } from '../../base/config';
import { defineModuleConfig } from '../../base';
interface DocServiceStartupConfigurations {
/**
* The endpoint of the doc service.
* Example: http://doc-service:3020
*/
endpoint: string;
}
declare module '../../base/config' {
interface AppConfig {
docService: ModuleConfig<DocServiceStartupConfigurations>;
declare global {
interface AppConfigSchema {
docService: {
endpoint: string;
};
}
}
defineStartupConfig('docService', {
endpoint: '',
defineModuleConfig('docService', {
endpoint: {
desc: 'The endpoint of the doc service.',
default: '',
},
});
@@ -6,8 +6,6 @@ import ava, { TestFn } from 'ava';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { AppModule } from '../../../app.module';
import { ConfigModule } from '../../../base/config';
import { Models } from '../../../models';
import { WorkspaceBlobStorage } from '../../storage/wrappers/blob';
import { DocReader, PgWorkspaceDocStorageAdapter } from '..';
@@ -22,9 +20,7 @@ const test = ava as TestFn<{
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [ConfigModule.forRoot(), AppModule],
});
const app = await createTestingApp();
t.context.models = app.get(Models);
t.context.docReader = app.get(DocReader);
@@ -6,9 +6,8 @@ import ava, { TestFn } from 'ava';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { AppModule } from '../../../app.module';
import { Config, UserFriendlyError } from '../../../base';
import { ConfigModule } from '../../../base/config';
import { UserFriendlyError } from '../../../base';
import { ConfigFactory } from '../../../base/config';
import { Models } from '../../../models';
import { DatabaseDocReader, DocReader, PgWorkspaceDocStorageAdapter } from '..';
import { RpcDocReader } from '../reader';
@@ -16,40 +15,45 @@ import { RpcDocReader } from '../reader';
const test = ava as TestFn<{
models: Models;
app: TestingApp;
docApp: TestingApp;
docReader: DocReader;
databaseDocReader: DatabaseDocReader;
adapter: PgWorkspaceDocStorageAdapter;
config: Config;
config: ConfigFactory;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [
ConfigModule.forRoot({
flavor: {
doc: false,
},
docService: {
endpoint: '',
},
}),
AppModule,
],
});
// test key
process.env.AFFINE_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgS3IAkshQuSmFWGpe
rGTg2vwaC3LdcvBQlYHHMBYJZMyhRANCAAQXdT/TAh4neNEpd4UqpDIEqWv0XvFo
BRJxGsC5I/fetqObdx1+KEjcm8zFU2xLaUTw9IZCu8OslloOjQv4ur0a
-----END PRIVATE KEY-----`;
// @ts-expect-error testing
env.FLAVOR = 'renderer';
const notDocApp = await createTestingApp();
// @ts-expect-error testing
env.FLAVOR = 'doc';
const docApp = await createTestingApp();
t.context.models = app.get(Models);
t.context.docReader = app.get(DocReader);
t.context.databaseDocReader = app.get(DatabaseDocReader);
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
t.context.config = app.get(Config);
t.context.app = app;
t.context.models = notDocApp.get(Models);
t.context.docReader = notDocApp.get(DocReader);
t.context.databaseDocReader = docApp.get(DatabaseDocReader);
t.context.adapter = docApp.get(PgWorkspaceDocStorageAdapter);
t.context.config = notDocApp.get(ConfigFactory);
t.context.app = notDocApp;
t.context.docApp = docApp;
});
let user: User;
let workspace: Workspace;
test.beforeEach(async t => {
t.context.config.docService.endpoint = t.context.app.url();
t.context.config.override({
docService: {
endpoint: t.context.docApp.url(),
},
});
await t.context.app.initTestingDB();
user = await t.context.models.user.create({
email: 'test@affine.pro',
@@ -63,6 +67,7 @@ test.afterEach.always(() => {
test.after.always(async t => {
await t.context.app.close();
await t.context.docApp.close();
});
test('should return null when doc not found', async t => {
@@ -113,7 +118,11 @@ test('should throw error when doc service internal error', async t => {
test('should fallback to database doc reader when endpoint network error', async t => {
const { docReader } = t.context;
t.context.config.docService.endpoint = 'http://localhost:13010';
t.context.config.override({
docService: {
endpoint: 'http://localhost:13010',
},
});
const docId = randomUUID();
const timestamp = Date.now();
await t.context.models.doc.createUpdates([
@@ -223,7 +232,11 @@ test('should return doc diff', async t => {
test('should get doc diff fallback to database doc reader when endpoint network error', async t => {
const { docReader } = t.context;
t.context.config.docService.endpoint = 'http://localhost:13010';
t.context.config.override({
docService: {
endpoint: 'http://localhost:13010',
},
});
const docId = randomUUID();
const timestamp = Date.now();
let updates: Buffer[] = [];
+17 -36
View File
@@ -1,44 +1,25 @@
import {
defineRuntimeConfig,
defineStartupConfig,
ModuleConfig,
} from '../../base/config';
import { defineModuleConfig } from '../../base';
interface DocStartupConfigurations {
history: {
/**
* How long the buffer time of creating a new history snapshot when doc get updated.
*
* in {ms}
*/
interval: number;
};
}
interface DocRuntimeConfigurations {
/**
* Use `y-octo` to merge updates at the same time when merging using Yjs.
*
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
*/
experimentalMergeWithYOcto: boolean;
}
declare module '../../base/config' {
interface AppConfig {
doc: ModuleConfig<DocStartupConfigurations, DocRuntimeConfigurations>;
declare global {
interface AppConfigSchema {
doc: {
history: {
interval: number;
};
experimental: {
yocto: boolean;
};
};
}
}
defineStartupConfig('doc', {
history: {
interval: 1000 * 60 * 10 /* 10 mins */,
},
});
defineRuntimeConfig('doc', {
experimentalMergeWithYOcto: {
defineModuleConfig('doc', {
'experimental.yocto': {
desc: 'Use `y-octo` to merge updates at the same time when merging using Yjs.',
default: false,
},
'history.interval': {
desc: 'The minimum time interval in milliseconds of creating a new history snapshot when doc get updated.',
default: 1000 * 60 * 10 /* 10 mins */,
},
});
@@ -2,13 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { chunk } from 'lodash-es';
import * as Y from 'yjs';
import {
CallMetric,
Config,
mergeUpdatesInApplyWay as yotcoMergeUpdates,
metrics,
Runtime,
} from '../../base';
import { CallMetric, Config, metrics } from '../../base';
import { mergeUpdatesInApplyWay as yoctoMergeUpdates } from '../../native';
import { QuotaService } from '../quota';
import { DocStorageOptions as IDocStorageOptions } from './storage';
@@ -35,7 +30,6 @@ export class DocStorageOptions implements IDocStorageOptions {
constructor(
private readonly config: Config,
private readonly runtime: Runtime,
private readonly quota: QuotaService
) {}
@@ -43,19 +37,17 @@ export class DocStorageOptions implements IDocStorageOptions {
const doc = await this.recoverDoc(updates);
const yjsResult = Buffer.from(Y.encodeStateAsUpdate(doc));
const useYocto = await this.runtime.fetch('doc/experimentalMergeWithYOcto');
if (useYocto) {
if (this.config.doc.experimental.yocto) {
metrics.jwst.counter('codec_merge_counter').add(1);
let log = false;
let yoctoResult: Buffer | null = null;
try {
yoctoResult = yotcoMergeUpdates(updates.map(Buffer.from));
yoctoResult = yoctoMergeUpdates(updates.map(Buffer.from));
if (!compare(yjsResult, yoctoResult)) {
metrics.jwst.counter('codec_not_match').add(1);
this.logger.warn(`yocto codec result doesn't match yjs codec result`);
log = true;
if (this.config.node.dev) {
if (env.dev) {
this.logger.warn(`Expected:\n ${yjsResult.toString('hex')}`);
this.logger.warn(`Result:\n ${yoctoResult.toString('hex')}`);
}
@@ -66,14 +58,14 @@ export class DocStorageOptions implements IDocStorageOptions {
log = true;
}
if (log && this.config.node.dev) {
if (log && env.dev) {
this.logger.warn(
`Updates: ${updates.map(u => Buffer.from(u).toString('hex')).join('\n')}`
);
}
if (
this.config.affine.canary &&
env.namespaces.canary &&
yoctoResult &&
yoctoResult.length > 2 /* simple test for non-empty yjs binary */
) {
@@ -402,11 +402,11 @@ export class RpcDocReader extends DatabaseDocReader {
export const DocReaderProvider: FactoryProvider = {
provide: DocReader,
useFactory: (config: Config, ref: ModuleRef) => {
if (config.flavor.doc) {
useFactory: (ref: ModuleRef) => {
if (env.flavors.doc) {
return ref.create(DatabaseDocReader);
}
return ref.create(RpcDocReader);
},
inject: [Config, ModuleRef],
inject: [ModuleRef],
};
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Runtime } from '../../base';
import { Config } from '../../base';
import { Models } from '../../models';
const STAFF = ['@toeverything.info', '@affine.pro'];
@@ -15,8 +15,8 @@ export class FeatureService {
protected logger = new Logger(FeatureService.name);
constructor(
private readonly models: Models,
private readonly runtime: Runtime
private readonly config: Config,
private readonly models: Models
) {}
// ======== Admin ========
@@ -73,9 +73,7 @@ export class FeatureService {
email: string,
type: EarlyAccessType = EarlyAccessType.App
) {
const earlyAccessControlEnabled = await this.runtime.fetch(
'flags/earlyAccessControl'
);
const earlyAccessControlEnabled = this.config.flags.earlyAccessControl;
if (earlyAccessControlEnabled && !this.isStaff(email)) {
const user = await this.models.user.getUserByEmail(email);
@@ -1,12 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Config } from '../../base';
import { Feature, UserFeatureName } from '../../models';
@Injectable()
export class AvailableUserFeatureConfig {
@Inject(Config) private readonly config!: Config;
availableUserFeatures(): Set<UserFeatureName> {
return new Set([
Feature.Admin,
@@ -18,7 +15,7 @@ export class AvailableUserFeatureConfig {
configurableUserFeatures(): Set<UserFeatureName> {
return new Set(
this.config.isSelfhosted
env.selfhosted
? [Feature.Admin, Feature.UnlimitedCopilot]
: [
Feature.EarlyAccess,
@@ -0,0 +1 @@
export * from './config';
+50 -12
View File
@@ -1,16 +1,54 @@
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { defineModuleConfig } from '../../base';
import { defineStartupConfig, ModuleConfig } from '../../base/config';
declare module '../../base/config' {
interface AppConfig {
/**
* Configurations for mail service used to post auth or bussiness mails.
*
* @see https://nodemailer.com/smtp/
*/
mailer: ModuleConfig<SMTPTransport.Options>;
declare global {
interface AppConfigSchema {
mailer: {
enabled: boolean;
SMTP: {
host: string;
port: number;
username: string;
password: string;
ignoreTLS: boolean;
sender: string;
};
};
}
}
defineStartupConfig('mailer', {});
defineModuleConfig('mailer', {
enabled: {
desc: 'Whether enabled mail service.',
default: false,
},
'SMTP.host': {
desc: 'Host of the email server (e.g. smtp.gmail.com)',
default: '',
env: 'MAILER_HOST',
},
'SMTP.port': {
desc: 'Port of the email server (they commonly are 25, 465 or 587)',
default: 465,
env: ['MAILER_PORT', 'integer'],
},
'SMTP.username': {
desc: 'Username used to authenticate the email server',
default: '',
env: 'MAILER_USER',
},
'SMTP.password': {
desc: 'Password used to authenticate the email server',
default: '',
env: 'MAILER_PASSWORD',
},
'SMTP.sender': {
desc: 'Sender of all the emails (e.g. "AFFiNE Team <noreply@affine.pro>")',
default: '',
env: 'MAILER_SENDER',
},
'SMTP.ignoreTLS': {
desc: "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.",
default: false,
env: 'MAILER_IGNORE_TLS',
},
});
+38 -12
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import {
createTestAccount,
createTransport,
@@ -8,7 +8,7 @@ import {
} from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { Config, metrics } from '../../base';
import { Config, metrics, OnEvent } from '../../base';
export type SendOptions = Omit<SendMailOptions, 'to' | 'subject' | 'html'> & {
to: string;
@@ -17,25 +17,51 @@ export type SendOptions = Omit<SendMailOptions, 'to' | 'subject' | 'html'> & {
};
@Injectable()
export class MailSender implements OnModuleInit {
export class MailSender {
private readonly logger = new Logger(MailSender.name);
private smtp: Transporter<SMTPTransport.SentMessageInfo> | null = null;
private usingTestAccount = false;
constructor(private readonly config: Config) {}
onModuleInit() {
this.createSMTP(this.config.mailer);
@OnEvent('config.init')
onConfigInit() {
this.setup();
}
createSMTP(config: SMTPTransport.Options) {
if (config.host) {
this.smtp = createTransport(config);
} else if (this.config.node.dev) {
@OnEvent('config.changed')
onConfigChanged(event: Events['config.changed']) {
if ('mailer' in event.updates) {
this.setup();
}
}
private setup() {
const { SMTP, enabled } = this.config.mailer;
if (!enabled) {
this.smtp = null;
return;
}
const opts: SMTPTransport.Options = {
host: SMTP.host,
port: SMTP.port,
tls: {
rejectUnauthorized: !SMTP.ignoreTLS,
},
auth: {
user: SMTP.username,
pass: SMTP.password,
},
};
if (SMTP.host) {
this.smtp = createTransport(opts);
} else if (env.dev) {
createTestAccount((err, account) => {
if (!err) {
this.smtp = createTransport({
from: 'noreply@toeverything.info',
...this.config.mailer,
...opts,
...account.smtp,
auth: {
user: account.user,
@@ -59,7 +85,7 @@ export class MailSender implements OnModuleInit {
metrics.mail.counter('send_total').add(1, { name });
try {
const result = await this.smtp.sendMail({
from: this.config.mailer.from,
from: this.config.mailer.SMTP.sender,
...options,
});
@@ -1,12 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import {
Config,
NotificationNotFound,
PaginationInput,
URLHelper,
} from '../../base';
import { NotificationNotFound, PaginationInput, URLHelper } from '../../base';
import {
DEFAULT_WORKSPACE_NAME,
InvitationNotificationCreate,
@@ -30,8 +25,7 @@ export class NotificationService {
private readonly models: Models,
private readonly docReader: DocReader,
private readonly mailer: Mailer,
private readonly url: URLHelper,
private readonly config: Config
private readonly url: URLHelper
) {}
async cleanExpiredNotifications() {
@@ -100,7 +94,7 @@ export class NotificationService {
private async sendInvitationEmail(input: InvitationNotificationCreate) {
const inviteUrl = this.url.link(`/invite/${input.body.inviteId}`);
if (this.config.node.dev) {
if (env.dev) {
// make it easier to test in dev mode
this.logger.debug(`Invite link: ${inviteUrl}`);
}
@@ -3,10 +3,11 @@ import type { Request, Response } from 'express';
import {
ActionForbidden,
Config,
InternalServerError,
Mutex,
PasswordRequired,
Runtime,
UseNamedGuard,
} from '../../base';
import { Models } from '../../models';
import { AuthService, Public } from '../auth';
@@ -18,14 +19,15 @@ interface CreateUserInput {
password: string;
}
@UseNamedGuard('selfhost')
@Controller('/api/setup')
export class CustomSetupController {
constructor(
private readonly config: Config,
private readonly models: Models,
private readonly auth: AuthService,
private readonly mutex: Mutex,
private readonly server: ServerService,
private readonly runtime: Runtime
private readonly server: ServerService
) {}
@Public()
@@ -45,15 +47,10 @@ export class CustomSetupController {
throw new PasswordRequired();
}
const config = await this.runtime.fetchAll({
'auth/password.max': true,
'auth/password.min': true,
});
validators.assertValidPassword(input.password, {
max: config['auth/password.max'],
min: config['auth/password.min'],
});
validators.assertValidPassword(
input.password,
this.config.auth.passwordRequirements
);
await using lock = await this.mutex.acquire('createFirstAdmin');
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { GuardProvider } from '../../base/guard';
declare module '../../base/guard' {
interface RegisterGuardName {
selfhost: 'selfhost';
}
}
@Injectable()
export class SelfhostGuard extends GuardProvider {
override name = 'selfhost' as const;
override canActivate() {
return env.selfhosted;
}
}
@@ -4,12 +4,13 @@ import { AuthModule } from '../auth';
import { ServerConfigModule } from '../config';
import { UserModule } from '../user';
import { CustomSetupController } from './controller';
import { SelfhostGuard } from './guard';
import { SetupMiddleware } from './setup';
import { StaticFilesResolver } from './static';
@Module({
imports: [AuthModule, UserModule, ServerConfigModule],
providers: [SetupMiddleware, StaticFilesResolver],
providers: [SetupMiddleware, StaticFilesResolver, SelfhostGuard],
controllers: [CustomSetupController],
})
export class SelfhostModule {}
@@ -26,7 +26,7 @@ export class StaticFilesResolver implements OnModuleInit {
const app = this.adapterHost.httpAdapter.getInstance<Application>();
// for example, '/affine' in host [//host.com/affine]
const basePath = this.config.server.path;
const staticPath = join(this.config.projectRoot, 'static');
const staticPath = join(env.projectRoot, 'static');
// web => {
// affine: 'static/index.html',
@@ -69,7 +69,7 @@ export class StaticFilesResolver implements OnModuleInit {
join(
staticPath,
'admin',
this.config.isSelfhosted ? 'selfhost.html' : 'index.html'
env.selfhosted ? 'selfhost.html' : 'index.html'
)
);
}
@@ -107,7 +107,7 @@ export class StaticFilesResolver implements OnModuleInit {
// fallback all unknown routes
app.get([basePath, basePath + '/*'], this.check.use, (req, res) => {
const mobile =
this.config.affine.canary &&
env.namespaces.canary &&
isMobile({
ua: req.headers['user-agent'] ?? undefined,
});
@@ -116,7 +116,7 @@ export class StaticFilesResolver implements OnModuleInit {
join(
staticPath,
mobile ? 'mobile' : '',
this.config.isSelfhosted ? 'selfhost.html' : 'index.html'
env.selfhosted ? 'selfhost.html' : 'index.html'
)
);
});
@@ -1,34 +1,50 @@
import { defineStartupConfig, ModuleConfig } from '../../base/config';
import { StorageProviderType } from '../../base/storage';
import {
defineModuleConfig,
StorageJSONSchema,
StorageProviderConfig,
} from '../../base';
export type StorageConfig<Ext = unknown> = {
provider: StorageProviderType;
bucket: string;
} & Ext;
export interface StorageStartupConfigurations {
avatar: StorageConfig<{
publicLinkFactory: (key: string) => string;
keyInPublicLink: (link: string) => string;
}>;
blob: StorageConfig;
export interface Storages {
avatar: {
storage: ConfigItem<StorageProviderConfig>;
publicPath: string;
};
blob: {
storage: ConfigItem<StorageProviderConfig>;
};
}
declare module '../../base/config' {
interface AppConfig {
storages: ModuleConfig<StorageStartupConfigurations>;
declare global {
interface AppConfigSchema {
storages: Storages;
}
}
defineStartupConfig('storages', {
avatar: {
provider: 'fs',
bucket: 'avatars',
publicLinkFactory: key => `/api/avatars/${key}`,
keyInPublicLink: link => link.split('/').pop() as string,
defineModuleConfig('storages', {
'avatar.publicPath': {
desc: 'The public accessible path prefix for user avatars.',
default: '/api/avatars/',
},
blob: {
provider: 'fs',
bucket: 'blobs',
'avatar.storage': {
desc: 'The config of storage for user avatars.',
default: {
provider: 'fs',
bucket: 'avatars',
config: {
path: '~/.affine/storage',
},
},
schema: StorageJSONSchema,
},
'blob.storage': {
desc: 'The config of storage for all uploaded blobs(images, videos, etc.).',
default: {
provider: 'fs',
bucket: 'blobs',
config: {
path: '~/.affine/storage',
},
},
schema: StorageJSONSchema,
},
});
@@ -14,21 +14,23 @@ import {
@Injectable()
export class AvatarStorage {
public readonly provider: StorageProvider;
private readonly storageConfig: Config['storages']['avatar'];
private provider: StorageProvider;
get config() {
return this.AFFiNEConfig.storages.avatar;
}
constructor(
private readonly config: Config,
private readonly AFFiNEConfig: Config,
private readonly url: URLHelper,
private readonly storageFactory: StorageProviderFactory
) {
this.storageConfig = this.config.storages.avatar;
this.provider = this.storageFactory.create(this.storageConfig);
this.provider = this.storageFactory.create(this.config.storage);
}
async put(key: string, blob: BlobInputType, metadata?: PutObjectMetadata) {
await this.provider.put(key, blob, metadata);
let link = this.storageConfig.publicLinkFactory(key);
let link = this.config.publicPath + key;
if (link.startsWith('/')) {
link = this.url.link(link);
@@ -42,7 +44,7 @@ export class AvatarStorage {
}
delete(link: string) {
return this.provider.delete(this.storageConfig.keyInPublicLink(link));
return this.provider.delete(link.split('/').pop() as string);
}
@OnEvent('user.deleted')
@@ -51,4 +53,11 @@ export class AvatarStorage {
await this.delete(user.avatarUrl);
}
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if (event.updates.storages?.avatar?.storage) {
this.provider = this.storageFactory.create(this.config.storage);
}
}
}
@@ -30,16 +30,20 @@ declare global {
@Injectable()
export class WorkspaceBlobStorage {
private readonly logger = new Logger(WorkspaceBlobStorage.name);
public readonly provider: StorageProvider;
private provider: StorageProvider;
get config() {
return this.AFFiNEConfig.storages.blob;
}
constructor(
private readonly config: Config,
private readonly AFFiNEConfig: Config,
private readonly event: EventBus,
private readonly storageFactory: StorageProviderFactory,
private readonly db: PrismaClient,
private readonly url: URLHelper
) {
this.provider = this.storageFactory.create(this.config.storages.blob);
this.provider = this.storageFactory.create(this.config.storage);
}
async put(workspaceId: string, key: string, blob: Buffer) {
@@ -225,4 +229,11 @@ export class WorkspaceBlobStorage {
}: Events['workspace.blob.delete']) {
await this.delete(workspaceId, key, true);
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if (event.updates.storages?.blob?.storage) {
this.provider = this.storageFactory.create(this.config.storage);
}
}
}
@@ -17,9 +17,7 @@ import {
GatewayErrorWrapper,
metrics,
NotInSpace,
Runtime,
SpaceAccessDenied,
VersionRejected,
} from '../../base';
import { Models } from '../../models';
import { CurrentUser } from '../auth';
@@ -145,7 +143,6 @@ export class SpaceSyncGateway
private connectionCount = 0;
constructor(
private readonly runtime: Runtime,
private readonly ac: AccessController,
private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly userspace: PgUserspaceDocStorageAdapter,
@@ -186,30 +183,6 @@ export class SpaceSyncGateway
return adapters[spaceType];
}
async assertVersion(client: Socket, version?: string) {
const shouldCheckClientVersion = await this.runtime.fetch(
'flags/syncClientVersionCheck'
);
if (
// @todo(@darkskygit): remove this flag after 0.12 goes stable
shouldCheckClientVersion &&
version !== AFFiNE.version
) {
client.emit('server-version-rejected', {
currentVersion: version,
requiredVersion: AFFiNE.version,
reason: `Client version${
version ? ` ${version}` : ''
} is outdated, please update to ${AFFiNE.version}`,
});
throw new VersionRejected({
version: version || 'unknown',
serverVersion: AFFiNE.version,
});
}
}
// v3
@SubscribeMessage('space:join')
async onJoinSpace(
@@ -218,8 +191,6 @@ export class SpaceSyncGateway
@MessageBody()
{ spaceType, spaceId, clientVersion }: JoinSpaceMessage
): Promise<EventResponse<{ clientId: string; success: true }>> {
await this.assertVersion(client, clientVersion);
// TODO(@forehalo): remove this after 0.19 goes out of life
// simple match 0.19.x
if (/^0.19.[\d]$/.test(clientVersion)) {
@@ -396,10 +367,8 @@ export class SpaceSyncGateway
@ConnectedSocket() client: Socket,
@CurrentUser() user: CurrentUser,
@MessageBody()
{ spaceType, spaceId, docId, clientVersion }: JoinSpaceAwarenessMessage
{ spaceType, spaceId, docId }: JoinSpaceAwarenessMessage
) {
await this.assertVersion(client, clientVersion);
await this.selectAdapter(client, spaceType).join(
user.id,
spaceId,
@@ -12,7 +12,7 @@ export class UserAvatarController {
@Get('/:id')
async getAvatar(@Res() res: Response, @Param('id') id: string) {
if (this.storage.provider.type !== 'fs') {
if (this.storage.config.storage.provider !== 'fs') {
throw new ActionForbidden(
'Only available when avatar storage provider set to fs.'
);
@@ -1,61 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Config, OnEvent } from '../../base';
@Injectable()
export class UserEventsListener {
private readonly logger = new Logger(UserEventsListener.name);
constructor(private readonly config: Config) {}
@OnEvent('user.updated')
async onUserUpdated(user: Events['user.updated']) {
const { enabled, customerIo } = this.config.metrics;
if (enabled && customerIo?.token) {
const payload = {
name: user.name,
email: user.email,
created_at: Number(user.createdAt) / 1000,
};
try {
await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, {
method: 'PUT',
headers: {
Authorization: `Basic ${customerIo.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
} catch (e) {
this.logger.error('Failed to publish user update event:', e);
}
}
}
@OnEvent('user.deleted')
async onUserDeleted(user: Events['user.deleted']) {
const { enabled, customerIo } = this.config.metrics;
if (enabled && customerIo?.token) {
try {
if (user.emailVerifiedAt) {
// suppress email if email is verified
await fetch(
`https://track.customer.io/api/v1/customers/${user.email}/suppress`,
{
method: 'POST',
headers: {
Authorization: `Basic ${customerIo.token}`,
},
}
);
}
await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, {
method: 'DELETE',
headers: { Authorization: `Basic ${customerIo.token}` },
});
} catch (e) {
this.logger.error('Failed to publish user delete event:', e);
}
}
}
}
@@ -3,7 +3,6 @@ import { Module } from '@nestjs/common';
import { PermissionModule } from '../permission';
import { StorageModule } from '../storage';
import { UserAvatarController } from './controller';
import { UserEventsListener } from './event';
import {
UserManagementResolver,
UserResolver,
@@ -12,12 +11,7 @@ import {
@Module({
imports: [StorageModule, PermissionModule],
providers: [
UserResolver,
UserManagementResolver,
UserEventsListener,
UserSettingsResolver,
],
providers: [UserResolver, UserManagementResolver, UserSettingsResolver],
controllers: [UserAvatarController],
})
export class UserModule {}
@@ -1,4 +1,4 @@
import { defineRuntimeConfig, ModuleConfig } from '../../base/config';
import { defineModuleConfig } from '../../base';
export interface VersionConfig {
versionControl: {
@@ -7,9 +7,9 @@ export interface VersionConfig {
};
}
declare module '../../base/config' {
interface AppConfig {
client: ModuleConfig<never, VersionConfig>;
declare global {
interface AppConfigSchema {
client: VersionConfig;
}
}
@@ -19,7 +19,7 @@ declare module '../../base/guard' {
}
}
defineRuntimeConfig('client', {
defineModuleConfig('client', {
'versionControl.enabled': {
desc: 'Whether check version of client before accessing the server.',
default: false,
@@ -6,9 +6,9 @@ import type {
import { Injectable } from '@nestjs/common';
import {
Config,
getRequestResponseFromContext,
GuardProvider,
Runtime,
} from '../../base';
import { VersionService } from './service';
@@ -20,14 +20,14 @@ export class VersionGuardProvider
name = 'version' as const;
constructor(
private readonly runtime: Runtime,
private readonly config: Config,
private readonly version: VersionService
) {
super();
}
async canActivate(context: ExecutionContext) {
if (!(await this.runtime.fetch('client/versionControl.enabled'))) {
if (!this.config.client.versionControl.enabled) {
return true;
}
@@ -1,18 +1,16 @@
import { Injectable, Logger } from '@nestjs/common';
import semver from 'semver';
import { Runtime, UnsupportedClientVersion } from '../../base';
import { Config, UnsupportedClientVersion } from '../../base';
@Injectable()
export class VersionService {
private readonly logger = new Logger(VersionService.name);
constructor(private readonly runtime: Runtime) {}
constructor(private readonly config: Config) {}
async checkVersion(clientVersion?: string) {
const requiredVersion = await this.runtime.fetch(
'client/versionControl.requiredVersion'
);
const requiredVersion = this.config.client.versionControl.requiredVersion;
const range = await this.getVersionRange(requiredVersion);
if (!range) {