feat(server): runtime setting support (#5602)

---

<details open="true"><summary>Generated summary (powered by <a href="https://app.graphite.dev">Graphite</a>)</summary>

> ## TL;DR
> This pull request adds a new migration file, a new model, and new modules related to runtime settings. It also introduces a new `Runtime` service that allows getting, setting, and updating runtime configurations.
>
> ## What changed
> - Added a new migration file `migration.sql` that creates a table called `application_settings` with columns `key` and `value`.
> - Added a new model `ApplicationSetting` with properties `key` and `value`.
> - Added a new module `RuntimeSettingModule` that exports the `Runtime` service.
> - Added a new service `Runtime` that provides methods for getting, setting, and updating runtime configurations.
> - Modified the `app.module.ts` file to import the `RuntimeSettingModule`.
> - Modified the `index.ts` file in the `fundamentals` directory to export the `Runtime` service.
> - Added a new file `def.ts` in the `runtime` directory that defines the runtime configurations and provides a default implementation.
> - Added a new file `service.ts` in the `runtime` directory that implements the `Runtime` service.
>
> ## How to test
> 1. Run the migration script to create the `application_settings` table.
> 2. Use the `Runtime` service to get, set, and update runtime configurations.
> 3. Verify that the runtime configurations are stored correctly in the database and can be retrieved and modified using the `Runtime` service.
>
> ## Why make this change
> This change introduces a new feature related to runtime settings. The `Runtime` service allows the application to dynamically manage and modify runtime configurations without requiring a restart. This provides flexibility and allows for easier customization and configuration of the application.
</details>
This commit is contained in:
forehalo 2024-05-28 06:43:53 +00:00
parent 9d296c4b62
commit 638fc62601
No known key found for this signature in database
GPG Key ID: 56709255DC7EC728
116 changed files with 1907 additions and 1106 deletions

View File

@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "RuntimeConfigType" AS ENUM ('String', 'Number', 'Boolean', 'Object', 'Array');
-- CreateTable
CREATE TABLE "app_runtime_settings" (
"id" VARCHAR NOT NULL,
"type" "RuntimeConfigType" NOT NULL,
"module" VARCHAR NOT NULL,
"key" VARCHAR NOT NULL,
"value" JSON NOT NULL,
"description" TEXT NOT NULL,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
"deleted_at" TIMESTAMPTZ(6),
"last_updated_by" VARCHAR(36),
CONSTRAINT "app_runtime_settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "app_runtime_settings_module_key_key" ON "app_runtime_settings"("module", "key");
-- AddForeignKey
ALTER TABLE "app_runtime_settings" ADD CONSTRAINT "app_runtime_settings_last_updated_by_fkey" FOREIGN KEY ("last_updated_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -22,15 +22,16 @@ model User {
/// for example, the value will be false if user never registered and invited into a workspace by others.
registered Boolean @default(true)
features UserFeatures[]
customer UserStripeCustomer?
subscriptions UserSubscription[]
invoices UserInvoice[]
workspacePermissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[]
connectedAccounts ConnectedAccount[]
sessions UserSession[]
aiSessions AiSession[]
features UserFeatures[]
customer UserStripeCustomer?
subscriptions UserSubscription[]
invoices UserInvoice[]
workspacePermissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[]
connectedAccounts ConnectedAccount[]
sessions UserSession[]
aiSessions AiSession[]
updatedRuntimeConfigs RuntimeConfig[]
@@index([email])
@@map("users")
@ -505,3 +506,28 @@ model DataMigration {
@@map("_data_migrations")
}
enum RuntimeConfigType {
String
Number
Boolean
Object
Array
}
model RuntimeConfig {
id String @id @db.VarChar
type RuntimeConfigType
module String @db.VarChar
key String @db.VarChar
value Json @db.Json
description String @db.Text
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
lastUpdatedBy String? @map("last_updated_by") @db.VarChar(36)
lastUpdatedByUser User? @relation(fields: [lastUpdatedBy], references: [id])
@@unique([module, key])
@@map("app_runtime_settings")
}

View File

@ -17,8 +17,11 @@ import { UserModule } from './core/user';
import { WorkspaceModule } from './core/workspaces';
import { getOptionalModuleMetadata } from './fundamentals';
import { CacheModule } from './fundamentals/cache';
import type { AvailablePlugins } from './fundamentals/config';
import { Config, ConfigModule } from './fundamentals/config';
import {
AFFiNEConfig,
ConfigModule,
mergeConfigOverride,
} from './fundamentals/config';
import { EventModule } from './fundamentals/event';
import { GqlModule } from './fundamentals/graphql';
import { HelpersModule } from './fundamentals/helpers';
@ -30,6 +33,7 @@ import { StorageProviderModule } from './fundamentals/storage';
import { RateLimiterModule } from './fundamentals/throttler';
import { WebSocketModule } from './fundamentals/websocket';
import { REGISTERED_PLUGINS } from './plugins';
import { ENABLED_PLUGINS } from './plugins/registry';
export const FunctionalityModules = [
ConfigModule.forRoot(),
@ -47,7 +51,7 @@ export const FunctionalityModules = [
export class AppModuleBuilder {
private readonly modules: AFFiNEModule[] = [];
constructor(private readonly config: Config) {}
constructor(private readonly config: AFFiNEConfig) {}
use(...modules: AFFiNEModule[]): this {
modules.forEach(m => {
@ -90,7 +94,7 @@ export class AppModuleBuilder {
}
useIf(
predicator: (config: Config) => boolean,
predicator: (config: AFFiNEConfig) => boolean,
...modules: AFFiNEModule[]
): this {
if (predicator(this.config)) {
@ -112,6 +116,7 @@ export class AppModuleBuilder {
}
function buildAppModule() {
AFFiNE = mergeConfigOverride(AFFiNE);
const factor = new AppModuleBuilder(AFFiNE);
factor
@ -147,8 +152,8 @@ function buildAppModule() {
);
// plugin modules
AFFiNE.plugins.enabled.forEach(name => {
const plugin = REGISTERED_PLUGINS.get(name as AvailablePlugins);
ENABLED_PLUGINS.forEach(name => {
const plugin = REGISTERED_PLUGINS.get(name);
if (!plugin) {
throw new Error(`Unknown plugin ${name}`);
}

View File

@ -50,11 +50,13 @@ export async function createApp() {
app.useWebSocketAdapter(adapter);
}
if (AFFiNE.isSelfhosted && AFFiNE.telemetry.enabled) {
if (AFFiNE.isSelfhosted && AFFiNE.metrics.telemetry.enabled) {
const mixpanel = await import('mixpanel');
mixpanel.init(AFFiNE.telemetry.token).track('selfhost-server-started', {
version: AFFiNE.version,
});
mixpanel
.init(AFFiNE.metrics.telemetry.token)
.track('selfhost-server-started', {
version: AFFiNE.version,
});
}
return app;

View File

@ -1,31 +1,22 @@
// Convenient way to map environment variables to config values.
AFFiNE.ENV_MAP = {
AFFINE_SERVER_PORT: ['port', 'int'],
AFFINE_SERVER_HOST: 'host',
AFFINE_SERVER_SUB_PATH: 'path',
AFFINE_SERVER_HTTPS: ['https', 'boolean'],
DATABASE_URL: 'db.url',
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
OAUTH_GOOGLE_CLIENT_ID: 'plugins.oauth.providers.google.clientId',
OAUTH_GOOGLE_CLIENT_SECRET: 'plugins.oauth.providers.google.clientSecret',
OAUTH_GITHUB_CLIENT_ID: 'plugins.oauth.providers.github.clientId',
OAUTH_GITHUB_CLIENT_SECRET: 'plugins.oauth.providers.github.clientSecret',
OAUTH_OIDC_ISSUER: 'plugins.oauth.providers.oidc.issuer',
OAUTH_OIDC_CLIENT_ID: 'plugins.oauth.providers.oidc.clientId',
OAUTH_OIDC_CLIENT_SECRET: 'plugins.oauth.providers.oidc.clientSecret',
OAUTH_OIDC_SCOPE: 'plugins.oauth.providers.oidc.args.scope',
OAUTH_OIDC_CLAIM_MAP_USERNAME: 'plugins.oauth.providers.oidc.args.claim_id',
OAUTH_OIDC_CLAIM_MAP_EMAIL: 'plugins.oauth.providers.oidc.args.claim_email',
OAUTH_OIDC_CLAIM_MAP_NAME: 'plugins.oauth.providers.oidc.args.claim_name',
AFFINE_SERVER_PORT: ['server.port', 'int'],
AFFINE_SERVER_HOST: 'server.host',
AFFINE_SERVER_SUB_PATH: 'server.path',
AFFINE_SERVER_HTTPS: ['server.https', 'boolean'],
ENABLE_TELEMETRY: ['metrics.telemetry.enabled', 'boolean'],
MAILER_HOST: 'mailer.host',
MAILER_PORT: ['mailer.port', 'int'],
MAILER_USER: 'mailer.auth.user',
MAILER_PASSWORD: 'mailer.auth.pass',
MAILER_SENDER: 'mailer.from.address',
MAILER_SECURE: ['mailer.secure', 'boolean'],
THROTTLE_TTL: ['rateLimiter.ttl', 'int'],
THROTTLE_LIMIT: ['rateLimiter.limit', 'int'],
OAUTH_GOOGLE_CLIENT_ID: 'plugins.oauth.providers.google.clientId',
OAUTH_GOOGLE_CLIENT_SECRET: 'plugins.oauth.providers.google.clientSecret',
OAUTH_GITHUB_CLIENT_ID: 'plugins.oauth.providers.github.clientId',
OAUTH_GITHUB_CLIENT_SECRET: 'plugins.oauth.providers.github.clientSecret',
OAUTH_OIDC_CLIENT_ID: 'plugins.oauth.providers.oidc.clientId',
OAUTH_OIDC_CLIENT_SECRET: 'plugins.oauth.providers.oidc.clientSecret',
METRICS_CUSTOMER_IO_TOKEN: ['metrics.customerIo.token', 'string'],
COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey',
COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey',
@ -36,16 +27,6 @@ AFFiNE.ENV_MAP = {
REDIS_SERVER_PASSWORD: 'plugins.redis.password',
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
DOC_MERGE_USE_JWST_CODEC: [
'doc.manager.experimentalMergeWithYOcto',
'boolean',
],
STRIPE_API_KEY: 'plugins.payment.stripe.keys.APIKey',
STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey',
FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'],
FEATURES_SYNC_CLIENT_VERSION_CHECK: [
'featureFlags.syncClientVersionCheck',
'boolean',
],
TELEMETRY_ENABLE: ['telemetry.enabled', 'boolean'],
};

View File

@ -20,35 +20,47 @@ const env = process.env;
AFFiNE.metrics.enabled = !AFFiNE.node.test;
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.plugins.use('cloudflare-r2', {
AFFiNE.use('cloudflare-r2', {
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
credentials: {
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
},
});
AFFiNE.storage.storages.avatar.provider = 'cloudflare-r2';
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
AFFiNE.storages.avatar.provider = 'cloudflare-r2';
AFFiNE.storages.avatar.bucket = 'account-avatar';
AFFiNE.storages.avatar.publicLinkFactory = key =>
`https://avatar.affineassets.com/${key}`;
AFFiNE.storage.storages.blob.provider = 'cloudflare-r2';
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
AFFiNE.storages.blob.provider = 'cloudflare-r2';
AFFiNE.storages.blob.bucket = `workspace-blobs-${
AFFiNE.affine.canary ? 'canary' : 'prod'
}`;
AFFiNE.storage.storages.copilot.provider = 'cloudflare-r2';
AFFiNE.storage.storages.copilot.bucket = `workspace-copilot-${
AFFiNE.affine.canary ? 'canary' : 'prod'
}`;
AFFiNE.use('copilot', {
storage: {
provider: 'cloudflare-r2',
bucket: `workspace-copilot-${AFFiNE.affine.canary ? 'canary' : 'prod'}`,
},
});
}
AFFiNE.plugins.use('copilot', {
openai: {},
fal: {},
AFFiNE.use('copilot', {
openai: {
apiKey: '',
},
fal: {
apiKey: '',
},
});
AFFiNE.plugins.use('redis');
AFFiNE.plugins.use('payment', {
AFFiNE.use('redis', {
host: env.REDIS_SERVER_HOST,
db: 0,
port: 6379,
username: env.REDIS_SERVER_USER,
password: env.REDIS_SERVER_PASSWORD,
});
AFFiNE.use('payment', {
stripe: {
keys: {
// fake the key to ensure the server generate full GraphQL Schema even env vars are not set
@ -57,7 +69,7 @@ AFFiNE.plugins.use('payment', {
},
},
});
AFFiNE.plugins.use('oauth');
AFFiNE.use('oauth');
if (AFFiNE.deploy) {
AFFiNE.mailer = {
@ -68,5 +80,5 @@ if (AFFiNE.deploy) {
},
};
AFFiNE.plugins.use('gcloud');
AFFiNE.use('gcloud');
}

View File

@ -26,22 +26,14 @@
// AFFiNE.serverName = 'Your Cool AFFiNE Selfhosted Cloud';
//
// /* Whether the server is deployed behind a HTTPS proxied environment */
AFFiNE.https = false;
AFFiNE.server.https = false;
// /* Domain of your server that your server will be available at */
AFFiNE.host = 'localhost';
AFFiNE.server.host = 'localhost';
// /* The local port of your server that will listen on */
AFFiNE.port = 3010;
AFFiNE.server.port = 3010;
// /* The sub path of your server */
// /* For example, if you set `AFFiNE.path = '/affine'`, then the server will be available at `${domain}/affine` */
// AFFiNE.path = '/affine';
//
//
// ###############################################################
// ## Database settings ##
// ###############################################################
//
// /* The URL of the database where most of AFFiNE server data will be stored in */
// AFFiNE.db.url = 'postgres://user:passsword@localhost:5432/affine';
// /* For example, if you set `AFFiNE.server.path = '/affine'`, then the server will be available at `${domain}/affine` */
// AFFiNE.server.path = '/affine';
//
//
// ###############################################################
@ -52,19 +44,12 @@ AFFiNE.port = 3010;
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
// AFFiNE.metrics.enabled = true;
//
// /* Authentication Settings */
// /* Whether allow anyone signup */
// AFFiNE.auth.allowSignup = true;
//
// /* User Signup password limitation */
// AFFiNE.auth.password = {
// minLength: 8,
// maxLength: 32,
// };
//
// /* How long the login session would last by default */
// AFFiNE.auth.session = {
// /* How long the login session would last by default */
// ttl: 15 * 24 * 60 * 60, // 15 days
// /* How long we should refresh the token before it getting expired */
// ttr: 7 * 24 * 60 * 60, // 7 days
// };
//
// /* GraphQL configurations that control the behavior of the Apollo Server behind */
@ -85,9 +70,6 @@ AFFiNE.port = 3010;
// /* How long the buffer time of creating a new history snapshot when doc get updated */
// AFFiNE.doc.history.interval = 1000 * 60 * 10; // 10 minutes
//
// /* Use `y-octo` to merge updates at the same time when merging using Yjs */
// AFFiNE.doc.manager.experimentalMergeWithYOcto = true;
//
// /* How often the manager will start a new turn of merging pending updates into doc snapshot */
// AFFiNE.doc.manager.updatePollInterval = 1000 * 3;
//
@ -99,20 +81,20 @@ AFFiNE.port = 3010;
// /* Redis Plugin */
// /* Provide caching and session storing backed by Redis. */
// /* Useful when you deploy AFFiNE server in a cluster. */
// AFFiNE.plugins.use('redis', {
// AFFiNE.use('redis', {
// /* override options */
// });
//
//
// /* Payment Plugin */
// AFFiNE.plugins.use('payment', {
// AFFiNE.use('payment', {
// stripe: { keys: {}, apiVersion: '2023-10-16' },
// });
//
//
// /* Cloudflare R2 Plugin */
// /* Enable if you choose to store workspace blobs or user avatars in Cloudflare R2 Storage Service */
// AFFiNE.plugins.use('cloudflare-r2', {
// AFFiNE.use('cloudflare-r2', {
// accountId: '',
// credentials: {
// accessKeyId: '',
@ -122,17 +104,17 @@ AFFiNE.port = 3010;
//
// /* AWS S3 Plugin */
// /* Enable if you choose to store workspace blobs or user avatars in AWS S3 Storage Service */
// AFFiNE.plugins.use('aws-s3', {
// AFFiNE.use('aws-s3', {
// credentials: {
// accessKeyId: '',
// secretAccessKey: '',
// })
// /* Update the provider of storages */
// AFFiNE.storage.storages.blob.provider = 'r2';
// AFFiNE.storage.storages.avatar.provider = 'r2';
// AFFiNE.storages.blob.provider = 'cloudflare-r2';
// AFFiNE.storages.avatar.provider = 'cloudflare-r2';
//
/* OAuth Plugin */
// AFFiNE.plugins.use('oauth', {
// /* OAuth Plugin */
// AFFiNE.use('oauth', {
// providers: {
// github: {
// clientId: '',
@ -166,3 +148,18 @@ AFFiNE.port = 3010;
// },
// },
// });
//
// /* Copilot Plugin */
// AFFiNE.use('copilot', {
// openai: {
// apiKey: 'your-key',
// },
// fal: {
// apiKey: 'your-key',
// },
// unsplashKey: 'your-key',
// storage: {
// provider: 'cloudflare-r2',
// bucket: 'copilot',
// }
// })

View File

@ -0,0 +1,81 @@
import {
defineRuntimeConfig,
defineStartupConfig,
ModuleConfig,
} from '../../fundamentals/config';
export interface AuthStartupConfigurations {
/**
* auth session config
*/
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;
/**
* The minimum and maximum length of the password when registering new users
*/
password: {
min: number;
max: number;
};
}
declare module '../../fundamentals/config' {
interface AppConfig {
auth: ModuleConfig<AuthStartupConfigurations, AuthRuntimeConfigurations>;
}
}
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', {
allowSignup: {
desc: 'Whether allow new registrations',
default: true,
},
'password.min': {
desc: 'The minimum length of user password',
default: 8,
},
'password.max': {
desc: 'The maximum length of user password',
default: 32,
},
});

View File

@ -14,12 +14,7 @@ import {
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
Config,
PaymentRequiredException,
Throttle,
URLHelper,
} from '../../fundamentals';
import { Config, Throttle, URLHelper } from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@ -60,7 +55,7 @@ export class AuthController {
validators.assertValidEmail(credential.email);
const canSignIn = await this.auth.canSignIn(credential.email);
if (!canSignIn) {
throw new PaymentRequiredException(
throw new BadRequestException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
);
}
@ -76,8 +71,11 @@ export class AuthController {
} else {
// send email magic link
const user = await this.user.findUserByEmail(credential.email);
if (!user && !this.config.auth.allowSignup) {
throw new BadRequestException('You are not allows to sign up.');
if (!user) {
const allowSignup = await this.config.runtime.fetch('auth/allowSignup');
if (!allowSignup) {
throw new BadRequestException('You are not allows to sign up.');
}
}
const result = await this.sendSignInEmail(

View File

@ -1,3 +1,5 @@
import './config';
import { Module } from '@nestjs/common';
import { FeatureModule } from '../features';

View File

@ -10,7 +10,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { Config, SkipThrottle, Throttle } from '../../fundamentals';
import { Config, SkipThrottle, Throttle, URLHelper } from '../../fundamentals';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
@ -36,6 +36,7 @@ export class ClientTokenType {
export class AuthResolver {
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly token: TokenService
@ -83,7 +84,14 @@ export class AuthResolver {
@Args('token') token: string,
@Args('newPassword') newPassword: string
) {
validators.assertValidPassword(newPassword);
const config = await this.config.runtime.fetchAll({
'auth/password.max': true,
'auth/password.min': true,
});
validators.assertValidPassword(newPassword, {
min: config['auth/password.min'],
max: config['auth/password.max'],
});
// NOTE: Set & Change password are using the same token type.
const valid = await this.token.verifyToken(
TokenType.ChangePassword,
@ -144,13 +152,9 @@ export class AuthResolver {
user.id
);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const url = this.url.link(callbackUrl, { token });
const res = await this.auth.sendChangePasswordEmail(
user.email,
url.toString()
);
const res = await this.auth.sendChangePasswordEmail(user.email, url);
return !res.rejected.length;
}
@ -170,13 +174,9 @@ export class AuthResolver {
user.id
);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const url = this.url.link(callbackUrl, { token });
const res = await this.auth.sendSetPasswordEmail(
user.email,
url.toString()
);
const res = await this.auth.sendSetPasswordEmail(user.email, url);
return !res.rejected.length;
}
@ -200,10 +200,9 @@ export class AuthResolver {
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const url = this.url.link(callbackUrl, { token });
const res = await this.auth.sendChangeEmail(user.email, url.toString());
const res = await this.auth.sendChangeEmail(user.email, url);
return !res.rejected.length;
}
@ -240,11 +239,8 @@ export class AuthResolver {
user.id
);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', verifyEmailToken);
url.searchParams.set('email', email);
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
const url = this.url.link(callbackUrl, { token: verifyEmailToken, email });
const res = await this.auth.sendVerifyChangeEmail(email, url);
return !res.rejected.length;
}
@ -256,10 +252,9 @@ export class AuthResolver {
) {
const token = await this.token.createToken(TokenType.VerifyEmail, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const url = this.url.link(callbackUrl, { token });
const res = await this.auth.sendVerifyEmail(user.email, url.toString());
const res = await this.auth.sendVerifyEmail(user.email, url);
return !res.rejected.length;
}

View File

@ -61,7 +61,7 @@ export class AuthService implements OnApplicationBootstrap {
sameSite: 'lax',
httpOnly: true,
path: '/',
secure: this.config.https,
secure: this.config.server.https,
};
static readonly sessionCookieName = 'affine_session';
static readonly authUserSeqHeaderName = 'x-auth-user';

View File

@ -1,102 +0,0 @@
import { Module } from '@nestjs/common';
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
import { DeploymentType } from '../fundamentals';
import { Public } from './auth';
export enum ServerFeature {
Copilot = 'copilot',
Payment = 'payment',
OAuth = 'oauth',
}
registerEnumType(ServerFeature, {
name: 'ServerFeature',
});
registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
@ObjectType()
export class PasswordLimitsType {
@Field()
minLength!: number;
@Field()
maxLength!: number;
}
@ObjectType()
export class CredentialsRequirementType {
@Field()
password!: PasswordLimitsType;
}
@ObjectType()
export class ServerConfigType {
@Field({
description:
'server identical name could be shown as badge on user interface',
})
name!: string;
@Field({ description: 'server version' })
version!: string;
@Field({ description: 'server base url' })
baseUrl!: string;
@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(() => CredentialsRequirementType, {
description: 'credentials requirement',
})
credentialsRequirement!: CredentialsRequirementType;
@Field({ description: 'enable telemetry' })
enableTelemetry!: boolean;
}
export class ServerConfigResolver {
@Public()
@Query(() => ServerConfigType, {
description: 'server config',
})
serverConfig(): ServerConfigType {
return {
name: AFFiNE.serverName,
version: AFFiNE.version,
baseUrl: AFFiNE.baseUrl,
type: AFFiNE.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: AFFiNE.type,
features: Array.from(ENABLED_FEATURES),
credentialsRequirement: {
password: AFFiNE.auth.password,
},
enableTelemetry: AFFiNE.telemetry.enabled,
};
}
}
@Module({
providers: [ServerConfigResolver],
})
export class ServerConfigModule {}

View File

@ -0,0 +1,23 @@
import { defineRuntimeConfig, ModuleConfig } from '../../fundamentals/config';
export interface ServerFlags {
earlyAccessControl: boolean;
syncClientVersionCheck: boolean;
}
declare module '../../fundamentals/config' {
interface AppConfig {
flags: ModuleConfig<never, ServerFlags>;
}
}
defineRuntimeConfig('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,
},
});

View File

@ -0,0 +1,12 @@
import './config';
import { Module } from '@nestjs/common';
import { ServerConfigResolver, ServerRuntimeConfigResolver } from './resolver';
@Module({
providers: [ServerConfigResolver, ServerRuntimeConfigResolver],
})
export class ServerConfigModule {}
export { ADD_ENABLED_FEATURES, ServerConfigType } from './resolver';
export { ServerFeature } from './types';

View File

@ -0,0 +1,207 @@
import {
Args,
Field,
GraphQLISODateTime,
Mutation,
ObjectType,
Query,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { RuntimeConfig, RuntimeConfigType } from '@prisma/client';
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { Config, DeploymentType, URLHelper } from '../../fundamentals';
import { Public } from '../auth';
import { Admin } from '../common';
import { ServerFlags } from './config';
import { ServerFeature } from './types';
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
registerEnumType(ServerFeature, {
name: 'ServerFeature',
});
registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
@ObjectType()
export class PasswordLimitsType {
@Field()
minLength!: number;
@Field()
maxLength!: number;
}
@ObjectType()
export class CredentialsRequirementType {
@Field()
password!: PasswordLimitsType;
}
@ObjectType()
export class ServerConfigType {
@Field({
description:
'server identical name could be shown as badge on user interface',
})
name!: string;
@Field({ description: 'server version' })
version!: string;
@Field({ description: 'server base url' })
baseUrl!: string;
@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;
}
registerEnumType(RuntimeConfigType, {
name: 'RuntimeConfigType',
});
@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 {
constructor(
private readonly config: Config,
private readonly url: URLHelper
) {}
@Public()
@Query(() => ServerConfigType, {
description: 'server config',
})
serverConfig(): ServerConfigType {
return {
name: this.config.serverName,
version: this.config.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,
};
}
@ResolveField(() => CredentialsRequirementType, {
description: 'credentials requirement',
})
async credentialsRequirement() {
const config = await this.config.runtime.fetchAll({
'auth/password.max': true,
'auth/password.min': true,
});
return {
password: {
minLength: config['auth/password.min'],
maxLength: config['auth/password.max'],
},
};
}
@ResolveField(() => ServerFlagsType, {
description: 'server flags',
})
async flags(): Promise<ServerFlagsType> {
const records = await this.config.runtime.list('flags');
return records.reduce((flags, record) => {
flags[record.key as keyof ServerFlagsType] = record.value as any;
return flags;
}, {} as ServerFlagsType);
}
}
@Resolver(() => ServerRuntimeConfigType)
export class ServerRuntimeConfigResolver {
constructor(private readonly config: Config) {}
@Admin()
@Query(() => [ServerRuntimeConfigType], {
description: 'get all server runtime configurable settings',
})
serverRuntimeConfig(): Promise<ServerRuntimeConfigType[]> {
return this.config.runtime.list();
}
@Admin()
@Mutation(() => ServerRuntimeConfigType, {
description: 'update server runtime configurable setting',
})
async updateRuntimeConfig(
@Args('id') id: string,
@Args({ type: () => GraphQLJSON, name: 'value' }) value: any
): Promise<ServerRuntimeConfigType> {
return await this.config.runtime.set(id as any, value);
}
@Admin()
@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.config.runtime.set(key as any, updates[key]))
);
return results;
}
}

View File

@ -0,0 +1,5 @@
export enum ServerFeature {
Copilot = 'copilot',
Payment = 'payment',
OAuth = 'oauth',
}

View File

@ -0,0 +1,71 @@
import {
defineRuntimeConfig,
defineStartupConfig,
ModuleConfig,
} from '../../fundamentals/config';
interface DocStartupConfigurations {
manager: {
/**
* Whether auto merge updates into doc snapshot.
*/
enableUpdateAutoMerging: boolean;
/**
* How often the [DocManager] will start a new turn of merging pending updates into doc snapshot.
*
* This is not the latency a new joint client will take to see the latest doc,
* but the buffer time we introduced to reduce the load of our service.
*
* in {ms}
*/
updatePollInterval: number;
/**
* The maximum number of updates that will be pulled from the server at once.
* Existing for avoiding the server to be overloaded when there are too many updates for one doc.
*/
maxUpdatesPullCount: number;
};
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 '../../fundamentals/config' {
interface AppConfig {
doc: ModuleConfig<DocStartupConfigurations, DocRuntimeConfigurations>;
}
}
defineStartupConfig('doc', {
manager: {
enableUpdateAutoMerging: true,
updatePollInterval: 1000,
maxUpdatesPullCount: 100,
},
history: {
interval: 1000,
},
});
defineRuntimeConfig('doc', {
experimentalMergeWithYOcto: {
desc: 'Use `y-octo` to merge updates at the same time when merging using Yjs.',
default: false,
},
});

View File

@ -1,3 +1,5 @@
import './config';
import { Module } from '@nestjs/common';
import { QuotaModule } from '../quota';

View File

@ -133,8 +133,11 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
private async applyUpdates(guid: string, ...updates: Buffer[]): Promise<Doc> {
const doc = await this.recoverDoc(...updates);
const useYocto = await this.config.runtime.fetch(
'doc/experimentalMergeWithYOcto'
);
// test jwst codec
if (this.config.doc.manager.experimentalMergeWithYOcto) {
if (useYocto) {
metrics.jwst.counter('codec_merge_counter').add(1);
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
let log = false;
@ -185,11 +188,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}, this.config.doc.manager.updatePollInterval);
this.logger.log('Automation started');
if (this.config.doc.manager.experimentalMergeWithYOcto) {
this.logger.warn(
'Experimental feature enabled: merge updates with jwst codec is enabled'
);
}
}
/**

View File

@ -95,7 +95,11 @@ export class FeatureManagementService {
email: string,
type: EarlyAccessType = EarlyAccessType.App
) {
if (this.config.featureFlags.earlyAccessPreview && !this.isStaff(email)) {
const earlyAccessControlEnabled = await this.config.runtime.fetch(
'flags/earlyAccessControl'
);
if (earlyAccessControlEnabled && !this.isStaff(email)) {
const user = await this.user.findUserByEmail(email);
if (!user) {
return false;

View File

@ -0,0 +1,30 @@
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
import { StorageProviderType } from '../../fundamentals/storage';
export type StorageConfig<Ext = unknown> = {
provider: StorageProviderType;
bucket: string;
} & Ext;
export interface StorageStartupConfigurations {
avatar: StorageConfig<{ publicLinkFactory: (key: string) => string }>;
blob: StorageConfig;
}
declare module '../../fundamentals/config' {
interface AppConfig {
storages: ModuleConfig<StorageStartupConfigurations>;
}
}
defineStartupConfig('storages', {
avatar: {
provider: 'fs',
bucket: 'avatars',
publicLinkFactory: key => `/api/avatars/${key}`,
},
blob: {
provider: 'fs',
bucket: 'blobs',
},
});

View File

@ -1,3 +1,5 @@
import './config';
import { Module } from '@nestjs/common';
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';

View File

@ -6,19 +6,25 @@ import type {
PutObjectMetadata,
StorageProvider,
} from '../../../fundamentals';
import { Config, OnEvent, StorageProviderFactory } from '../../../fundamentals';
import {
Config,
OnEvent,
StorageProviderFactory,
URLHelper,
} from '../../../fundamentals';
@Injectable()
export class AvatarStorage {
public readonly provider: StorageProvider;
private readonly storageConfig: Config['storage']['storages']['avatar'];
private readonly storageConfig: Config['storages']['avatar'];
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly storageFactory: StorageProviderFactory
) {
this.provider = this.storageFactory.create('avatar');
this.storageConfig = this.config.storage.storages.avatar;
this.storageConfig = this.config.storages.avatar;
this.provider = this.storageFactory.create(this.storageConfig);
}
async put(key: string, blob: BlobInputType, metadata?: PutObjectMetadata) {
@ -26,7 +32,7 @@ export class AvatarStorage {
let link = this.storageConfig.publicLinkFactory(key);
if (link.startsWith('/')) {
link = this.config.baseUrl + link;
link = this.url.link(link);
}
return link;

View File

@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import {
type BlobInputType,
Cache,
Config,
EventEmitter,
type EventPayload,
type ListObjectsMetadata,
@ -16,11 +17,12 @@ export class WorkspaceBlobStorage {
public readonly provider: StorageProvider;
constructor(
private readonly config: Config,
private readonly event: EventEmitter,
private readonly storageFactory: StorageProviderFactory,
private readonly cache: Cache
) {
this.provider = this.storageFactory.create('blob');
this.provider = this.storageFactory.create(this.config.storages.blob);
}
async put(workspaceId: string, key: string, blob: BlobInputType) {

View File

@ -11,7 +11,7 @@ import {
import { Server, Socket } from 'socket.io';
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
import { CallTimer, metrics } from '../../../fundamentals';
import { CallTimer, Config, metrics } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { DocManager } from '../../doc';
import { DocID } from '../../utils/doc';
@ -98,6 +98,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
private connectionCount = 0;
constructor(
private readonly config: Config,
private readonly docManager: DocManager,
private readonly permissions: PermissionService
) {}
@ -115,10 +116,13 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
}
assertVersion(client: Socket, version?: string) {
async assertVersion(client: Socket, version?: string) {
const shouldCheckClientVersion = await this.config.runtime.fetch(
'flags/syncClientVersionCheck'
);
if (
// @todo(@darkskygit): remove this flag after 0.12 goes stable
AFFiNE.featureFlags.syncClientVersionCheck &&
shouldCheckClientVersion &&
version !== AFFiNE.version
) {
client.emit('server-version-rejected', {
@ -180,7 +184,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody('version') version: string | undefined,
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ clientId: string }>> {
this.assertVersion(client, version);
await this.assertVersion(client, version);
await this.assertWorkspaceAccessible(
workspaceId,
user.id,
@ -203,7 +207,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody('version') version: string | undefined,
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ clientId: string }>> {
this.assertVersion(client, version);
await this.assertVersion(client, version);
await this.assertWorkspaceAccessible(
workspaceId,
user.id,

View File

@ -1,26 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import z from 'zod';
function getAuthCredentialValidator() {
const email = z.string().email({ message: 'Invalid email address' });
let password = z.string();
password = password
.min(AFFiNE.auth.password.minLength, {
message: `Password must be ${AFFiNE.auth.password.minLength} or more charactors long`,
})
.max(AFFiNE.auth.password.maxLength, {
message: `Password must be ${AFFiNE.auth.password.maxLength} or fewer charactors long`,
});
return z
.object({
email,
password,
})
.required();
}
function assertValid<T>(z: z.ZodType<T>, value: unknown) {
const result = z.safeParse(value);
@ -35,22 +15,25 @@ function assertValid<T>(z: z.ZodType<T>, value: unknown) {
}
export function assertValidEmail(email: string) {
assertValid(getAuthCredentialValidator().shape.email, email);
assertValid(z.string().email({ message: 'Invalid email address' }), email);
}
export function assertValidPassword(password: string) {
assertValid(getAuthCredentialValidator().shape.password, password);
}
export function assertValidCredential(credential: {
email: string;
password: string;
}) {
assertValid(getAuthCredentialValidator(), credential);
export function assertValidPassword(
password: string,
{ min, max }: { min: number; max: number }
) {
assertValid(
z
.string()
.min(min, { message: `Password must be ${min} or more charactors long` })
.max(max, {
message: `Password must be ${max} or fewer charactors long`,
}),
password
);
}
export const validators = {
assertValidEmail,
assertValidPassword,
assertValidCredential,
};

View File

@ -1,16 +1,5 @@
import type { ApolloDriverConfig } from '@nestjs/apollo';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import type { LeafPaths } from '../utils/types';
import type { AFFiNEStorageConfig } from './storage';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace globalThis {
// eslint-disable-next-line no-var
var AFFiNE: AFFiNEConfig;
}
}
import { AppStartupConfig } from './types';
export type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
@ -22,333 +11,33 @@ export enum DeploymentType {
Selfhosted = 'selfhosted',
}
export type ConfigPaths = LeafPaths<
Omit<
AFFiNEConfig,
| 'ENV_MAP'
| 'version'
| 'type'
| 'isSelfhosted'
| 'flavor'
| 'env'
| 'affine'
| 'deploy'
| 'node'
| 'baseUrl'
| 'origin'
>,
'',
'......'
>;
export type ConfigPaths = LeafPaths<AppStartupConfig, '', '......'>;
/**
* All Configurations that would control AFFiNE server behaviors
*
*/
export interface AFFiNEConfig {
export interface PreDefinedAFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
/**
* Server Identity
*/
serverId: string;
/**
* Name may show on the UI
*/
serverName: string;
/**
* System version
*/
readonly version: string;
/**
* Deployment type, AFFiNE Cloud, or Selfhosted
*/
get type(): DeploymentType;
/**
* Fast detect whether currently deployed in a selfhosted environment
*/
get isSelfhosted(): boolean;
/**
* Server flavor
*/
get flavor(): {
type: string;
graphql: boolean;
sync: boolean;
};
/**
* Application secrets for authentication and data encryption
*/
secrets: {
/**
* Application public key
*
*/
publicKey: string;
/**
* Application private key
*
*/
privateKey: string;
};
/**
* Deployment environment
*/
readonly AFFINE_ENV: AFFINE_ENV;
/**
* alias to `process.env.NODE_ENV`
*
* @default 'development'
* @env NODE_ENV
*/
readonly NODE_ENV: NODE_ENV;
/**
* fast AFFiNE environment judge
*/
get affine(): {
canary: boolean;
beta: boolean;
stable: boolean;
};
/**
* fast environment judge
*/
get node(): {
prod: boolean;
dev: boolean;
test: boolean;
};
get deploy(): boolean;
/**
* Whether the server is hosted on a ssl enabled domain
*/
https: boolean;
/**
* where the server get deployed.
*
* @default 'localhost'
* @env AFFINE_SERVER_HOST
*/
host: string;
/**
* which port the server will listen on
*
* @default 3010
* @env AFFINE_SERVER_PORT
*/
port: number;
/**
* subpath where the server get deployed if there is.
*
* @default '' // empty string
* @env AFFINE_SERVER_SUB_PATH
*/
path: string;
/**
* Readonly property `baseUrl` is the full url of the server consists of `https://HOST:PORT/PATH`.
*
* if `host` is not `localhost` then the port will be ignored
*/
get baseUrl(): string;
/**
* Readonly property `origin` is domain origin in the form of `https://HOST:PORT` without subpath.
*
* if `host` is not `localhost` then the port will be ignored
*/
get origin(): string;
/**
* the database config
*/
db: {
url: string;
};
/**
* the apollo driver config
*/
graphql: ApolloDriverConfig;
/**
* app features flag
*/
featureFlags: {
earlyAccessPreview: boolean;
syncClientVersionCheck: boolean;
};
/**
* Configuration for Object Storage, which defines how blobs and avatar assets are stored.
*/
storage: AFFiNEStorageConfig;
/**
* Rate limiter config
*/
rateLimiter: {
/**
* How long each request will be throttled (seconds)
* @default 60
* @env THROTTLE_TTL
*/
ttl: number;
/**
* How many requests can be made in the given time frame
* @default 120
* @env THROTTLE_LIMIT
*/
limit: number;
};
/**
* authentication config
*/
auth: {
allowSignup: boolean;
/**
* The minimum and maximum length of the password when registering new users
*
* @default [8,32]
*/
password: {
/**
* The minimum length of the password
*
* @default 8
*/
minLength: number;
/**
* The maximum length of the password
*
* @default 32
*/
maxLength: number;
};
session: {
/**
* Application auth expiration time in seconds
*
* @default 15 days
*/
ttl: number;
/**
* Application auth time to refresh in seconds
*
* @default 7 days
*/
ttr: number;
};
/**
* Application access token config
*/
accessToken: {
/**
* Application access token expiration time in seconds
*
* @default 7 days
*/
ttl: number;
/**
* Application refresh token expiration time in seconds
*
* @default 30 days
*/
refreshTokenTtl: number;
};
captcha: {
/**
* whether to enable captcha
*/
enable: boolean;
turnstile: {
/**
* Cloudflare Turnstile CAPTCHA secret
* default value is demo api key, witch always return success
*/
secret: string;
};
challenge: {
/**
* challenge bits length
* default value is 20, which can resolve in 0.5-3 second in M2 MacBook Air in single thread
* @default 20
*/
bits: number;
};
};
};
/**
* Configurations for mail service used to post auth or bussiness mails.
*
* @see https://nodemailer.com/smtp/
*/
mailer?: SMTPTransport.Options;
doc: {
manager: {
/**
* Whether auto merge updates into doc snapshot.
*/
enableUpdateAutoMerging: boolean;
/**
* How often the [DocManager] will start a new turn of merging pending updates into doc snapshot.
*
* This is not the latency a new joint client will take to see the latest doc,
* but the buffer time we introduced to reduce the load of our service.
*
* in {ms}
*/
updatePollInterval: number;
/**
* The maximum number of updates that will be pulled from the server at once.
* Existing for avoiding the server to be overloaded when there are too many updates for one doc.
*/
maxUpdatesPullCount: number;
/**
* 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;
};
history: {
/**
* How long the buffer time of creating a new history snapshot when doc get updated.
*
* in {ms}
*/
interval: number;
};
};
metrics: {
enabled: boolean;
customerIo: {
token: string;
};
};
telemetry: {
enabled: boolean;
token: string;
};
readonly version: string;
readonly type: DeploymentType;
readonly isSelfhosted: boolean;
readonly flavor: { type: string; graphql: boolean; sync: boolean };
readonly affine: { canary: boolean; beta: boolean; stable: boolean };
readonly node: { prod: boolean; dev: boolean; test: boolean };
readonly deploy: boolean;
}
export * from './storage';
export interface AppPluginsConfig {}
export type AFFiNEConfig = PreDefinedAFFiNEConfig &
AppStartupConfig &
AppPluginsConfig;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace globalThis {
// eslint-disable-next-line no-var
var AFFiNE: AFFiNEConfig;
}
}

View File

@ -1,55 +1,16 @@
/// <reference types="../../global.d.ts" />
import { createPrivateKey, createPublicKey } from 'node:crypto';
import { merge } from 'lodash-es';
import pkg from '../../../package.json' assert { type: 'json' };
import type { AFFINE_ENV, NODE_ENV, ServerFlavor } from './def';
import { AFFiNEConfig, DeploymentType } from './def';
import {
AFFINE_ENV,
AFFiNEConfig,
DeploymentType,
NODE_ENV,
PreDefinedAFFiNEConfig,
ServerFlavor,
} from './def';
import { readEnv } from './env';
import { getDefaultAFFiNEStorageConfig } from './storage';
import { defaultStartupConfig } from './register';
// Don't use this in production
const examplePrivateKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
-----END EC PRIVATE KEY-----`;
const ONE_DAY_IN_SEC = 60 * 60 * 24;
const keyPair = (function () {
const AFFINE_PRIVATE_KEY =
process.env.AFFINE_PRIVATE_KEY ?? examplePrivateKey;
const privateKey = createPrivateKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'sec1',
})
.export({
format: 'pem',
type: 'pkcs8',
})
.toString('utf8');
const publicKey = createPublicKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'spki',
})
.export({
format: 'pem',
type: 'spki',
})
.toString('utf8');
return {
publicKey,
privateKey,
};
})();
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
function getPredefinedAFFiNEConfig(): PreDefinedAFFiNEConfig {
const NODE_ENV = readEnv<NODE_ENV>('NODE_ENV', 'development', [
'development',
'test',
@ -83,127 +44,84 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
dev: NODE_ENV === 'development',
test: NODE_ENV === 'test',
};
const defaultConfig = {
serverId: 'affine-nestjs-server',
return {
ENV_MAP: {},
NODE_ENV,
AFFINE_ENV,
serverId: 'some-randome-uuid',
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
version: pkg.version,
get type() {
return deploymentType;
type: deploymentType,
isSelfhosted,
flavor: {
type: flavor,
graphql: flavor === 'graphql' || flavor === 'allinone',
sync: flavor === 'sync' || flavor === 'allinone',
},
get isSelfhosted() {
return isSelfhosted;
},
get flavor() {
return {
type: flavor,
graphql: flavor === 'graphql' || flavor === 'allinone',
sync: flavor === 'sync' || flavor === 'allinone',
};
},
ENV_MAP: {},
AFFINE_ENV,
get affine() {
return affine;
},
NODE_ENV,
get node() {
return node;
},
get deploy() {
return !this.node.dev && !this.node.test;
},
secrets: {
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
},
featureFlags: {
earlyAccessPreview: false,
syncClientVersionCheck: false,
},
https: false,
host: 'localhost',
port: 3010,
path: '',
db: {
url: '',
},
get origin() {
return this.node.dev
? 'http://localhost:8080'
: `${this.https ? 'https' : 'http'}://${this.host}${
this.host === 'localhost' || this.host === '0.0.0.0'
? `:${this.port}`
: ''
}`;
},
get baseUrl() {
return `${this.origin}${this.path}`;
},
graphql: {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
introspection: true,
playground: true,
},
auth: {
allowSignup: true,
password: {
minLength: node.prod ? 8 : 1,
maxLength: 32,
},
session: {
ttl: 15 * ONE_DAY_IN_SEC,
ttr: 7 * ONE_DAY_IN_SEC,
},
accessToken: {
ttl: 7 * ONE_DAY_IN_SEC,
refreshTokenTtl: 30 * ONE_DAY_IN_SEC,
},
captcha: {
enable: false,
turnstile: {
secret: '1x0000000000000000000000000000000AA',
},
challenge: {
bits: 20,
},
},
},
storage: getDefaultAFFiNEStorageConfig(),
rateLimiter: {
ttl: 60,
limit: 120,
},
doc: {
manager: {
enableUpdateAutoMerging: flavor !== 'sync',
updatePollInterval: 3000,
maxUpdatesPullCount: 500,
experimentalMergeWithYOcto: false,
},
history: {
interval: 1000 * 60 * 10 /* 10 mins */,
},
},
metrics: {
enabled: false,
customerIo: {
token: '',
},
},
telemetry: {
enabled: isSelfhosted,
token: '389c0615a69b57cca7d3fa0a4824c930',
},
plugins: {
enabled: new Set(),
use(plugin, config) {
this[plugin] = merge(this[plugin], config || {});
this.enabled.add(plugin);
},
},
} satisfies AFFiNEConfig;
affine,
node,
deploy: !node.dev && !node.test,
};
}
return defaultConfig;
};
export function getAFFiNEConfigModifier(): AFFiNEConfig {
const predefined = getPredefinedAFFiNEConfig() as AFFiNEConfig;
return chainableProxy(predefined);
}
function merge(a: any, b: any) {
if (typeof b !== 'object' || b instanceof Map || b instanceof Set) {
return b;
}
if (Array.isArray(b)) {
if (Array.isArray(a)) {
return a.concat(b);
}
return b;
}
const result = { ...a };
Object.keys(b).forEach(key => {
result[key] = merge(result[key], b[key]);
});
return result;
}
export function mergeConfigOverride(override: any) {
return merge(defaultStartupConfig, override);
}
function chainableProxy(obj: any) {
const keys: Set<string> = new Set(Object.keys(obj));
return new Proxy(obj, {
get(target, prop) {
if (!(prop in target)) {
keys.add(prop as string);
target[prop] = chainableProxy({});
}
return target[prop];
},
set(target, prop, value) {
keys.add(prop as string);
if (
typeof value === 'object' &&
!(
value instanceof Map ||
value instanceof Set ||
value instanceof Array
)
) {
value = chainableProxy(value);
}
target[prop] = value;
return true;
},
ownKeys() {
return Array.from(keys);
},
});
}

View File

@ -1,4 +1,38 @@
import { DynamicModule, FactoryProvider } from '@nestjs/common';
import { merge } from 'lodash-es';
import { AFFiNEConfig } from './def';
import { Config } from './provider';
import { Runtime } from './runtime/service';
export * from './def';
export * from './default';
export { applyEnvToConfig, parseEnvValue } from './env';
export * from './module';
export * from './provider';
export { defineRuntimeConfig, defineStartupConfig } from './register';
export type { AppConfig, ConfigItem, ModuleConfig } from './types';
function createConfigProvider(
override?: DeepPartial<Config>
): FactoryProvider<Config> {
return {
provide: Config,
useFactory: (runtime: Runtime) => {
return Object.freeze(merge({}, globalThis.AFFiNE, override, { runtime }));
},
inject: [Runtime],
};
}
export class ConfigModule {
static forRoot = (override?: DeepPartial<AFFiNEConfig>): DynamicModule => {
const provider = createConfigProvider(override);
return {
global: true,
module: ConfigModule,
providers: [provider, Runtime],
exports: [provider],
};
};
}

View File

@ -1,58 +0,0 @@
import { DynamicModule, FactoryProvider } from '@nestjs/common';
import { merge } from 'lodash-es';
import { ApplyType } from '../utils/types';
import { AFFiNEConfig } from './def';
/**
* @example
*
* import { Config } from '@affine/server'
*
* class TestConfig {
* constructor(private readonly config: Config) {}
* test() {
* return this.config.env
* }
* }
*/
export class Config extends ApplyType<AFFiNEConfig>() {}
function createConfigProvider(
override?: DeepPartial<Config>
): FactoryProvider<Config> {
return {
provide: Config,
useFactory: () => {
const wrapper = new Config();
const config = merge({}, globalThis.AFFiNE, override);
const proxy: Config = new Proxy(wrapper, {
get: (_target, property: keyof Config) => {
const desc = Object.getOwnPropertyDescriptor(
globalThis.AFFiNE,
property
);
if (desc?.get) {
return desc.get.call(proxy);
}
return config[property];
},
});
return proxy;
},
};
}
export class ConfigModule {
static forRoot = (override?: DeepPartial<Config>): DynamicModule => {
const provider = createConfigProvider(override);
return {
global: true,
module: ConfigModule,
providers: [provider],
exports: [provider],
};
};
}

View File

@ -0,0 +1,19 @@
import { ApplyType } from '../utils/types';
import { AFFiNEConfig } from './def';
import type { Runtime } from './runtime/service';
/**
* @example
*
* import { Config } from '@affine/server'
*
* class TestConfig {
* constructor(private readonly config: Config) {}
* test() {
* return this.config.env
* }
* }
*/
export class Config extends ApplyType<AFFiNEConfig>() {
runtime!: Runtime;
}

View File

@ -0,0 +1,66 @@
import { Prisma, RuntimeConfigType } from '@prisma/client';
import { get, merge, set } from 'lodash-es';
import {
AppModulesConfigDef,
AppStartupConfig,
ModuleRuntimeConfigDescriptions,
ModuleStartupConfigDescriptions,
} from './types';
export const defaultStartupConfig: AppStartupConfig = {} as any;
export const defaultRuntimeConfig: Record<
string,
Prisma.RuntimeConfigCreateInput
> = {} as any;
export function runtimeConfigType(val: any): RuntimeConfigType {
if (Array.isArray(val)) {
return RuntimeConfigType.Array;
}
switch (typeof val) {
case 'string':
return RuntimeConfigType.String;
case 'number':
return RuntimeConfigType.Number;
case 'boolean':
return RuntimeConfigType.Boolean;
default:
return RuntimeConfigType.Object;
}
}
function registerRuntimeConfig<T extends keyof AppModulesConfigDef>(
module: T,
configs: ModuleRuntimeConfigDescriptions<T>
) {
Object.entries(configs).forEach(([key, value]) => {
defaultRuntimeConfig[`${module}/${key}`] = {
id: `${module}/${key}`,
module,
key,
description: value.desc,
value: value.default,
type: runtimeConfigType(value.default),
};
});
}
export function defineStartupConfig<T extends keyof AppModulesConfigDef>(
module: T,
configs: ModuleStartupConfigDescriptions<AppModulesConfigDef[T]>
) {
set(
defaultStartupConfig,
module,
merge(get(defaultStartupConfig, module, {}), configs)
);
}
export function defineRuntimeConfig<T extends keyof AppModulesConfigDef>(
module: T,
configs: ModuleRuntimeConfigDescriptions<T>
) {
registerRuntimeConfig(module, configs);
}

View File

@ -0,0 +1,22 @@
import { OnEvent } from '../../event';
import { Payload } from '../../event/def';
import { FlattenedAppRuntimeConfig } from '../types';
declare module '../../event/def' {
interface EventDefinitions {
runtimeConfig: {
[K in keyof FlattenedAppRuntimeConfig]: {
changed: Payload<FlattenedAppRuntimeConfig[K]>;
};
};
}
}
/**
* not implemented yet
*/
export const OnRuntimeConfigChange_DO_NOT_USE = (
nameWithModule: keyof FlattenedAppRuntimeConfig
) => {
return OnEvent(`runtimeConfig.${nameWithModule}.changed`);
};

View File

@ -0,0 +1,242 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { difference, keyBy } from 'lodash-es';
import { Cache } from '../../cache';
import { defer } from '../../utils/promise';
import { defaultRuntimeConfig, runtimeConfigType } from '../register';
import { AppRuntimeConfigModules, FlattenedAppRuntimeConfig } from '../types';
function validateConfigType<K extends keyof FlattenedAppRuntimeConfig>(
key: K,
value: any
) {
const want = defaultRuntimeConfig[key].type;
const get = runtimeConfigType(value);
if (get !== want) {
throw new BadRequestException(
`Invalid runtime config type for '${key}', want '${want}', but get '${get}'`
);
}
}
/**
* runtime.fetch(k) // v1
* runtime.fetchAll(k1, k2, k3) // [v1, v2, v3]
* runtime.set(k, v)
* runtime.update(k, (v) => {
* v.xxx = 'yyy';
* return v
* })
*/
@Injectable()
export class Runtime implements OnApplicationBootstrap {
private readonly logger = new Logger('App:RuntimeConfig');
constructor(
private readonly db: PrismaClient,
// circular deps: runtime => cache => redis(maybe) => config => runtime
@Inject(forwardRef(() => Cache)) private readonly cache: Cache
) {}
async onApplicationBootstrap() {
await this.upgradeDB();
}
async fetch<K extends keyof FlattenedAppRuntimeConfig>(
k: K
): Promise<FlattenedAppRuntimeConfig[K]> {
const cached = await this.loadCache<K>(k);
if (cached !== undefined) {
return cached;
}
const dbValue = await this.loadDb<K>(k);
if (dbValue === undefined) {
throw new Error(`Runtime config ${k} not found`);
}
await this.setCache(k, dbValue);
return dbValue;
}
async fetchAll<
Selector extends { [Key in keyof FlattenedAppRuntimeConfig]?: true },
>(
selector: Selector
): Promise<{
// @ts-expect-error allow
[Key in keyof Selector]: FlattenedAppRuntimeConfig[Key];
}> {
const keys = Object.keys(selector);
if (keys.length === 0) {
return {} as any;
}
const records = await this.db.runtimeConfig.findMany({
select: {
id: true,
value: true,
},
where: {
id: {
in: keys,
},
deletedAt: null,
},
});
const keyed = keyBy(records, 'id');
return keys.reduce((ret, key) => {
ret[key] = keyed[key]?.value ?? defaultRuntimeConfig[key].value;
return ret;
}, {} as any);
}
async list(module?: AppRuntimeConfigModules) {
return await this.db.runtimeConfig.findMany({
where: module ? { module, deletedAt: null } : { deletedAt: null },
});
}
async set<
K extends keyof FlattenedAppRuntimeConfig,
V = FlattenedAppRuntimeConfig[K],
>(key: K, value: V) {
validateConfigType(key, value);
const config = await this.db.runtimeConfig.update({
where: {
id: key,
deletedAt: null,
},
data: {
value: value as any,
},
});
await this.setCache(key, config.value as FlattenedAppRuntimeConfig[K]);
return config;
}
async update<
K extends keyof FlattenedAppRuntimeConfig,
V = FlattenedAppRuntimeConfig[K],
>(k: K, modifier: (v: V) => V | Promise<V>) {
const data = await this.fetch<K>(k);
const updated = await modifier(data as V);
await this.set(k, updated);
return updated;
}
async loadDb<K extends keyof FlattenedAppRuntimeConfig>(
k: K
): Promise<FlattenedAppRuntimeConfig[K] | undefined> {
const v = await this.db.runtimeConfig.findFirst({
where: {
id: k,
deletedAt: null,
},
});
if (v) {
return v.value as FlattenedAppRuntimeConfig[K];
} else {
const record = await this.db.runtimeConfig.create({
data: defaultRuntimeConfig[k],
});
return record.value as any;
}
}
async loadCache<K extends keyof FlattenedAppRuntimeConfig>(
k: K
): Promise<FlattenedAppRuntimeConfig[K] | undefined> {
return this.cache.get<FlattenedAppRuntimeConfig[K]>(`SERVER_RUNTIME:${k}`);
}
async setCache<K extends keyof FlattenedAppRuntimeConfig>(
k: K,
v: FlattenedAppRuntimeConfig[K]
): Promise<boolean> {
return this.cache.set<FlattenedAppRuntimeConfig[K]>(
`SERVER_RUNTIME:${k}`,
v,
{ ttl: 60 * 1000 }
);
}
/**
* Upgrade the DB with latest runtime configs
*/
private async upgradeDB() {
const existingConfig = await this.db.runtimeConfig.findMany({
select: {
id: true,
},
where: {
deletedAt: null,
},
});
const defined = Object.keys(defaultRuntimeConfig);
const existing = existingConfig.map(c => c.id);
const newConfigs = difference(defined, existing);
const deleteConfigs = difference(existing, defined);
if (!newConfigs.length && !deleteConfigs.length) {
return;
}
this.logger.log(`Found runtime config changes, upgrading...`);
const acquired = await this.cache.setnx('runtime:upgrade', 1, {
ttl: 10 * 60 * 1000,
});
await using _ = defer(async () => {
await this.cache.delete('runtime:upgrade');
});
if (acquired) {
for (const key of newConfigs) {
await this.db.runtimeConfig.upsert({
create: defaultRuntimeConfig[key],
// old deleted setting should be restored
update: {
...defaultRuntimeConfig[key],
deletedAt: null,
},
where: {
id: key,
},
});
}
await this.db.runtimeConfig.updateMany({
where: {
id: {
in: deleteConfigs,
},
},
data: {
deletedAt: new Date(),
},
});
}
this.logger.log('Upgrade completed');
}
}

View File

@ -0,0 +1,127 @@
import { Join, PathType } from '../utils/types';
export type ConfigItem<T> = T & { __type: 'ConfigItem' };
type ConfigDef = Record<string, any> | never;
export interface ModuleConfig<
Startup extends ConfigDef = never,
Runtime extends ConfigDef = never,
> {
startup: Startup;
runtime: Runtime;
}
export type RuntimeConfigDescription<T> = {
desc: string;
default: T;
};
type ConfigItemLeaves<T, P extends string = ''> =
T extends Record<string, any>
? {
[K in keyof T]: K extends string
? T[K] extends { __type: 'ConfigItem' }
? K
: T[K] extends PrimitiveType
? K
: Join<K, ConfigItemLeaves<T[K], P>>
: never;
}[keyof T]
: never;
type StartupConfigDescriptions<T extends ConfigDef> = {
[K in keyof T]: T[K] extends Record<string, any>
? T[K] extends ConfigItem<infer V>
? V
: T[K]
: T[K];
};
type ModuleConfigLeaves<T, P extends string = ''> =
T extends Record<string, any>
? {
[K in keyof T]: K extends string
? T[K] extends ModuleConfig<any, any>
? K
: Join<K, ModuleConfigLeaves<T[K], P>>
: never;
}[keyof T]
: never;
type FlattenModuleConfigs<T extends Record<string, any>> = {
// @ts-expect-error allow
[K in ModuleConfigLeaves<T>]: PathType<T, K>;
};
type _AppStartupConfig<T extends Record<string, any>> = {
[K in keyof T]: T[K] extends ModuleConfig<infer S, any>
? S
: _AppStartupConfig<T[K]>;
};
// for extending
export interface AppConfig {}
export type AppModulesConfigDef = FlattenModuleConfigs<AppConfig>;
export type AppConfigModules = keyof AppModulesConfigDef;
export type AppStartupConfig = _AppStartupConfig<AppConfig>;
// app runtime config keyed by module names
export type AppRuntimeConfigByModules = {
[Module in keyof AppModulesConfigDef]: AppModulesConfigDef[Module] extends ModuleConfig<
any,
infer Runtime
>
? Runtime extends never
? never
: {
// @ts-expect-error allow
[K in ConfigItemLeaves<Runtime>]: PathType<
Runtime,
K
> extends infer Config
? Config extends ConfigItem<infer V>
? V
: Config
: never;
}
: never;
};
// names of modules that have runtime config
export type AppRuntimeConfigModules = {
[Module in keyof AppRuntimeConfigByModules]: AppRuntimeConfigByModules[Module] extends never
? never
: Module;
}[keyof AppRuntimeConfigByModules];
// runtime config keyed by module names flattened into config names
// { auth: { allowSignup: boolean } } => { 'auth/allowSignup': boolean }
export type FlattenedAppRuntimeConfig = UnionToIntersection<
{
[Module in keyof AppRuntimeConfigByModules]: AppModulesConfigDef[Module] extends never
? never
: {
[K in keyof AppRuntimeConfigByModules[Module] as K extends string
? `${Module}/${K}`
: never]: AppRuntimeConfigByModules[Module][K];
};
}[keyof AppRuntimeConfigByModules]
>;
export type ModuleStartupConfigDescriptions<T extends ModuleConfig<any, any>> =
T extends ModuleConfig<infer S, any>
? S extends never
? undefined
: StartupConfigDescriptions<S>
: never;
export type ModuleRuntimeConfigDescriptions<
Module extends keyof AppRuntimeConfigByModules,
> = AppModulesConfigDef[Module] extends never
? never
: {
[K in keyof AppRuntimeConfigByModules[Module]]: RuntimeConfigDescription<
AppRuntimeConfigByModules[Module][K]
>;
};

View File

@ -1,35 +1,22 @@
import type { Join, PathType } from '../utils/types';
export type Payload<T> = {
__payload: true;
data: T;
};
export type Join<A extends string, B extends string> = A extends ''
? B
: `${A}.${B}`;
export type PathType<T, Path extends string> = string extends Path
? unknown
: Path extends keyof T
? T[Path]
: Path extends `${infer K}.${infer R}`
? K extends keyof T
? PathType<T[K], R>
: unknown
: unknown;
export type Leaves<T, P extends string = ''> =
T extends Payload<any>
? P
: T extends Record<string, any>
? {
[K in keyof T]: K extends string ? Leaves<T[K], Join<P, K>> : never;
}[keyof T]
: never;
export type Flatten<T> =
Leaves<T> extends infer R
T extends Record<string, any>
? {
// @ts-expect-error yo, ts can't make it
[K in R]: PathType<T, K> extends Payload<infer U> ? U : never;
}
[K in keyof T]: K extends string
? T[K] extends Payload<any>
? K
: Join<K, Leaves<T[K], P>>
: never;
}[keyof T]
: never;
export type Flatten<T extends Record<string, any>> = {
// @ts-expect-error allow
[K in Leaves<T>]: PathType<T, K> extends Payload<infer U> ? U : never;
};

View File

@ -0,0 +1,17 @@
import { ApolloDriverConfig } from '@nestjs/apollo';
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
declare module '../../fundamentals/config' {
interface AppConfig {
graphql: ModuleConfig<ApolloDriverConfig>;
}
}
defineStartupConfig('graphql', {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
introspection: true,
playground: true,
});

View File

@ -1,3 +1,5 @@
import './config';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -25,7 +27,7 @@ export type GraphqlContext = {
useFactory: (config: Config) => {
return {
...config.graphql,
path: `${config.path}/graphql`,
path: `${config.server.path}/graphql`,
csrfPrevention: {
requestHeaders: ['content-type'],
},

View File

@ -42,9 +42,11 @@ test.beforeEach(async t => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
secrets: {
publicKey,
privateKey,
crypto: {
secret: {
publicKey,
privateKey,
},
},
}),
],

View File

@ -13,9 +13,11 @@ test.beforeEach(async t => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
host: 'app.affine.local',
port: 3010,
https: true,
server: {
host: 'app.affine.local',
port: 3010,
https: true,
},
}),
],
providers: [URLHelper],

View File

@ -0,0 +1,53 @@
import { createPrivateKey, createPublicKey } from 'node:crypto';
import { defineStartupConfig, ModuleConfig } from '../config';
declare module '../config' {
interface AppConfig {
crypto: ModuleConfig<{
secret: {
publicKey: string;
privateKey: string;
};
}>;
}
}
// Don't use this in production
const examplePrivateKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
-----END EC PRIVATE KEY-----`;
defineStartupConfig('crypto', {
secret: (function () {
const AFFINE_PRIVATE_KEY =
process.env.AFFINE_PRIVATE_KEY ?? examplePrivateKey;
const privateKey = createPrivateKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'sec1',
})
.export({
format: 'pem',
type: 'pkcs8',
})
.toString('utf8');
const publicKey = createPublicKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'spki',
})
.export({
format: 'pem',
type: 'spki',
})
.toString('utf8');
return {
publicKey,
privateKey,
};
})(),
});

View File

@ -32,11 +32,11 @@ export class CryptoHelper {
constructor(config: Config) {
this.keyPair = {
publicKey: Buffer.from(config.secrets.publicKey, 'utf8'),
privateKey: Buffer.from(config.secrets.privateKey, 'utf8'),
publicKey: Buffer.from(config.crypto.secret.publicKey, 'utf8'),
privateKey: Buffer.from(config.crypto.secret.privateKey, 'utf8'),
sha256: {
publicKey: this.sha256(config.secrets.publicKey),
privateKey: this.sha256(config.secrets.privateKey),
publicKey: this.sha256(config.crypto.secret.publicKey),
privateKey: this.sha256(config.crypto.secret.privateKey),
},
};
}

View File

@ -1,3 +1,5 @@
import './config';
import { Global, Module } from '@nestjs/common';
import { CryptoHelper } from './crypto';

View File

@ -5,35 +5,44 @@ import { Config } from '../config';
@Injectable()
export class URLHelper {
redirectAllowHosts: string[];
private readonly redirectAllowHosts: string[];
readonly origin = this.config.node.dev
? 'http://localhost:8080'
: `${this.config.server.https ? 'https' : 'http'}://${this.config.server.host}${
this.config.server.host === 'localhost' ||
this.config.server.host === '0.0.0.0'
? `:${this.config.server.port}`
: ''
}`;
readonly baseUrl = `${this.origin}${this.config.server.path}`;
readonly home = this.baseUrl;
constructor(private readonly config: Config) {
this.redirectAllowHosts = [this.config.baseUrl];
}
get home() {
return this.config.baseUrl;
this.redirectAllowHosts = [this.baseUrl];
}
stringify(query: Record<string, any>) {
return new URLSearchParams(query).toString();
}
link(path: string, query: Record<string, any> = {}) {
const url = new URL(
this.config.baseUrl + (path.startsWith('/') ? path : '/' + path)
);
url(path: string, query: Record<string, any> = {}) {
const url = new URL(path, this.origin);
for (const key in query) {
url.searchParams.set(key, query[key]);
}
return url.toString();
return url;
}
link(path: string, query: Record<string, any> = {}) {
return this.url(path, query).toString();
}
safeRedirect(res: Response, to: string) {
try {
const finalTo = new URL(decodeURIComponent(to), this.config.baseUrl);
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
for (const host of this.redirectAllowHosts) {
const hostURL = new URL(host);

View File

@ -6,11 +6,12 @@ export {
SessionCache,
} from './cache';
export {
type AFFiNEConfig,
applyEnvToConfig,
Config,
type ConfigPaths,
DeploymentType,
getDefaultAFFiNEStorageConfig,
getAFFiNEConfigModifier,
} from './config';
export * from './error';
export { EventEmitter, type EventPayload, OnEvent } from './event';

View File

@ -0,0 +1,16 @@
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { defineStartupConfig, ModuleConfig } from '../config';
declare module '../config' {
interface AppConfig {
/**
* Configurations for mail service used to post auth or bussiness mails.
*
* @see https://nodemailer.com/smtp/
*/
mailer: ModuleConfig<SMTPTransport.Options>;
}
}
defineStartupConfig('mailer', {});

View File

@ -1,3 +1,5 @@
import './config';
import { Global, Module } from '@nestjs/common';
import { OptionalModule } from '../nestjs';
@ -8,7 +10,7 @@ import { MAILER } from './mailer';
@OptionalModule({
providers: [MAILER],
exports: [MAILER],
requires: ['mailer.auth.user'],
requires: ['mailer.host'],
})
class MailerModule {}

View File

@ -0,0 +1,33 @@
import { defineStartupConfig, ModuleConfig } from '../config';
declare module '../config' {
interface AppConfig {
metrics: ModuleConfig<{
/**
* Enable metric and tracing collection
*/
enabled: boolean;
/**
* Enable telemetry
*/
telemetry: {
enabled: boolean;
token: string;
};
customerIo: {
token: string;
};
}>;
}
}
defineStartupConfig('metrics', {
enabled: false,
telemetry: {
enabled: false,
token: '',
},
customerIo: {
token: '',
},
});

View File

@ -1,3 +1,5 @@
import './config';
import {
Global,
Module,

View File

@ -0,0 +1,42 @@
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
export interface ServerStartupConfigurations {
/**
* Whether the server is hosted on a ssl enabled domain
*/
https: boolean;
/**
* where the server get deployed.
*
* @default 'localhost'
* @env AFFINE_SERVER_HOST
*/
host: string;
/**
* which port the server will listen on
*
* @default 3010
* @env AFFINE_SERVER_PORT
*/
port: number;
/**
* subpath where the server get deployed if there is.
*
* @default '' // empty string
* @env AFFINE_SERVER_SUB_PATH
*/
path: string;
}
declare module '../../fundamentals/config' {
interface AppConfig {
server: ModuleConfig<ServerStartupConfigurations>;
}
}
defineStartupConfig('server', {
https: false,
host: 'localhost',
port: 3010,
path: '',
});

View File

@ -1,2 +1,3 @@
import './config';
export * from './exception';
export * from './optional-module';

View File

@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import { omit } from 'lodash-es';
import { Config, ConfigPaths } from '../config';
import type { AFFiNEConfig, ConfigPaths } from '../config';
export interface OptionalModuleMetadata extends ModuleMetadata {
/**
@ -18,7 +18,7 @@ export interface OptionalModuleMetadata extends ModuleMetadata {
/**
* Only install module if the predication returns true.
*/
if?: (config: Config) => boolean;
if?: (config: AFFiNEConfig) => boolean;
/**
* Defines which feature will be enabled if the module installed.

View File

@ -1,14 +1,28 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { defineStartupConfig, ModuleConfig } from '../config';
export interface FsStorageConfig {
path: string;
}
export interface StorageProvidersConfig {
fs: FsStorageConfig;
fs?: FsStorageConfig;
}
declare module '../config' {
interface AppConfig {
storageProviders: ModuleConfig<StorageProvidersConfig>;
}
}
defineStartupConfig('storageProviders', {
fs: {
path: join(homedir(), '.affine/storage'),
},
});
export type StorageProviderType = keyof StorageProvidersConfig;
export type StorageConfig<Ext = unknown> = {

View File

@ -1,14 +1,16 @@
import './config';
import { Global, Module } from '@nestjs/common';
import { registerStorageProvider, StorageProviderFactory } from './providers';
import { FsStorageProvider } from './providers/fs';
registerStorageProvider('fs', (config, bucket) => {
if (!config.storage.providers.fs) {
if (!config.storageProviders.fs) {
throw new Error('Missing fs storage provider configuration');
}
return new FsStorageProvider(config.storage.providers.fs, bucket);
return new FsStorageProvider(config.storageProviders.fs, bucket);
});
@Global()
@ -19,6 +21,7 @@ registerStorageProvider('fs', (config, bucket) => {
export class StorageProviderModule {}
export * from '../../native';
export type { StorageProviderType } from './config';
export type {
BlobInputType,
BlobOutputType,

View File

@ -15,7 +15,7 @@ import { Readable } from 'node:stream';
import { Logger } from '@nestjs/common';
import { FsStorageConfig } from '../../config/storage';
import { FsStorageConfig } from '../config';
import {
BlobInputType,
GetObjectMetadata,

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Config } from '../../config';
import type { StorageProviderType, Storages } from '../../config/storage';
import { StorageConfig, StorageProviderType } from '../config';
import type { StorageProvider } from './provider';
const availableProviders = new Map<
@ -20,17 +20,14 @@ export function registerStorageProvider(
export class StorageProviderFactory {
constructor(private readonly config: Config) {}
create(storage: Storages): StorageProvider {
const storageConfig = this.config.storage.storages[storage];
const providerFactory = availableProviders.get(storageConfig.provider);
create(storage: StorageConfig): StorageProvider {
const providerFactory = availableProviders.get(storage.provider);
if (!providerFactory) {
throw new Error(
`Unknown storage provider type: ${storageConfig.provider}`
);
throw new Error(`Unknown storage provider type: ${storage.provider}`);
}
return providerFactory(this.config, storageConfig.bucket);
return providerFactory(this.config, storage.bucket);
}
}

View File

@ -1,6 +1,6 @@
import type { Readable } from 'node:stream';
import { StorageProviderType } from '../../config';
import { StorageProviderType } from '../config';
export interface GetObjectMetadata {
/**

View File

@ -0,0 +1,27 @@
import { defineStartupConfig, ModuleConfig } from '../config';
export type ThrottlerType = 'default' | 'strict';
type ThrottlerStartupConfigurations = {
[key in ThrottlerType]: {
ttl: number;
limit: number;
};
};
declare module '../config' {
interface AppConfig {
throttler: ModuleConfig<ThrottlerStartupConfigurations>;
}
}
defineStartupConfig('throttler', {
default: {
ttl: 60,
limit: 120,
},
strict: {
ttl: 60,
limit: 20,
},
});

View File

@ -1,6 +1,8 @@
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { SkipThrottle, Throttle as RawThrottle } from '@nestjs/throttler';
import { ThrottlerType } from './config';
export type Throttlers = 'default' | 'strict' | 'authenticated';
export const THROTTLER_PROTECTED = 'affine_throttler:protected';
@ -25,7 +27,7 @@ export const THROTTLER_PROTECTED = 'affine_throttler:protected';
*
*/
export function Throttle(
type: Throttlers = 'default',
type: ThrottlerType | 'authenticated' = 'default',
override: { limit?: number; ttl?: number } = {}
): MethodDecorator & ClassDecorator {
return applyDecorators(

View File

@ -1,3 +1,5 @@
import './config';
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
@ -14,6 +16,7 @@ import type { Request } from 'express';
import { Config } from '../config';
import { getRequestResponseFromContext } from '../utils/request';
import type { ThrottlerType } from './config';
import { THROTTLER_PROTECTED, Throttlers } from './decorators';
@Injectable()
@ -21,25 +24,14 @@ export class ThrottlerStorage extends ThrottlerStorageService {}
@Injectable()
class CustomOptionsFactory implements ThrottlerOptionsFactory {
constructor(
private readonly config: Config,
private readonly storage: ThrottlerStorage
) {}
constructor(private readonly storage: ThrottlerStorage) {}
createThrottlerOptions() {
const options: ThrottlerModuleOptions = {
throttlers: [
{
name: 'default',
ttl: this.config.rateLimiter.ttl * 1000,
limit: this.config.rateLimiter.limit,
},
{
name: 'strict',
ttl: this.config.rateLimiter.ttl * 1000,
limit: 20,
},
],
throttlers: Object.entries(AFFiNE.throttler).map(([name, config]) => ({
name,
...config,
})),
storage: this.storage,
};
@ -165,7 +157,7 @@ export class CloudThrottlerGuard extends ThrottlerGuard {
return super.canActivate(context);
}
getSpecifiedThrottler(context: ExecutionContext) {
getSpecifiedThrottler(context: ExecutionContext): ThrottlerType | undefined {
const throttler = this.reflector.getAllAndOverride<Throttlers | undefined>(
THROTTLER_PROTECTED,
[context.getHandler(), context.getClass()]

View File

@ -1,4 +1,4 @@
import { defer, retry } from 'rxjs';
import { defer as rxjsDefer, retry } from 'rxjs';
export class RetryablePromise<T> extends Promise<T> {
constructor(
@ -10,7 +10,7 @@ export class RetryablePromise<T> extends Promise<T> {
retryIntervalInMs: number = 300
) {
super((resolve, reject) => {
defer(() => new Promise<T>(executor))
rxjsDefer(() => new Promise<T>(executor))
.pipe(
retry({
count: retryTimes,
@ -42,3 +42,9 @@ export function retryable<Ret = unknown>(
retryIntervalInMs
);
}
export function defer(dispose: () => Promise<void>) {
return {
[Symbol.asyncDispose]: dispose,
};
}

View File

@ -7,7 +7,20 @@ export function ApplyType<T>(): ConstructorOf<T> {
};
}
type Join<Prefix, Suffixes> = Prefix extends string | number
export type PathType<T, Path extends string> =
T extends Record<string, any>
? string extends Path
? unknown
: Path extends keyof T
? T[Path]
: Path extends `${infer K}.${infer R}`
? K extends keyof T
? PathType<T[K], R>
: unknown
: unknown
: unknown;
export type Join<Prefix, Suffixes> = Prefix extends string | number
? Suffixes extends string | number
? Prefix extends ''
? Suffixes
@ -18,7 +31,7 @@ type Join<Prefix, Suffixes> = Prefix extends string | number
export type LeafPaths<
T,
Path extends string = '',
MaxDepth extends string = '...',
MaxDepth extends string = '.....',
Depth extends string = '',
> = Depth extends MaxDepth
? never

View File

@ -13,6 +13,12 @@ declare type PrimitiveType =
| null
| undefined;
declare type UnionToIntersection<T> = (
T extends any ? (x: T) => any : never
) extends (x: infer R) => any
? R
: never;
declare type ConstructorOf<T> = {
new (): T;
};
@ -22,7 +28,7 @@ declare type DeepPartial<T> =
? DeepPartial<U>[]
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends object
: T extends Record<string, any>
? {
[K in keyof T]?: DeepPartial<T[K]>;
}

View File

@ -2,15 +2,22 @@
import './prelude';
import { Logger } from '@nestjs/common';
import { omit } from 'lodash-es';
import { createApp } from './app';
import { URLHelper } from './fundamentals';
const app = await createApp();
const listeningHost = AFFiNE.deploy ? '0.0.0.0' : 'localhost';
await app.listen(AFFiNE.port, listeningHost);
await app.listen(AFFiNE.server.port, listeningHost);
const url = app.get(URLHelper);
const logger = new Logger('App');
logger.log(`AFFiNE Server is running in [${AFFiNE.type}] mode`);
logger.log(`Listening on http://${listeningHost}:${AFFiNE.port}`);
logger.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`);
if (AFFiNE.node.dev) {
logger.log('Startup Configration:');
logger.log(omit(globalThis.AFFiNE, 'ENV_MAP'));
}
logger.log(`Listening on http://${listeningHost}:${AFFiNE.server.port}`);
logger.log(`And the public server should be recognized as ${url.home}`);

View File

@ -1,30 +1,20 @@
import { CopilotConfig } from './copilot';
import { GCloudConfig } from './gcloud/config';
import { OAuthConfig } from './oauth';
import { PaymentConfig } from './payment';
import { RedisOptions } from './redis';
import { R2StorageConfig, S3StorageConfig } from './storage';
import { ModuleStartupConfigDescriptions } from '../fundamentals/config/types';
export interface PluginsConfig {}
export type AvailablePlugins = keyof PluginsConfig;
declare module '../fundamentals/config' {}
declare module '../fundamentals/config' {
interface PluginsConfig {
readonly copilot: CopilotConfig;
readonly payment: PaymentConfig;
readonly redis: RedisOptions;
readonly gcloud: GCloudConfig;
readonly 'cloudflare-r2': R2StorageConfig;
readonly 'aws-s3': S3StorageConfig;
readonly oauth: OAuthConfig;
interface AppConfig {
plugins: PluginsConfig;
}
export type AvailablePlugins = keyof PluginsConfig;
interface AFFiNEConfig {
readonly plugins: {
enabled: Set<AvailablePlugins>;
use<Plugin extends AvailablePlugins>(
plugin: Plugin,
config?: DeepPartial<PluginsConfig[Plugin]>
): void;
} & Partial<PluginsConfig>;
interface AppPluginsConfig {
use<Plugin extends AvailablePlugins>(
plugin: Plugin,
config?: DeepPartial<
ModuleStartupConfigDescriptions<PluginsConfig[Plugin]>
>
): void;
}
}

View File

@ -0,0 +1,26 @@
import type { ClientOptions as OpenAIClientOptions } from 'openai';
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
import { StorageConfig } from '../../fundamentals/storage/config';
import type { FalConfig } from './providers/fal';
export interface CopilotStartupConfigurations {
openai?: OpenAIClientOptions;
fal?: FalConfig;
test?: never;
unsplashKey?: string;
storage: StorageConfig;
}
declare module '../config' {
interface PluginsConfig {
copilot: ModuleConfig<CopilotStartupConfigurations>;
}
}
defineStartupConfig('plugins.copilot', {
storage: {
provider: 'fs',
bucket: 'copilot',
},
});

View File

@ -1,3 +1,5 @@
import './config';
import { ServerFeature } from '../../core/config';
import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota';
@ -43,5 +45,3 @@ registerCopilotProvider(OpenAIProvider);
},
})
export class CopilotModule {}
export type { CopilotConfig } from './types';

View File

@ -2,16 +2,17 @@ import assert from 'node:assert';
import { Injectable, Logger } from '@nestjs/common';
import { Config } from '../../../fundamentals';
import { AFFiNEConfig, Config } from '../../../fundamentals';
import { CopilotStartupConfigurations } from '../config';
import {
CapabilityToCopilotProvider,
CopilotCapability,
CopilotConfig,
CopilotProvider,
CopilotProviderType,
} from '../types';
type CopilotProviderConfig = CopilotConfig[keyof CopilotConfig];
type CopilotProviderConfig =
CopilotStartupConfigurations[keyof CopilotStartupConfigurations];
interface CopilotProviderDefinition<C extends CopilotProviderConfig> {
// constructor signature
@ -37,7 +38,10 @@ const PROVIDER_CAPABILITY_MAP = new Map<
>();
// config assertions for providers
const ASSERT_CONFIG = new Map<CopilotProviderType, (config: Config) => void>();
const ASSERT_CONFIG = new Map<
CopilotProviderType,
(config: AFFiNEConfig) => void
>();
export function registerCopilotProvider<
C extends CopilotProviderConfig = CopilotProviderConfig,
@ -69,7 +73,7 @@ export function registerCopilotProvider<
PROVIDER_CAPABILITY_MAP.set(capability, providers);
}
// register the provider config assertion
ASSERT_CONFIG.set(type, (config: Config) => {
ASSERT_CONFIG.set(type, (config: AFFiNEConfig) => {
assert(config.plugins.copilot);
const providerConfig = config.plugins.copilot[type];
if (!providerConfig) return false;
@ -89,7 +93,7 @@ export function unregisterCopilotProvider(type: CopilotProviderType) {
}
/// Asserts that the config is valid for any registered providers
export function assertProvidersConfigs(config: Config) {
export function assertProvidersConfigs(config: AFFiNEConfig) {
return (
Array.from(ASSERT_CONFIG.values()).findIndex(assertConfig =>
assertConfig(config)

View File

@ -9,6 +9,7 @@ import {
type FileUpload,
type StorageProvider,
StorageProviderFactory,
URLHelper,
} from '../../fundamentals';
@Injectable()
@ -17,10 +18,13 @@ export class CopilotStorage {
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly storageFactory: StorageProviderFactory,
private readonly quota: QuotaManagementService
) {
this.provider = this.storageFactory.create('copilot');
this.provider = this.storageFactory.create(
this.config.plugins.copilot.storage
);
}
async put(
@ -35,7 +39,7 @@ export class CopilotStorage {
// return image base64url for dev environment
return `data:image/png;base64,${blob.toString('base64')}`;
}
return `${this.config.baseUrl}/api/copilot/blob/${name}`;
return this.url.link(`/api/copilot/blob/${name}`);
}
async get(userId: string, workspaceId: string, key: string) {

View File

@ -1,18 +1,9 @@
import { type Tokenizer } from '@affine/server-native';
import { AiPromptRole } from '@prisma/client';
import type { ClientOptions as OpenAIClientOptions } from 'openai';
import { z } from 'zod';
import { fromModelName } from '../../native';
import type { ChatPrompt } from './prompt';
import type { FalConfig } from './providers/fal';
export interface CopilotConfig {
openai: OpenAIClientOptions;
fal: FalConfig;
unsplashKey: string;
test: never;
}
export enum AvailableModels {
// text to text

View File

@ -1 +1,14 @@
export interface GCloudConfig {}
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
export interface GCloudConfig {
enabled: boolean;
}
declare module '../config' {
interface PluginsConfig {
gcloud: ModuleConfig<GCloudConfig>;
}
}
defineStartupConfig('plugins.gcloud', {
enabled: false,
});

View File

@ -1,3 +1,5 @@
import './config';
import { Global } from '@nestjs/common';
import { Plugin } from '../registry';

View File

@ -5,4 +5,8 @@ import './payment';
import './redis';
import './storage';
export { REGISTERED_PLUGINS } from './registry';
export {
enablePlugin,
REGISTERED_PLUGINS,
ENABLED_PLUGINS as USED_PLUGINS,
} from './registry';

View File

@ -1,3 +1,5 @@
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
export interface OAuthProviderConfig {
clientId: string;
clientSecret: string;
@ -29,6 +31,15 @@ type OAuthProviderConfigMapping = {
};
export interface OAuthConfig {
enabled: boolean;
providers: Partial<OAuthProviderConfigMapping>;
}
declare module '../config' {
interface PluginsConfig {
oauth: ModuleConfig<OAuthConfig>;
}
}
defineStartupConfig('plugins.oauth', {
providers: {},
});

View File

@ -12,10 +12,10 @@ import type { Request, Response } from 'express';
import { AuthService, Public } from '../../core/auth';
import { UserService } from '../../core/user';
import { URLHelper } from '../../fundamentals';
import { OAuthProviderName } from './config';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthProviderFactory } from './register';
import { OAuthService } from './service';
import { OAuthProviderName } from './types';
@Controller('/oauth')
export class OAuthController {

View File

@ -1,3 +1,5 @@
import './config';
import { AuthModule } from '../../core/auth';
import { ServerFeature } from '../../core/config';
import { UserModule } from '../../core/user';
@ -22,4 +24,3 @@ import { OAuthService } from './service';
if: config => !!config.plugins.oauth,
})
export class OAuthModule {}
export type { OAuthConfig } from './types';

View File

@ -1,4 +1,4 @@
import { OAuthProviderName } from '../types';
import { OAuthProviderName } from '../config';
export interface OAuthAccount {
id: string;

View File

@ -1,8 +1,8 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Config, URLHelper } from '../../../fundamentals';
import { OAuthProviderName } from '../config';
import { AutoRegisteredOAuthProvider } from '../register';
import { OAuthProviderName } from '../types';
interface AuthTokenResponse {
access_token: string;

View File

@ -1,8 +1,8 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Config, URLHelper } from '../../../fundamentals';
import { OAuthProviderName } from '../config';
import { AutoRegisteredOAuthProvider } from '../register';
import { OAuthProviderName } from '../types';
interface GoogleOAuthTokenResponse {
access_token: string;

View File

@ -7,8 +7,12 @@ import {
import { z } from 'zod';
import { Config, URLHelper } from '../../../fundamentals';
import {
OAuthOIDCProviderConfig,
OAuthProviderName,
OIDCArgs,
} from '../config';
import { AutoRegisteredOAuthProvider } from '../register';
import { OAuthOIDCProviderConfig, OAuthProviderName, OIDCArgs } from '../types';
import { OAuthAccount, Tokens } from './def';
const OIDCTokenSchema = z.object({

View File

@ -1,8 +1,8 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Config } from '../../fundamentals';
import { OAuthProviderName } from './config';
import { OAuthProvider } from './providers/def';
import { OAuthProviderName } from './types';
const PROVIDERS: Map<OAuthProviderName, OAuthProvider> = new Map();

View File

@ -1,8 +1,8 @@
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
import { ServerConfigType } from '../../core/config';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './register';
import { OAuthProviderName } from './types';
registerEnumType(OAuthProviderName, { name: 'OAuthProviderType' });

View File

@ -3,8 +3,8 @@ import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { SessionCache } from '../../fundamentals';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './register';
import { OAuthProviderName } from './types';
const OAUTH_STATE_KEY = 'OAUTH_STATE';

View File

@ -0,0 +1,20 @@
import type { Stripe } from 'stripe';
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
export interface PaymentStartupConfig {
stripe?: {
keys: {
APIKey: string;
webhookKey: string;
};
} & Stripe.StripeConfig;
}
declare module '../config' {
interface PluginsConfig {
payment: ModuleConfig<PaymentStartupConfig>;
}
}
defineStartupConfig('plugins.payment', {});

View File

@ -1,3 +1,5 @@
import './config';
import { ServerFeature } from '../../core/config';
import { FeatureModule } from '../../core/features';
import { Plugin } from '../registry';
@ -26,5 +28,3 @@ import { StripeWebhook } from './webhook';
if: config => config.flavor.graphql,
})
export class PaymentModule {}
export type { PaymentConfig } from './types';

View File

@ -19,7 +19,7 @@ import { groupBy } from 'lodash-es';
import { CurrentUser, Public } from '../../core/auth';
import { UserType } from '../../core/user';
import { Config } from '../../fundamentals';
import { Config, URLHelper } from '../../fundamentals';
import { decodeLookupKey, SubscriptionService } from './service';
import {
InvoiceStatus,
@ -146,8 +146,8 @@ class CreateCheckoutSessionInput {
@Field(() => String, { nullable: true })
coupon!: string | null;
@Field(() => String, { nullable: true })
successCallbackLink!: string | null;
@Field(() => String)
successCallbackLink!: string;
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
@Field(() => String)
@ -158,7 +158,7 @@ class CreateCheckoutSessionInput {
export class SubscriptionResolver {
constructor(
private readonly service: SubscriptionService,
private readonly config: Config
private readonly url: URLHelper
) {}
@Public()
@ -222,8 +222,7 @@ export class SubscriptionResolver {
plan: input.plan,
recurring: input.recurring,
promotionCode: input.coupon,
redirectUrl:
input.successCallbackLink ?? `${this.config.baseUrl}/upgrade-success`,
redirectUrl: this.url.link(input.successCallbackLink),
idempotencyKey: input.idempotencyKey,
});

View File

@ -9,8 +9,8 @@ import { Config } from '../../fundamentals';
export const StripeProvider: FactoryProvider = {
provide: Stripe,
useFactory: (config: Config) => {
assert(config.plugins.payment);
const stripeConfig = config.plugins.payment.stripe;
assert(stripeConfig, 'Stripe configuration is missing');
return new Stripe(stripeConfig.keys.APIKey, omit(stripeConfig, 'keys'));
},

View File

@ -1,17 +1,7 @@
import type { User } from '@prisma/client';
import type { Stripe } from 'stripe';
import type { Payload } from '../../fundamentals/event/def';
export interface PaymentConfig {
stripe: {
keys: {
APIKey: string;
webhookKey: string;
};
} & Stripe.StripeConfig;
}
export enum SubscriptionRecurring {
Monthly = 'monthly',
Yearly = 'yearly',

View File

@ -25,7 +25,7 @@ export class StripeWebhook {
private readonly stripe: Stripe,
private readonly event: EventEmitter2
) {
assert(config.plugins.payment);
assert(config.plugins.payment.stripe);
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
}

View File

@ -0,0 +1,11 @@
import { RedisOptions } from 'ioredis';
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
declare module '../config' {
interface PluginsConfig {
redis: ModuleConfig<RedisOptions>;
}
}
defineStartupConfig('plugins.redis', {});

View File

@ -1,5 +1,6 @@
import './config';
import { Global, Provider, Type } from '@nestjs/common';
import type { RedisOptions } from 'ioredis';
import { Redis } from 'ioredis';
import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis';
@ -64,5 +65,3 @@ const mutexRedisAdapterProvider: Provider = {
requires: ['plugins.redis.host'],
})
export class RedisModule {}
export { RedisOptions };

View File

@ -30,20 +30,20 @@ class Redis extends IORedis implements OnModuleDestroy, OnModuleInit {
@Injectable()
export class CacheRedis extends Redis {
constructor(config: Config) {
super(config.plugins.redis ?? {});
super(config.plugins.redis);
}
}
@Injectable()
export class SessionRedis extends Redis {
constructor(config: Config) {
super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 2 });
super({ ...config.plugins.redis, db: (config.plugins.redis.db ?? 0) + 2 });
}
}
@Injectable()
export class SocketIoRedis extends Redis {
constructor(config: Config) {
super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 3 });
super({ ...config.plugins.redis, db: (config.plugins.redis.db ?? 0) + 3 });
}
}

View File

@ -1,3 +0,0 @@
import { RedisOptions } from 'ioredis';
export type { RedisOptions };

View File

@ -1,11 +1,12 @@
import { omit } from 'lodash-es';
import { get, merge, omit, set } from 'lodash-es';
import { AvailablePlugins } from '../fundamentals/config';
import { OptionalModule, OptionalModuleMetadata } from '../fundamentals/nestjs';
import { AvailablePlugins } from './config';
export const REGISTERED_PLUGINS = new Map<AvailablePlugins, AFFiNEModule>();
export const ENABLED_PLUGINS = new Set<AvailablePlugins>();
function register(plugin: AvailablePlugins, module: AFFiNEModule) {
function registerPlugin(plugin: AvailablePlugins, module: AFFiNEModule) {
REGISTERED_PLUGINS.set(plugin, module);
}
@ -15,8 +16,15 @@ interface PluginModuleMetadata extends OptionalModuleMetadata {
export const Plugin = (options: PluginModuleMetadata) => {
return (target: any) => {
register(options.name, target);
registerPlugin(options.name, target);
return OptionalModule(omit(options, 'name'))(target);
};
};
export function enablePlugin(plugin: AvailablePlugins, config: any = {}) {
config = merge(get(AFFiNE.plugins, plugin), config);
set(AFFiNE.plugins, plugin, config);
ENABLED_PLUGINS.add(plugin);
}

View File

@ -0,0 +1,27 @@
import { S3ClientConfig, S3ClientConfigType } from '@aws-sdk/client-s3';
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
type WARNING = '__YOU_SHOULD_NOT_MANUALLY_CONFIGURATE_THIS_TYPE__';
declare module '../../fundamentals/storage/config' {
interface StorageProvidersConfig {
// the type here is only existing for extends [StorageProviderType] with better type inference and checking.
'cloudflare-r2'?: WARNING;
'aws-s3'?: WARNING;
}
}
export type S3StorageConfig = S3ClientConfigType;
export type R2StorageConfig = S3ClientConfigType & {
accountId?: string;
};
declare module '../config' {
interface PluginsConfig {
'aws-s3': ModuleConfig<S3ClientConfig>;
'cloudflare-r2': ModuleConfig<R2StorageConfig>;
}
}
defineStartupConfig('plugins.aws-s3', {});
defineStartupConfig('plugins.cloudflare-r2', {});

View File

@ -1,3 +1,5 @@
import './config';
import { registerStorageProvider } from '../../fundamentals/storage';
import { Plugin } from '../registry';
import { R2StorageProvider } from './providers/r2';
@ -38,5 +40,3 @@ export class CloudflareR2Module {}
if: config => config.flavor.graphql,
})
export class AwsS3Module {}
export type { R2StorageConfig, S3StorageConfig } from './types';

View File

@ -1,12 +1,15 @@
import assert from 'node:assert';
import { Logger } from '@nestjs/common';
import type { R2StorageConfig } from '../types';
import type { R2StorageConfig } from '../config';
import { S3StorageProvider } from './s3';
export class R2StorageProvider extends S3StorageProvider {
override readonly type = 'cloudflare-r2' as any /* cast 'r2' to 's3' */;
constructor(config: R2StorageConfig, bucket: string) {
assert(config.accountId, 'accountId is required for R2 storage provider');
super(
{
...config,

View File

@ -20,7 +20,7 @@ import {
StorageProvider,
toBuffer,
} from '../../../fundamentals/storage';
import type { S3StorageConfig } from '../types';
import type { S3StorageConfig } from '../config';
export class S3StorageProvider implements StorageProvider {
protected logger: Logger;

View File

@ -1,16 +0,0 @@
import { S3ClientConfigType } from '@aws-sdk/client-s3';
type WARNING = '__YOU_SHOULD_NOT_MANUALLY_CONFIGURATE_THIS_TYPE__';
export type R2StorageConfig = S3ClientConfigType & {
accountId: string;
};
export type S3StorageConfig = S3ClientConfigType;
declare module '../../fundamentals/config/storage' {
interface StorageProvidersConfig {
// the type here is only existing for extends [StorageProviderType] with better type inference and checking.
'cloudflare-r2'?: WARNING;
'aws-s3'?: WARNING;
}
}

Some files were not shown because too many files have changed in this diff Show More