mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-16 09:36:17 +08:00
refactor(server): config system (#11081)
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user