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:
@@ -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;
|
||||||
@@ -22,15 +22,16 @@ model User {
|
|||||||
/// for example, the value will be false if user never registered and invited into a workspace by others.
|
/// for example, the value will be false if user never registered and invited into a workspace by others.
|
||||||
registered Boolean @default(true)
|
registered Boolean @default(true)
|
||||||
|
|
||||||
features UserFeatures[]
|
features UserFeatures[]
|
||||||
customer UserStripeCustomer?
|
customer UserStripeCustomer?
|
||||||
subscriptions UserSubscription[]
|
subscriptions UserSubscription[]
|
||||||
invoices UserInvoice[]
|
invoices UserInvoice[]
|
||||||
workspacePermissions WorkspaceUserPermission[]
|
workspacePermissions WorkspaceUserPermission[]
|
||||||
pagePermissions WorkspacePageUserPermission[]
|
pagePermissions WorkspacePageUserPermission[]
|
||||||
connectedAccounts ConnectedAccount[]
|
connectedAccounts ConnectedAccount[]
|
||||||
sessions UserSession[]
|
sessions UserSession[]
|
||||||
aiSessions AiSession[]
|
aiSessions AiSession[]
|
||||||
|
updatedRuntimeConfigs RuntimeConfig[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -505,3 +506,28 @@ model DataMigration {
|
|||||||
|
|
||||||
@@map("_data_migrations")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ import { UserModule } from './core/user';
|
|||||||
import { WorkspaceModule } from './core/workspaces';
|
import { WorkspaceModule } from './core/workspaces';
|
||||||
import { getOptionalModuleMetadata } from './fundamentals';
|
import { getOptionalModuleMetadata } from './fundamentals';
|
||||||
import { CacheModule } from './fundamentals/cache';
|
import { CacheModule } from './fundamentals/cache';
|
||||||
import type { AvailablePlugins } from './fundamentals/config';
|
import {
|
||||||
import { Config, ConfigModule } from './fundamentals/config';
|
AFFiNEConfig,
|
||||||
|
ConfigModule,
|
||||||
|
mergeConfigOverride,
|
||||||
|
} from './fundamentals/config';
|
||||||
import { EventModule } from './fundamentals/event';
|
import { EventModule } from './fundamentals/event';
|
||||||
import { GqlModule } from './fundamentals/graphql';
|
import { GqlModule } from './fundamentals/graphql';
|
||||||
import { HelpersModule } from './fundamentals/helpers';
|
import { HelpersModule } from './fundamentals/helpers';
|
||||||
@@ -30,6 +33,7 @@ import { StorageProviderModule } from './fundamentals/storage';
|
|||||||
import { RateLimiterModule } from './fundamentals/throttler';
|
import { RateLimiterModule } from './fundamentals/throttler';
|
||||||
import { WebSocketModule } from './fundamentals/websocket';
|
import { WebSocketModule } from './fundamentals/websocket';
|
||||||
import { REGISTERED_PLUGINS } from './plugins';
|
import { REGISTERED_PLUGINS } from './plugins';
|
||||||
|
import { ENABLED_PLUGINS } from './plugins/registry';
|
||||||
|
|
||||||
export const FunctionalityModules = [
|
export const FunctionalityModules = [
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
@@ -47,7 +51,7 @@ export const FunctionalityModules = [
|
|||||||
|
|
||||||
export class AppModuleBuilder {
|
export class AppModuleBuilder {
|
||||||
private readonly modules: AFFiNEModule[] = [];
|
private readonly modules: AFFiNEModule[] = [];
|
||||||
constructor(private readonly config: Config) {}
|
constructor(private readonly config: AFFiNEConfig) {}
|
||||||
|
|
||||||
use(...modules: AFFiNEModule[]): this {
|
use(...modules: AFFiNEModule[]): this {
|
||||||
modules.forEach(m => {
|
modules.forEach(m => {
|
||||||
@@ -90,7 +94,7 @@ export class AppModuleBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useIf(
|
useIf(
|
||||||
predicator: (config: Config) => boolean,
|
predicator: (config: AFFiNEConfig) => boolean,
|
||||||
...modules: AFFiNEModule[]
|
...modules: AFFiNEModule[]
|
||||||
): this {
|
): this {
|
||||||
if (predicator(this.config)) {
|
if (predicator(this.config)) {
|
||||||
@@ -112,6 +116,7 @@ export class AppModuleBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAppModule() {
|
function buildAppModule() {
|
||||||
|
AFFiNE = mergeConfigOverride(AFFiNE);
|
||||||
const factor = new AppModuleBuilder(AFFiNE);
|
const factor = new AppModuleBuilder(AFFiNE);
|
||||||
|
|
||||||
factor
|
factor
|
||||||
@@ -147,8 +152,8 @@ function buildAppModule() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// plugin modules
|
// plugin modules
|
||||||
AFFiNE.plugins.enabled.forEach(name => {
|
ENABLED_PLUGINS.forEach(name => {
|
||||||
const plugin = REGISTERED_PLUGINS.get(name as AvailablePlugins);
|
const plugin = REGISTERED_PLUGINS.get(name);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
throw new Error(`Unknown plugin ${name}`);
|
throw new Error(`Unknown plugin ${name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ export async function createApp() {
|
|||||||
app.useWebSocketAdapter(adapter);
|
app.useWebSocketAdapter(adapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (AFFiNE.isSelfhosted && AFFiNE.telemetry.enabled) {
|
if (AFFiNE.isSelfhosted && AFFiNE.metrics.telemetry.enabled) {
|
||||||
const mixpanel = await import('mixpanel');
|
const mixpanel = await import('mixpanel');
|
||||||
mixpanel.init(AFFiNE.telemetry.token).track('selfhost-server-started', {
|
mixpanel
|
||||||
version: AFFiNE.version,
|
.init(AFFiNE.metrics.telemetry.token)
|
||||||
});
|
.track('selfhost-server-started', {
|
||||||
|
version: AFFiNE.version,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -1,31 +1,22 @@
|
|||||||
// Convenient way to map environment variables to config values.
|
// Convenient way to map environment variables to config values.
|
||||||
AFFiNE.ENV_MAP = {
|
AFFiNE.ENV_MAP = {
|
||||||
AFFINE_SERVER_PORT: ['port', 'int'],
|
AFFINE_SERVER_PORT: ['server.port', 'int'],
|
||||||
AFFINE_SERVER_HOST: 'host',
|
AFFINE_SERVER_HOST: 'server.host',
|
||||||
AFFINE_SERVER_SUB_PATH: 'path',
|
AFFINE_SERVER_SUB_PATH: 'server.path',
|
||||||
AFFINE_SERVER_HTTPS: ['https', 'boolean'],
|
AFFINE_SERVER_HTTPS: ['server.https', 'boolean'],
|
||||||
DATABASE_URL: 'db.url',
|
ENABLE_TELEMETRY: ['metrics.telemetry.enabled', 'boolean'],
|
||||||
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',
|
|
||||||
MAILER_HOST: 'mailer.host',
|
MAILER_HOST: 'mailer.host',
|
||||||
MAILER_PORT: ['mailer.port', 'int'],
|
MAILER_PORT: ['mailer.port', 'int'],
|
||||||
MAILER_USER: 'mailer.auth.user',
|
MAILER_USER: 'mailer.auth.user',
|
||||||
MAILER_PASSWORD: 'mailer.auth.pass',
|
MAILER_PASSWORD: 'mailer.auth.pass',
|
||||||
MAILER_SENDER: 'mailer.from.address',
|
MAILER_SENDER: 'mailer.from.address',
|
||||||
MAILER_SECURE: ['mailer.secure', 'boolean'],
|
MAILER_SECURE: ['mailer.secure', 'boolean'],
|
||||||
THROTTLE_TTL: ['rateLimiter.ttl', 'int'],
|
OAUTH_GOOGLE_CLIENT_ID: 'plugins.oauth.providers.google.clientId',
|
||||||
THROTTLE_LIMIT: ['rateLimiter.limit', 'int'],
|
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'],
|
METRICS_CUSTOMER_IO_TOKEN: ['metrics.customerIo.token', 'string'],
|
||||||
COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey',
|
COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey',
|
||||||
COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey',
|
COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey',
|
||||||
@@ -36,16 +27,6 @@ AFFiNE.ENV_MAP = {
|
|||||||
REDIS_SERVER_PASSWORD: 'plugins.redis.password',
|
REDIS_SERVER_PASSWORD: 'plugins.redis.password',
|
||||||
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
|
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
|
||||||
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', '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_API_KEY: 'plugins.payment.stripe.keys.APIKey',
|
||||||
STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey',
|
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'],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,35 +20,47 @@ const env = process.env;
|
|||||||
AFFiNE.metrics.enabled = !AFFiNE.node.test;
|
AFFiNE.metrics.enabled = !AFFiNE.node.test;
|
||||||
|
|
||||||
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||||
AFFiNE.plugins.use('cloudflare-r2', {
|
AFFiNE.use('cloudflare-r2', {
|
||||||
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
|
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
|
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
|
||||||
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
|
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
AFFiNE.storage.storages.avatar.provider = 'cloudflare-r2';
|
AFFiNE.storages.avatar.provider = 'cloudflare-r2';
|
||||||
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
|
AFFiNE.storages.avatar.bucket = 'account-avatar';
|
||||||
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
|
AFFiNE.storages.avatar.publicLinkFactory = key =>
|
||||||
`https://avatar.affineassets.com/${key}`;
|
`https://avatar.affineassets.com/${key}`;
|
||||||
|
|
||||||
AFFiNE.storage.storages.blob.provider = 'cloudflare-r2';
|
AFFiNE.storages.blob.provider = 'cloudflare-r2';
|
||||||
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
|
AFFiNE.storages.blob.bucket = `workspace-blobs-${
|
||||||
AFFiNE.affine.canary ? 'canary' : 'prod'
|
AFFiNE.affine.canary ? 'canary' : 'prod'
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
AFFiNE.storage.storages.copilot.provider = 'cloudflare-r2';
|
AFFiNE.use('copilot', {
|
||||||
AFFiNE.storage.storages.copilot.bucket = `workspace-copilot-${
|
storage: {
|
||||||
AFFiNE.affine.canary ? 'canary' : 'prod'
|
provider: 'cloudflare-r2',
|
||||||
}`;
|
bucket: `workspace-copilot-${AFFiNE.affine.canary ? 'canary' : 'prod'}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
AFFiNE.plugins.use('copilot', {
|
AFFiNE.use('copilot', {
|
||||||
openai: {},
|
openai: {
|
||||||
fal: {},
|
apiKey: '',
|
||||||
|
},
|
||||||
|
fal: {
|
||||||
|
apiKey: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
AFFiNE.plugins.use('redis');
|
AFFiNE.use('redis', {
|
||||||
AFFiNE.plugins.use('payment', {
|
host: env.REDIS_SERVER_HOST,
|
||||||
|
db: 0,
|
||||||
|
port: 6379,
|
||||||
|
username: env.REDIS_SERVER_USER,
|
||||||
|
password: env.REDIS_SERVER_PASSWORD,
|
||||||
|
});
|
||||||
|
AFFiNE.use('payment', {
|
||||||
stripe: {
|
stripe: {
|
||||||
keys: {
|
keys: {
|
||||||
// fake the key to ensure the server generate full GraphQL Schema even env vars are not set
|
// 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) {
|
if (AFFiNE.deploy) {
|
||||||
AFFiNE.mailer = {
|
AFFiNE.mailer = {
|
||||||
@@ -68,5 +80,5 @@ if (AFFiNE.deploy) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
AFFiNE.plugins.use('gcloud');
|
AFFiNE.use('gcloud');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,22 +26,14 @@
|
|||||||
// AFFiNE.serverName = 'Your Cool AFFiNE Selfhosted Cloud';
|
// AFFiNE.serverName = 'Your Cool AFFiNE Selfhosted Cloud';
|
||||||
//
|
//
|
||||||
// /* Whether the server is deployed behind a HTTPS proxied environment */
|
// /* 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 */
|
// /* 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 */
|
// /* The local port of your server that will listen on */
|
||||||
AFFiNE.port = 3010;
|
AFFiNE.server.port = 3010;
|
||||||
// /* The sub path of your server */
|
// /* The sub path of your server */
|
||||||
// /* For example, if you set `AFFiNE.path = '/affine'`, then the server will be available at `${domain}/affine` */
|
// /* For example, if you set `AFFiNE.server.path = '/affine'`, then the server will be available at `${domain}/affine` */
|
||||||
// AFFiNE.path = '/affine';
|
// AFFiNE.server.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';
|
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
// ###############################################################
|
// ###############################################################
|
||||||
@@ -52,19 +44,12 @@ AFFiNE.port = 3010;
|
|||||||
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
|
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
|
||||||
// AFFiNE.metrics.enabled = true;
|
// 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 = {
|
// AFFiNE.auth.session = {
|
||||||
|
// /* How long the login session would last by default */
|
||||||
// ttl: 15 * 24 * 60 * 60, // 15 days
|
// 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 */
|
// /* 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 */
|
// /* How long the buffer time of creating a new history snapshot when doc get updated */
|
||||||
// AFFiNE.doc.history.interval = 1000 * 60 * 10; // 10 minutes
|
// 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 */
|
// /* How often the manager will start a new turn of merging pending updates into doc snapshot */
|
||||||
// AFFiNE.doc.manager.updatePollInterval = 1000 * 3;
|
// AFFiNE.doc.manager.updatePollInterval = 1000 * 3;
|
||||||
//
|
//
|
||||||
@@ -99,20 +81,20 @@ AFFiNE.port = 3010;
|
|||||||
// /* Redis Plugin */
|
// /* Redis Plugin */
|
||||||
// /* Provide caching and session storing backed by Redis. */
|
// /* Provide caching and session storing backed by Redis. */
|
||||||
// /* Useful when you deploy AFFiNE server in a cluster. */
|
// /* Useful when you deploy AFFiNE server in a cluster. */
|
||||||
// AFFiNE.plugins.use('redis', {
|
// AFFiNE.use('redis', {
|
||||||
// /* override options */
|
// /* override options */
|
||||||
// });
|
// });
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
// /* Payment Plugin */
|
// /* Payment Plugin */
|
||||||
// AFFiNE.plugins.use('payment', {
|
// AFFiNE.use('payment', {
|
||||||
// stripe: { keys: {}, apiVersion: '2023-10-16' },
|
// stripe: { keys: {}, apiVersion: '2023-10-16' },
|
||||||
// });
|
// });
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
// /* Cloudflare R2 Plugin */
|
// /* Cloudflare R2 Plugin */
|
||||||
// /* Enable if you choose to store workspace blobs or user avatars in Cloudflare R2 Storage Service */
|
// /* 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: '',
|
// accountId: '',
|
||||||
// credentials: {
|
// credentials: {
|
||||||
// accessKeyId: '',
|
// accessKeyId: '',
|
||||||
@@ -122,17 +104,17 @@ AFFiNE.port = 3010;
|
|||||||
//
|
//
|
||||||
// /* AWS S3 Plugin */
|
// /* AWS S3 Plugin */
|
||||||
// /* Enable if you choose to store workspace blobs or user avatars in AWS S3 Storage Service */
|
// /* 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: {
|
// credentials: {
|
||||||
// accessKeyId: '',
|
// accessKeyId: '',
|
||||||
// secretAccessKey: '',
|
// secretAccessKey: '',
|
||||||
// })
|
// })
|
||||||
// /* Update the provider of storages */
|
// /* Update the provider of storages */
|
||||||
// AFFiNE.storage.storages.blob.provider = 'r2';
|
// AFFiNE.storages.blob.provider = 'cloudflare-r2';
|
||||||
// AFFiNE.storage.storages.avatar.provider = 'r2';
|
// AFFiNE.storages.avatar.provider = 'cloudflare-r2';
|
||||||
//
|
//
|
||||||
/* OAuth Plugin */
|
// /* OAuth Plugin */
|
||||||
// AFFiNE.plugins.use('oauth', {
|
// AFFiNE.use('oauth', {
|
||||||
// providers: {
|
// providers: {
|
||||||
// github: {
|
// github: {
|
||||||
// clientId: '',
|
// 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',
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|||||||
81
packages/backend/server/src/core/auth/config.ts
Normal file
81
packages/backend/server/src/core/auth/config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -14,12 +14,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import {
|
import { Config, Throttle, URLHelper } from '../../fundamentals';
|
||||||
Config,
|
|
||||||
PaymentRequiredException,
|
|
||||||
Throttle,
|
|
||||||
URLHelper,
|
|
||||||
} from '../../fundamentals';
|
|
||||||
import { UserService } from '../user';
|
import { UserService } from '../user';
|
||||||
import { validators } from '../utils/validators';
|
import { validators } from '../utils/validators';
|
||||||
import { CurrentUser } from './current-user';
|
import { CurrentUser } from './current-user';
|
||||||
@@ -60,7 +55,7 @@ export class AuthController {
|
|||||||
validators.assertValidEmail(credential.email);
|
validators.assertValidEmail(credential.email);
|
||||||
const canSignIn = await this.auth.canSignIn(credential.email);
|
const canSignIn = await this.auth.canSignIn(credential.email);
|
||||||
if (!canSignIn) {
|
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`
|
`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 {
|
} else {
|
||||||
// send email magic link
|
// send email magic link
|
||||||
const user = await this.user.findUserByEmail(credential.email);
|
const user = await this.user.findUserByEmail(credential.email);
|
||||||
if (!user && !this.config.auth.allowSignup) {
|
if (!user) {
|
||||||
throw new BadRequestException('You are not allows to sign up.');
|
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(
|
const result = await this.sendSignInEmail(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { FeatureModule } from '../features';
|
import { FeatureModule } from '../features';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
import { Config, SkipThrottle, Throttle } from '../../fundamentals';
|
import { Config, SkipThrottle, Throttle, URLHelper } from '../../fundamentals';
|
||||||
import { UserService } from '../user';
|
import { UserService } from '../user';
|
||||||
import { UserType } from '../user/types';
|
import { UserType } from '../user/types';
|
||||||
import { validators } from '../utils/validators';
|
import { validators } from '../utils/validators';
|
||||||
@@ -36,6 +36,7 @@ export class ClientTokenType {
|
|||||||
export class AuthResolver {
|
export class AuthResolver {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
|
private readonly url: URLHelper,
|
||||||
private readonly auth: AuthService,
|
private readonly auth: AuthService,
|
||||||
private readonly user: UserService,
|
private readonly user: UserService,
|
||||||
private readonly token: TokenService
|
private readonly token: TokenService
|
||||||
@@ -83,7 +84,14 @@ export class AuthResolver {
|
|||||||
@Args('token') token: string,
|
@Args('token') token: string,
|
||||||
@Args('newPassword') newPassword: 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.
|
// NOTE: Set & Change password are using the same token type.
|
||||||
const valid = await this.token.verifyToken(
|
const valid = await this.token.verifyToken(
|
||||||
TokenType.ChangePassword,
|
TokenType.ChangePassword,
|
||||||
@@ -144,13 +152,9 @@ export class AuthResolver {
|
|||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
const url = this.url.link(callbackUrl, { token });
|
||||||
url.searchParams.set('token', token);
|
|
||||||
|
|
||||||
const res = await this.auth.sendChangePasswordEmail(
|
const res = await this.auth.sendChangePasswordEmail(user.email, url);
|
||||||
user.email,
|
|
||||||
url.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
return !res.rejected.length;
|
return !res.rejected.length;
|
||||||
}
|
}
|
||||||
@@ -170,13 +174,9 @@ export class AuthResolver {
|
|||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
const url = this.url.link(callbackUrl, { token });
|
||||||
url.searchParams.set('token', token);
|
|
||||||
|
|
||||||
const res = await this.auth.sendSetPasswordEmail(
|
const res = await this.auth.sendSetPasswordEmail(user.email, url);
|
||||||
user.email,
|
|
||||||
url.toString()
|
|
||||||
);
|
|
||||||
return !res.rejected.length;
|
return !res.rejected.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +200,9 @@ export class AuthResolver {
|
|||||||
|
|
||||||
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
|
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
|
||||||
|
|
||||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
const url = this.url.link(callbackUrl, { token });
|
||||||
url.searchParams.set('token', token);
|
|
||||||
|
|
||||||
const res = await this.auth.sendChangeEmail(user.email, url.toString());
|
const res = await this.auth.sendChangeEmail(user.email, url);
|
||||||
return !res.rejected.length;
|
return !res.rejected.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,11 +239,8 @@ export class AuthResolver {
|
|||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
const url = this.url.link(callbackUrl, { token: verifyEmailToken, email });
|
||||||
url.searchParams.set('token', verifyEmailToken);
|
const res = await this.auth.sendVerifyChangeEmail(email, url);
|
||||||
url.searchParams.set('email', email);
|
|
||||||
|
|
||||||
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
|
|
||||||
|
|
||||||
return !res.rejected.length;
|
return !res.rejected.length;
|
||||||
}
|
}
|
||||||
@@ -256,10 +252,9 @@ export class AuthResolver {
|
|||||||
) {
|
) {
|
||||||
const token = await this.token.createToken(TokenType.VerifyEmail, user.id);
|
const token = await this.token.createToken(TokenType.VerifyEmail, user.id);
|
||||||
|
|
||||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
const url = this.url.link(callbackUrl, { token });
|
||||||
url.searchParams.set('token', token);
|
|
||||||
|
|
||||||
const res = await this.auth.sendVerifyEmail(user.email, url.toString());
|
const res = await this.auth.sendVerifyEmail(user.email, url);
|
||||||
return !res.rejected.length;
|
return !res.rejected.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: '/',
|
path: '/',
|
||||||
secure: this.config.https,
|
secure: this.config.server.https,
|
||||||
};
|
};
|
||||||
static readonly sessionCookieName = 'affine_session';
|
static readonly sessionCookieName = 'affine_session';
|
||||||
static readonly authUserSeqHeaderName = 'x-auth-user';
|
static readonly authUserSeqHeaderName = 'x-auth-user';
|
||||||
|
|||||||
@@ -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 {}
|
|
||||||
23
packages/backend/server/src/core/config/config.ts
Normal file
23
packages/backend/server/src/core/config/config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
12
packages/backend/server/src/core/config/index.ts
Normal file
12
packages/backend/server/src/core/config/index.ts
Normal 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';
|
||||||
207
packages/backend/server/src/core/config/resolver.ts
Normal file
207
packages/backend/server/src/core/config/resolver.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/backend/server/src/core/config/types.ts
Normal file
5
packages/backend/server/src/core/config/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum ServerFeature {
|
||||||
|
Copilot = 'copilot',
|
||||||
|
Payment = 'payment',
|
||||||
|
OAuth = 'oauth',
|
||||||
|
}
|
||||||
71
packages/backend/server/src/core/doc/config.ts
Normal file
71
packages/backend/server/src/core/doc/config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { QuotaModule } from '../quota';
|
import { QuotaModule } from '../quota';
|
||||||
|
|||||||
@@ -133,8 +133,11 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
private async applyUpdates(guid: string, ...updates: Buffer[]): Promise<Doc> {
|
private async applyUpdates(guid: string, ...updates: Buffer[]): Promise<Doc> {
|
||||||
const doc = await this.recoverDoc(...updates);
|
const doc = await this.recoverDoc(...updates);
|
||||||
|
|
||||||
|
const useYocto = await this.config.runtime.fetch(
|
||||||
|
'doc/experimentalMergeWithYOcto'
|
||||||
|
);
|
||||||
// test jwst codec
|
// test jwst codec
|
||||||
if (this.config.doc.manager.experimentalMergeWithYOcto) {
|
if (useYocto) {
|
||||||
metrics.jwst.counter('codec_merge_counter').add(1);
|
metrics.jwst.counter('codec_merge_counter').add(1);
|
||||||
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
|
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
|
||||||
let log = false;
|
let log = false;
|
||||||
@@ -185,11 +188,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
}, this.config.doc.manager.updatePollInterval);
|
}, this.config.doc.manager.updatePollInterval);
|
||||||
|
|
||||||
this.logger.log('Automation started');
|
this.logger.log('Automation started');
|
||||||
if (this.config.doc.manager.experimentalMergeWithYOcto) {
|
|
||||||
this.logger.warn(
|
|
||||||
'Experimental feature enabled: merge updates with jwst codec is enabled'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ export class FeatureManagementService {
|
|||||||
email: string,
|
email: string,
|
||||||
type: EarlyAccessType = EarlyAccessType.App
|
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);
|
const user = await this.user.findUserByEmail(email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
30
packages/backend/server/src/core/storage/config.ts
Normal file
30
packages/backend/server/src/core/storage/config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';
|
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';
|
||||||
|
|||||||
@@ -6,19 +6,25 @@ import type {
|
|||||||
PutObjectMetadata,
|
PutObjectMetadata,
|
||||||
StorageProvider,
|
StorageProvider,
|
||||||
} from '../../../fundamentals';
|
} from '../../../fundamentals';
|
||||||
import { Config, OnEvent, StorageProviderFactory } from '../../../fundamentals';
|
import {
|
||||||
|
Config,
|
||||||
|
OnEvent,
|
||||||
|
StorageProviderFactory,
|
||||||
|
URLHelper,
|
||||||
|
} from '../../../fundamentals';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AvatarStorage {
|
export class AvatarStorage {
|
||||||
public readonly provider: StorageProvider;
|
public readonly provider: StorageProvider;
|
||||||
private readonly storageConfig: Config['storage']['storages']['avatar'];
|
private readonly storageConfig: Config['storages']['avatar'];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
|
private readonly url: URLHelper,
|
||||||
private readonly storageFactory: StorageProviderFactory
|
private readonly storageFactory: StorageProviderFactory
|
||||||
) {
|
) {
|
||||||
this.provider = this.storageFactory.create('avatar');
|
this.storageConfig = this.config.storages.avatar;
|
||||||
this.storageConfig = this.config.storage.storages.avatar;
|
this.provider = this.storageFactory.create(this.storageConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(key: string, blob: BlobInputType, metadata?: PutObjectMetadata) {
|
async put(key: string, blob: BlobInputType, metadata?: PutObjectMetadata) {
|
||||||
@@ -26,7 +32,7 @@ export class AvatarStorage {
|
|||||||
let link = this.storageConfig.publicLinkFactory(key);
|
let link = this.storageConfig.publicLinkFactory(key);
|
||||||
|
|
||||||
if (link.startsWith('/')) {
|
if (link.startsWith('/')) {
|
||||||
link = this.config.baseUrl + link;
|
link = this.url.link(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
return link;
|
return link;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import {
|
import {
|
||||||
type BlobInputType,
|
type BlobInputType,
|
||||||
Cache,
|
Cache,
|
||||||
|
Config,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
type EventPayload,
|
type EventPayload,
|
||||||
type ListObjectsMetadata,
|
type ListObjectsMetadata,
|
||||||
@@ -16,11 +17,12 @@ export class WorkspaceBlobStorage {
|
|||||||
public readonly provider: StorageProvider;
|
public readonly provider: StorageProvider;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
private readonly event: EventEmitter,
|
private readonly event: EventEmitter,
|
||||||
private readonly storageFactory: StorageProviderFactory,
|
private readonly storageFactory: StorageProviderFactory,
|
||||||
private readonly cache: Cache
|
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) {
|
async put(workspaceId: string, key: string, blob: BlobInputType) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||||
|
|
||||||
import { CallTimer, metrics } from '../../../fundamentals';
|
import { CallTimer, Config, metrics } from '../../../fundamentals';
|
||||||
import { Auth, CurrentUser } from '../../auth';
|
import { Auth, CurrentUser } from '../../auth';
|
||||||
import { DocManager } from '../../doc';
|
import { DocManager } from '../../doc';
|
||||||
import { DocID } from '../../utils/doc';
|
import { DocID } from '../../utils/doc';
|
||||||
@@ -98,6 +98,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
private connectionCount = 0;
|
private connectionCount = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
private readonly docManager: DocManager,
|
private readonly docManager: DocManager,
|
||||||
private readonly permissions: PermissionService
|
private readonly permissions: PermissionService
|
||||||
) {}
|
) {}
|
||||||
@@ -115,10 +116,13 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
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 (
|
if (
|
||||||
// @todo(@darkskygit): remove this flag after 0.12 goes stable
|
// @todo(@darkskygit): remove this flag after 0.12 goes stable
|
||||||
AFFiNE.featureFlags.syncClientVersionCheck &&
|
shouldCheckClientVersion &&
|
||||||
version !== AFFiNE.version
|
version !== AFFiNE.version
|
||||||
) {
|
) {
|
||||||
client.emit('server-version-rejected', {
|
client.emit('server-version-rejected', {
|
||||||
@@ -180,7 +184,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody('version') version: string | undefined,
|
@MessageBody('version') version: string | undefined,
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse<{ clientId: string }>> {
|
): Promise<EventResponse<{ clientId: string }>> {
|
||||||
this.assertVersion(client, version);
|
await this.assertVersion(client, version);
|
||||||
await this.assertWorkspaceAccessible(
|
await this.assertWorkspaceAccessible(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
user.id,
|
||||||
@@ -203,7 +207,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody('version') version: string | undefined,
|
@MessageBody('version') version: string | undefined,
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse<{ clientId: string }>> {
|
): Promise<EventResponse<{ clientId: string }>> {
|
||||||
this.assertVersion(client, version);
|
await this.assertVersion(client, version);
|
||||||
await this.assertWorkspaceAccessible(
|
await this.assertWorkspaceAccessible(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
user.id,
|
||||||
|
|||||||
@@ -1,26 +1,6 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import z from 'zod';
|
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) {
|
function assertValid<T>(z: z.ZodType<T>, value: unknown) {
|
||||||
const result = z.safeParse(value);
|
const result = z.safeParse(value);
|
||||||
|
|
||||||
@@ -35,22 +15,25 @@ function assertValid<T>(z: z.ZodType<T>, value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function assertValidEmail(email: string) {
|
export function assertValidEmail(email: string) {
|
||||||
assertValid(getAuthCredentialValidator().shape.email, email);
|
assertValid(z.string().email({ message: 'Invalid email address' }), email);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertValidPassword(password: string) {
|
export function assertValidPassword(
|
||||||
assertValid(getAuthCredentialValidator().shape.password, password);
|
password: string,
|
||||||
}
|
{ min, max }: { min: number; max: number }
|
||||||
|
) {
|
||||||
export function assertValidCredential(credential: {
|
assertValid(
|
||||||
email: string;
|
z
|
||||||
password: string;
|
.string()
|
||||||
}) {
|
.min(min, { message: `Password must be ${min} or more charactors long` })
|
||||||
assertValid(getAuthCredentialValidator(), credential);
|
.max(max, {
|
||||||
|
message: `Password must be ${max} or fewer charactors long`,
|
||||||
|
}),
|
||||||
|
password
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validators = {
|
export const validators = {
|
||||||
assertValidEmail,
|
assertValidEmail,
|
||||||
assertValidPassword,
|
assertValidPassword,
|
||||||
assertValidCredential,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { LeafPaths } from '../utils/types';
|
||||||
import type { AFFiNEStorageConfig } from './storage';
|
import { AppStartupConfig } from './types';
|
||||||
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
namespace globalThis {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var AFFiNE: AFFiNEConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
|
export type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
|
||||||
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
|
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
|
||||||
@@ -22,333 +11,33 @@ export enum DeploymentType {
|
|||||||
Selfhosted = 'selfhosted',
|
Selfhosted = 'selfhosted',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConfigPaths = LeafPaths<
|
export type ConfigPaths = LeafPaths<AppStartupConfig, '', '......'>;
|
||||||
Omit<
|
|
||||||
AFFiNEConfig,
|
|
||||||
| 'ENV_MAP'
|
|
||||||
| 'version'
|
|
||||||
| 'type'
|
|
||||||
| 'isSelfhosted'
|
|
||||||
| 'flavor'
|
|
||||||
| 'env'
|
|
||||||
| 'affine'
|
|
||||||
| 'deploy'
|
|
||||||
| 'node'
|
|
||||||
| 'baseUrl'
|
|
||||||
| 'origin'
|
|
||||||
>,
|
|
||||||
'',
|
|
||||||
'......'
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
export interface PreDefinedAFFiNEConfig {
|
||||||
* All Configurations that would control AFFiNE server behaviors
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export interface AFFiNEConfig {
|
|
||||||
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
|
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
|
||||||
/**
|
|
||||||
* Server Identity
|
|
||||||
*/
|
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Name may show on the UI
|
|
||||||
*/
|
|
||||||
serverName: string;
|
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;
|
readonly AFFINE_ENV: AFFINE_ENV;
|
||||||
/**
|
|
||||||
* alias to `process.env.NODE_ENV`
|
|
||||||
*
|
|
||||||
* @default 'development'
|
|
||||||
* @env NODE_ENV
|
|
||||||
*/
|
|
||||||
readonly NODE_ENV: NODE_ENV;
|
readonly NODE_ENV: NODE_ENV;
|
||||||
|
readonly version: string;
|
||||||
/**
|
readonly type: DeploymentType;
|
||||||
* fast AFFiNE environment judge
|
readonly isSelfhosted: boolean;
|
||||||
*/
|
readonly flavor: { type: string; graphql: boolean; sync: boolean };
|
||||||
get affine(): {
|
readonly affine: { canary: boolean; beta: boolean; stable: boolean };
|
||||||
canary: boolean;
|
readonly node: { prod: boolean; dev: boolean; test: boolean };
|
||||||
beta: boolean;
|
readonly deploy: 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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 pkg from '../../../package.json' assert { type: 'json' };
|
||||||
import type { AFFINE_ENV, NODE_ENV, ServerFlavor } from './def';
|
import {
|
||||||
import { AFFiNEConfig, DeploymentType } from './def';
|
AFFINE_ENV,
|
||||||
|
AFFiNEConfig,
|
||||||
|
DeploymentType,
|
||||||
|
NODE_ENV,
|
||||||
|
PreDefinedAFFiNEConfig,
|
||||||
|
ServerFlavor,
|
||||||
|
} from './def';
|
||||||
import { readEnv } from './env';
|
import { readEnv } from './env';
|
||||||
import { getDefaultAFFiNEStorageConfig } from './storage';
|
import { defaultStartupConfig } from './register';
|
||||||
|
|
||||||
// Don't use this in production
|
function getPredefinedAFFiNEConfig(): PreDefinedAFFiNEConfig {
|
||||||
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 = () => {
|
|
||||||
const NODE_ENV = readEnv<NODE_ENV>('NODE_ENV', 'development', [
|
const NODE_ENV = readEnv<NODE_ENV>('NODE_ENV', 'development', [
|
||||||
'development',
|
'development',
|
||||||
'test',
|
'test',
|
||||||
@@ -83,127 +44,84 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
|||||||
dev: NODE_ENV === 'development',
|
dev: NODE_ENV === 'development',
|
||||||
test: NODE_ENV === 'test',
|
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',
|
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
get type() {
|
type: deploymentType,
|
||||||
return deploymentType;
|
isSelfhosted,
|
||||||
|
flavor: {
|
||||||
|
type: flavor,
|
||||||
|
graphql: flavor === 'graphql' || flavor === 'allinone',
|
||||||
|
sync: flavor === 'sync' || flavor === 'allinone',
|
||||||
},
|
},
|
||||||
get isSelfhosted() {
|
affine,
|
||||||
return isSelfhosted;
|
node,
|
||||||
},
|
deploy: !node.dev && !node.test,
|
||||||
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;
|
|
||||||
|
|
||||||
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 './def';
|
||||||
export * from './default';
|
export * from './default';
|
||||||
export { applyEnvToConfig, parseEnvValue } from './env';
|
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],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
19
packages/backend/server/src/fundamentals/config/provider.ts
Normal file
19
packages/backend/server/src/fundamentals/config/provider.ts
Normal 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;
|
||||||
|
}
|
||||||
66
packages/backend/server/src/fundamentals/config/register.ts
Normal file
66
packages/backend/server/src/fundamentals/config/register.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { OnEvent } from '../../event';
|
||||||
|
import { Payload } from '../../event/def';
|
||||||
|
import { FlattenedAppRuntimeConfig } from '../types';
|
||||||
|
|
||||||
|
declare module '../../event/def' {
|
||||||
|
interface EventDefinitions {
|
||||||
|
runtimeConfig: {
|
||||||
|
[K in keyof FlattenedAppRuntimeConfig]: {
|
||||||
|
changed: Payload<FlattenedAppRuntimeConfig[K]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* not implemented yet
|
||||||
|
*/
|
||||||
|
export const OnRuntimeConfigChange_DO_NOT_USE = (
|
||||||
|
nameWithModule: keyof FlattenedAppRuntimeConfig
|
||||||
|
) => {
|
||||||
|
return OnEvent(`runtimeConfig.${nameWithModule}.changed`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,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');
|
||||||
|
}
|
||||||
|
}
|
||||||
127
packages/backend/server/src/fundamentals/config/types.ts
Normal file
127
packages/backend/server/src/fundamentals/config/types.ts
Normal 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]
|
||||||
|
>;
|
||||||
|
};
|
||||||
@@ -1,35 +1,22 @@
|
|||||||
|
import type { Join, PathType } from '../utils/types';
|
||||||
|
|
||||||
export type Payload<T> = {
|
export type Payload<T> = {
|
||||||
__payload: true;
|
__payload: true;
|
||||||
data: T;
|
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 = ''> =
|
export type Leaves<T, P extends string = ''> =
|
||||||
T extends Payload<any>
|
T extends Record<string, 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
|
|
||||||
? {
|
? {
|
||||||
// @ts-expect-error yo, ts can't make it
|
[K in keyof T]: K extends string
|
||||||
[K in R]: PathType<T, K> extends Payload<infer U> ? U : never;
|
? T[K] extends Payload<any>
|
||||||
}
|
? K
|
||||||
|
: Join<K, Leaves<T[K], P>>
|
||||||
|
: never;
|
||||||
|
}[keyof T]
|
||||||
: never;
|
: 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;
|
||||||
|
};
|
||||||
|
|||||||
17
packages/backend/server/src/fundamentals/graphql/config.ts
Normal file
17
packages/backend/server/src/fundamentals/graphql/config.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export type GraphqlContext = {
|
|||||||
useFactory: (config: Config) => {
|
useFactory: (config: Config) => {
|
||||||
return {
|
return {
|
||||||
...config.graphql,
|
...config.graphql,
|
||||||
path: `${config.path}/graphql`,
|
path: `${config.server.path}/graphql`,
|
||||||
csrfPrevention: {
|
csrfPrevention: {
|
||||||
requestHeaders: ['content-type'],
|
requestHeaders: ['content-type'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ test.beforeEach(async t => {
|
|||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
secrets: {
|
crypto: {
|
||||||
publicKey,
|
secret: {
|
||||||
privateKey,
|
publicKey,
|
||||||
|
privateKey,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ test.beforeEach(async t => {
|
|||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
host: 'app.affine.local',
|
server: {
|
||||||
port: 3010,
|
host: 'app.affine.local',
|
||||||
https: true,
|
port: 3010,
|
||||||
|
https: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [URLHelper],
|
providers: [URLHelper],
|
||||||
|
|||||||
53
packages/backend/server/src/fundamentals/helpers/config.ts
Normal file
53
packages/backend/server/src/fundamentals/helpers/config.ts
Normal 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,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
});
|
||||||
@@ -32,11 +32,11 @@ export class CryptoHelper {
|
|||||||
|
|
||||||
constructor(config: Config) {
|
constructor(config: Config) {
|
||||||
this.keyPair = {
|
this.keyPair = {
|
||||||
publicKey: Buffer.from(config.secrets.publicKey, 'utf8'),
|
publicKey: Buffer.from(config.crypto.secret.publicKey, 'utf8'),
|
||||||
privateKey: Buffer.from(config.secrets.privateKey, 'utf8'),
|
privateKey: Buffer.from(config.crypto.secret.privateKey, 'utf8'),
|
||||||
sha256: {
|
sha256: {
|
||||||
publicKey: this.sha256(config.secrets.publicKey),
|
publicKey: this.sha256(config.crypto.secret.publicKey),
|
||||||
privateKey: this.sha256(config.secrets.privateKey),
|
privateKey: this.sha256(config.crypto.secret.privateKey),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CryptoHelper } from './crypto';
|
import { CryptoHelper } from './crypto';
|
||||||
|
|||||||
@@ -5,35 +5,44 @@ import { Config } from '../config';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class URLHelper {
|
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) {
|
constructor(private readonly config: Config) {
|
||||||
this.redirectAllowHosts = [this.config.baseUrl];
|
this.redirectAllowHosts = [this.baseUrl];
|
||||||
}
|
|
||||||
|
|
||||||
get home() {
|
|
||||||
return this.config.baseUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stringify(query: Record<string, any>) {
|
stringify(query: Record<string, any>) {
|
||||||
return new URLSearchParams(query).toString();
|
return new URLSearchParams(query).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
link(path: string, query: Record<string, any> = {}) {
|
url(path: string, query: Record<string, any> = {}) {
|
||||||
const url = new URL(
|
const url = new URL(path, this.origin);
|
||||||
this.config.baseUrl + (path.startsWith('/') ? path : '/' + path)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const key in query) {
|
for (const key in query) {
|
||||||
url.searchParams.set(key, query[key]);
|
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) {
|
safeRedirect(res: Response, to: string) {
|
||||||
try {
|
try {
|
||||||
const finalTo = new URL(decodeURIComponent(to), this.config.baseUrl);
|
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
|
||||||
|
|
||||||
for (const host of this.redirectAllowHosts) {
|
for (const host of this.redirectAllowHosts) {
|
||||||
const hostURL = new URL(host);
|
const hostURL = new URL(host);
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ export {
|
|||||||
SessionCache,
|
SessionCache,
|
||||||
} from './cache';
|
} from './cache';
|
||||||
export {
|
export {
|
||||||
|
type AFFiNEConfig,
|
||||||
applyEnvToConfig,
|
applyEnvToConfig,
|
||||||
Config,
|
Config,
|
||||||
type ConfigPaths,
|
type ConfigPaths,
|
||||||
DeploymentType,
|
DeploymentType,
|
||||||
getDefaultAFFiNEStorageConfig,
|
getAFFiNEConfigModifier,
|
||||||
} from './config';
|
} from './config';
|
||||||
export * from './error';
|
export * from './error';
|
||||||
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
||||||
|
|||||||
16
packages/backend/server/src/fundamentals/mailer/config.ts
Normal file
16
packages/backend/server/src/fundamentals/mailer/config.ts
Normal 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', {});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OptionalModule } from '../nestjs';
|
import { OptionalModule } from '../nestjs';
|
||||||
@@ -8,7 +10,7 @@ import { MAILER } from './mailer';
|
|||||||
@OptionalModule({
|
@OptionalModule({
|
||||||
providers: [MAILER],
|
providers: [MAILER],
|
||||||
exports: [MAILER],
|
exports: [MAILER],
|
||||||
requires: ['mailer.auth.user'],
|
requires: ['mailer.host'],
|
||||||
})
|
})
|
||||||
class MailerModule {}
|
class MailerModule {}
|
||||||
|
|
||||||
|
|||||||
33
packages/backend/server/src/fundamentals/metrics/config.ts
Normal file
33
packages/backend/server/src/fundamentals/metrics/config.ts
Normal 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: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Global,
|
Global,
|
||||||
Module,
|
Module,
|
||||||
|
|||||||
42
packages/backend/server/src/fundamentals/nestjs/config.ts
Normal file
42
packages/backend/server/src/fundamentals/nestjs/config.ts
Normal 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: '',
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
import './config';
|
||||||
export * from './exception';
|
export * from './exception';
|
||||||
export * from './optional-module';
|
export * from './optional-module';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
|
|
||||||
import { Config, ConfigPaths } from '../config';
|
import type { AFFiNEConfig, ConfigPaths } from '../config';
|
||||||
|
|
||||||
export interface OptionalModuleMetadata extends ModuleMetadata {
|
export interface OptionalModuleMetadata extends ModuleMetadata {
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +18,7 @@ export interface OptionalModuleMetadata extends ModuleMetadata {
|
|||||||
/**
|
/**
|
||||||
* Only install module if the predication returns true.
|
* 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.
|
* Defines which feature will be enabled if the module installed.
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||||
|
|
||||||
export interface FsStorageConfig {
|
export interface FsStorageConfig {
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageProvidersConfig {
|
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 StorageProviderType = keyof StorageProvidersConfig;
|
||||||
|
|
||||||
export type StorageConfig<Ext = unknown> = {
|
export type StorageConfig<Ext = unknown> = {
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { registerStorageProvider, StorageProviderFactory } from './providers';
|
import { registerStorageProvider, StorageProviderFactory } from './providers';
|
||||||
import { FsStorageProvider } from './providers/fs';
|
import { FsStorageProvider } from './providers/fs';
|
||||||
|
|
||||||
registerStorageProvider('fs', (config, bucket) => {
|
registerStorageProvider('fs', (config, bucket) => {
|
||||||
if (!config.storage.providers.fs) {
|
if (!config.storageProviders.fs) {
|
||||||
throw new Error('Missing fs storage provider configuration');
|
throw new Error('Missing fs storage provider configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new FsStorageProvider(config.storage.providers.fs, bucket);
|
return new FsStorageProvider(config.storageProviders.fs, bucket);
|
||||||
});
|
});
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@@ -19,6 +21,7 @@ registerStorageProvider('fs', (config, bucket) => {
|
|||||||
export class StorageProviderModule {}
|
export class StorageProviderModule {}
|
||||||
|
|
||||||
export * from '../../native';
|
export * from '../../native';
|
||||||
|
export type { StorageProviderType } from './config';
|
||||||
export type {
|
export type {
|
||||||
BlobInputType,
|
BlobInputType,
|
||||||
BlobOutputType,
|
BlobOutputType,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Readable } from 'node:stream';
|
|||||||
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { FsStorageConfig } from '../../config/storage';
|
import { FsStorageConfig } from '../config';
|
||||||
import {
|
import {
|
||||||
BlobInputType,
|
BlobInputType,
|
||||||
GetObjectMetadata,
|
GetObjectMetadata,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import type { StorageProviderType, Storages } from '../../config/storage';
|
import { StorageConfig, StorageProviderType } from '../config';
|
||||||
import type { StorageProvider } from './provider';
|
import type { StorageProvider } from './provider';
|
||||||
|
|
||||||
const availableProviders = new Map<
|
const availableProviders = new Map<
|
||||||
@@ -20,17 +20,14 @@ export function registerStorageProvider(
|
|||||||
export class StorageProviderFactory {
|
export class StorageProviderFactory {
|
||||||
constructor(private readonly config: Config) {}
|
constructor(private readonly config: Config) {}
|
||||||
|
|
||||||
create(storage: Storages): StorageProvider {
|
create(storage: StorageConfig): StorageProvider {
|
||||||
const storageConfig = this.config.storage.storages[storage];
|
const providerFactory = availableProviders.get(storage.provider);
|
||||||
const providerFactory = availableProviders.get(storageConfig.provider);
|
|
||||||
|
|
||||||
if (!providerFactory) {
|
if (!providerFactory) {
|
||||||
throw new Error(
|
throw new Error(`Unknown storage provider type: ${storage.provider}`);
|
||||||
`Unknown storage provider type: ${storageConfig.provider}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return providerFactory(this.config, storageConfig.bucket);
|
return providerFactory(this.config, storage.bucket);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Readable } from 'node:stream';
|
import type { Readable } from 'node:stream';
|
||||||
|
|
||||||
import { StorageProviderType } from '../../config';
|
import { StorageProviderType } from '../config';
|
||||||
|
|
||||||
export interface GetObjectMetadata {
|
export interface GetObjectMetadata {
|
||||||
/**
|
/**
|
||||||
|
|||||||
27
packages/backend/server/src/fundamentals/throttler/config.ts
Normal file
27
packages/backend/server/src/fundamentals/throttler/config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
||||||
import { SkipThrottle, Throttle as RawThrottle } from '@nestjs/throttler';
|
import { SkipThrottle, Throttle as RawThrottle } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
import { ThrottlerType } from './config';
|
||||||
|
|
||||||
export type Throttlers = 'default' | 'strict' | 'authenticated';
|
export type Throttlers = 'default' | 'strict' | 'authenticated';
|
||||||
export const THROTTLER_PROTECTED = 'affine_throttler:protected';
|
export const THROTTLER_PROTECTED = 'affine_throttler:protected';
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export const THROTTLER_PROTECTED = 'affine_throttler:protected';
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export function Throttle(
|
export function Throttle(
|
||||||
type: Throttlers = 'default',
|
type: ThrottlerType | 'authenticated' = 'default',
|
||||||
override: { limit?: number; ttl?: number } = {}
|
override: { limit?: number; ttl?: number } = {}
|
||||||
): MethodDecorator & ClassDecorator {
|
): MethodDecorator & ClassDecorator {
|
||||||
return applyDecorators(
|
return applyDecorators(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
|
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +16,7 @@ import type { Request } from 'express';
|
|||||||
|
|
||||||
import { Config } from '../config';
|
import { Config } from '../config';
|
||||||
import { getRequestResponseFromContext } from '../utils/request';
|
import { getRequestResponseFromContext } from '../utils/request';
|
||||||
|
import type { ThrottlerType } from './config';
|
||||||
import { THROTTLER_PROTECTED, Throttlers } from './decorators';
|
import { THROTTLER_PROTECTED, Throttlers } from './decorators';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -21,25 +24,14 @@ export class ThrottlerStorage extends ThrottlerStorageService {}
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
class CustomOptionsFactory implements ThrottlerOptionsFactory {
|
class CustomOptionsFactory implements ThrottlerOptionsFactory {
|
||||||
constructor(
|
constructor(private readonly storage: ThrottlerStorage) {}
|
||||||
private readonly config: Config,
|
|
||||||
private readonly storage: ThrottlerStorage
|
|
||||||
) {}
|
|
||||||
|
|
||||||
createThrottlerOptions() {
|
createThrottlerOptions() {
|
||||||
const options: ThrottlerModuleOptions = {
|
const options: ThrottlerModuleOptions = {
|
||||||
throttlers: [
|
throttlers: Object.entries(AFFiNE.throttler).map(([name, config]) => ({
|
||||||
{
|
name,
|
||||||
name: 'default',
|
...config,
|
||||||
ttl: this.config.rateLimiter.ttl * 1000,
|
})),
|
||||||
limit: this.config.rateLimiter.limit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'strict',
|
|
||||||
ttl: this.config.rateLimiter.ttl * 1000,
|
|
||||||
limit: 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
storage: this.storage,
|
storage: this.storage,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,7 +157,7 @@ export class CloudThrottlerGuard extends ThrottlerGuard {
|
|||||||
return super.canActivate(context);
|
return super.canActivate(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSpecifiedThrottler(context: ExecutionContext) {
|
getSpecifiedThrottler(context: ExecutionContext): ThrottlerType | undefined {
|
||||||
const throttler = this.reflector.getAllAndOverride<Throttlers | undefined>(
|
const throttler = this.reflector.getAllAndOverride<Throttlers | undefined>(
|
||||||
THROTTLER_PROTECTED,
|
THROTTLER_PROTECTED,
|
||||||
[context.getHandler(), context.getClass()]
|
[context.getHandler(), context.getClass()]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defer, retry } from 'rxjs';
|
import { defer as rxjsDefer, retry } from 'rxjs';
|
||||||
|
|
||||||
export class RetryablePromise<T> extends Promise<T> {
|
export class RetryablePromise<T> extends Promise<T> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -10,7 +10,7 @@ export class RetryablePromise<T> extends Promise<T> {
|
|||||||
retryIntervalInMs: number = 300
|
retryIntervalInMs: number = 300
|
||||||
) {
|
) {
|
||||||
super((resolve, reject) => {
|
super((resolve, reject) => {
|
||||||
defer(() => new Promise<T>(executor))
|
rxjsDefer(() => new Promise<T>(executor))
|
||||||
.pipe(
|
.pipe(
|
||||||
retry({
|
retry({
|
||||||
count: retryTimes,
|
count: retryTimes,
|
||||||
@@ -42,3 +42,9 @@ export function retryable<Ret = unknown>(
|
|||||||
retryIntervalInMs
|
retryIntervalInMs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function defer(dispose: () => Promise<void>) {
|
||||||
|
return {
|
||||||
|
[Symbol.asyncDispose]: dispose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
? Suffixes extends string | number
|
||||||
? Prefix extends ''
|
? Prefix extends ''
|
||||||
? Suffixes
|
? Suffixes
|
||||||
@@ -18,7 +31,7 @@ type Join<Prefix, Suffixes> = Prefix extends string | number
|
|||||||
export type LeafPaths<
|
export type LeafPaths<
|
||||||
T,
|
T,
|
||||||
Path extends string = '',
|
Path extends string = '',
|
||||||
MaxDepth extends string = '...',
|
MaxDepth extends string = '.....',
|
||||||
Depth extends string = '',
|
Depth extends string = '',
|
||||||
> = Depth extends MaxDepth
|
> = Depth extends MaxDepth
|
||||||
? never
|
? never
|
||||||
|
|||||||
8
packages/backend/server/src/global.d.ts
vendored
8
packages/backend/server/src/global.d.ts
vendored
@@ -13,6 +13,12 @@ declare type PrimitiveType =
|
|||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
declare type UnionToIntersection<T> = (
|
||||||
|
T extends any ? (x: T) => any : never
|
||||||
|
) extends (x: infer R) => any
|
||||||
|
? R
|
||||||
|
: never;
|
||||||
|
|
||||||
declare type ConstructorOf<T> = {
|
declare type ConstructorOf<T> = {
|
||||||
new (): T;
|
new (): T;
|
||||||
};
|
};
|
||||||
@@ -22,7 +28,7 @@ declare type DeepPartial<T> =
|
|||||||
? DeepPartial<U>[]
|
? DeepPartial<U>[]
|
||||||
: T extends ReadonlyArray<infer U>
|
: T extends ReadonlyArray<infer U>
|
||||||
? ReadonlyArray<DeepPartial<U>>
|
? ReadonlyArray<DeepPartial<U>>
|
||||||
: T extends object
|
: T extends Record<string, any>
|
||||||
? {
|
? {
|
||||||
[K in keyof T]?: DeepPartial<T[K]>;
|
[K in keyof T]?: DeepPartial<T[K]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,22 @@
|
|||||||
import './prelude';
|
import './prelude';
|
||||||
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { omit } from 'lodash-es';
|
||||||
|
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
|
import { URLHelper } from './fundamentals';
|
||||||
|
|
||||||
const app = await createApp();
|
const app = await createApp();
|
||||||
const listeningHost = AFFiNE.deploy ? '0.0.0.0' : 'localhost';
|
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');
|
const logger = new Logger('App');
|
||||||
|
|
||||||
logger.log(`AFFiNE Server is running in [${AFFiNE.type}] mode`);
|
logger.log(`AFFiNE Server is running in [${AFFiNE.type}] mode`);
|
||||||
logger.log(`Listening on http://${listeningHost}:${AFFiNE.port}`);
|
if (AFFiNE.node.dev) {
|
||||||
logger.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`);
|
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}`);
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
import { CopilotConfig } from './copilot';
|
import { ModuleStartupConfigDescriptions } from '../fundamentals/config/types';
|
||||||
import { GCloudConfig } from './gcloud/config';
|
|
||||||
import { OAuthConfig } from './oauth';
|
|
||||||
import { PaymentConfig } from './payment';
|
|
||||||
import { RedisOptions } from './redis';
|
|
||||||
import { R2StorageConfig, S3StorageConfig } from './storage';
|
|
||||||
|
|
||||||
|
export interface PluginsConfig {}
|
||||||
|
export type AvailablePlugins = keyof PluginsConfig;
|
||||||
|
|
||||||
|
declare module '../fundamentals/config' {}
|
||||||
declare module '../fundamentals/config' {
|
declare module '../fundamentals/config' {
|
||||||
interface PluginsConfig {
|
interface AppConfig {
|
||||||
readonly copilot: CopilotConfig;
|
plugins: PluginsConfig;
|
||||||
readonly payment: PaymentConfig;
|
|
||||||
readonly redis: RedisOptions;
|
|
||||||
readonly gcloud: GCloudConfig;
|
|
||||||
readonly 'cloudflare-r2': R2StorageConfig;
|
|
||||||
readonly 'aws-s3': S3StorageConfig;
|
|
||||||
readonly oauth: OAuthConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvailablePlugins = keyof PluginsConfig;
|
interface AppPluginsConfig {
|
||||||
|
use<Plugin extends AvailablePlugins>(
|
||||||
interface AFFiNEConfig {
|
plugin: Plugin,
|
||||||
readonly plugins: {
|
config?: DeepPartial<
|
||||||
enabled: Set<AvailablePlugins>;
|
ModuleStartupConfigDescriptions<PluginsConfig[Plugin]>
|
||||||
use<Plugin extends AvailablePlugins>(
|
>
|
||||||
plugin: Plugin,
|
): void;
|
||||||
config?: DeepPartial<PluginsConfig[Plugin]>
|
|
||||||
): void;
|
|
||||||
} & Partial<PluginsConfig>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
packages/backend/server/src/plugins/copilot/config.ts
Normal file
26
packages/backend/server/src/plugins/copilot/config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { ServerFeature } from '../../core/config';
|
import { ServerFeature } from '../../core/config';
|
||||||
import { FeatureModule } from '../../core/features';
|
import { FeatureModule } from '../../core/features';
|
||||||
import { QuotaModule } from '../../core/quota';
|
import { QuotaModule } from '../../core/quota';
|
||||||
@@ -43,5 +45,3 @@ registerCopilotProvider(OpenAIProvider);
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class CopilotModule {}
|
export class CopilotModule {}
|
||||||
|
|
||||||
export type { CopilotConfig } from './types';
|
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import assert from 'node:assert';
|
|||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config } from '../../../fundamentals';
|
import { AFFiNEConfig, Config } from '../../../fundamentals';
|
||||||
|
import { CopilotStartupConfigurations } from '../config';
|
||||||
import {
|
import {
|
||||||
CapabilityToCopilotProvider,
|
CapabilityToCopilotProvider,
|
||||||
CopilotCapability,
|
CopilotCapability,
|
||||||
CopilotConfig,
|
|
||||||
CopilotProvider,
|
CopilotProvider,
|
||||||
CopilotProviderType,
|
CopilotProviderType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
type CopilotProviderConfig = CopilotConfig[keyof CopilotConfig];
|
type CopilotProviderConfig =
|
||||||
|
CopilotStartupConfigurations[keyof CopilotStartupConfigurations];
|
||||||
|
|
||||||
interface CopilotProviderDefinition<C extends CopilotProviderConfig> {
|
interface CopilotProviderDefinition<C extends CopilotProviderConfig> {
|
||||||
// constructor signature
|
// constructor signature
|
||||||
@@ -37,7 +38,10 @@ const PROVIDER_CAPABILITY_MAP = new Map<
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
// config assertions for providers
|
// 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<
|
export function registerCopilotProvider<
|
||||||
C extends CopilotProviderConfig = CopilotProviderConfig,
|
C extends CopilotProviderConfig = CopilotProviderConfig,
|
||||||
@@ -69,7 +73,7 @@ export function registerCopilotProvider<
|
|||||||
PROVIDER_CAPABILITY_MAP.set(capability, providers);
|
PROVIDER_CAPABILITY_MAP.set(capability, providers);
|
||||||
}
|
}
|
||||||
// register the provider config assertion
|
// register the provider config assertion
|
||||||
ASSERT_CONFIG.set(type, (config: Config) => {
|
ASSERT_CONFIG.set(type, (config: AFFiNEConfig) => {
|
||||||
assert(config.plugins.copilot);
|
assert(config.plugins.copilot);
|
||||||
const providerConfig = config.plugins.copilot[type];
|
const providerConfig = config.plugins.copilot[type];
|
||||||
if (!providerConfig) return false;
|
if (!providerConfig) return false;
|
||||||
@@ -89,7 +93,7 @@ export function unregisterCopilotProvider(type: CopilotProviderType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Asserts that the config is valid for any registered providers
|
/// Asserts that the config is valid for any registered providers
|
||||||
export function assertProvidersConfigs(config: Config) {
|
export function assertProvidersConfigs(config: AFFiNEConfig) {
|
||||||
return (
|
return (
|
||||||
Array.from(ASSERT_CONFIG.values()).findIndex(assertConfig =>
|
Array.from(ASSERT_CONFIG.values()).findIndex(assertConfig =>
|
||||||
assertConfig(config)
|
assertConfig(config)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type FileUpload,
|
type FileUpload,
|
||||||
type StorageProvider,
|
type StorageProvider,
|
||||||
StorageProviderFactory,
|
StorageProviderFactory,
|
||||||
|
URLHelper,
|
||||||
} from '../../fundamentals';
|
} from '../../fundamentals';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -17,10 +18,13 @@ export class CopilotStorage {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
|
private readonly url: URLHelper,
|
||||||
private readonly storageFactory: StorageProviderFactory,
|
private readonly storageFactory: StorageProviderFactory,
|
||||||
private readonly quota: QuotaManagementService
|
private readonly quota: QuotaManagementService
|
||||||
) {
|
) {
|
||||||
this.provider = this.storageFactory.create('copilot');
|
this.provider = this.storageFactory.create(
|
||||||
|
this.config.plugins.copilot.storage
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(
|
async put(
|
||||||
@@ -35,7 +39,7 @@ export class CopilotStorage {
|
|||||||
// return image base64url for dev environment
|
// return image base64url for dev environment
|
||||||
return `data:image/png;base64,${blob.toString('base64')}`;
|
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) {
|
async get(userId: string, workspaceId: string, key: string) {
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { type Tokenizer } from '@affine/server-native';
|
import { type Tokenizer } from '@affine/server-native';
|
||||||
import { AiPromptRole } from '@prisma/client';
|
import { AiPromptRole } from '@prisma/client';
|
||||||
import type { ClientOptions as OpenAIClientOptions } from 'openai';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { fromModelName } from '../../native';
|
import { fromModelName } from '../../native';
|
||||||
import type { ChatPrompt } from './prompt';
|
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 {
|
export enum AvailableModels {
|
||||||
// text to text
|
// text to text
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { Global } from '@nestjs/common';
|
import { Global } from '@nestjs/common';
|
||||||
|
|
||||||
import { Plugin } from '../registry';
|
import { Plugin } from '../registry';
|
||||||
|
|||||||
@@ -5,4 +5,8 @@ import './payment';
|
|||||||
import './redis';
|
import './redis';
|
||||||
import './storage';
|
import './storage';
|
||||||
|
|
||||||
export { REGISTERED_PLUGINS } from './registry';
|
export {
|
||||||
|
enablePlugin,
|
||||||
|
REGISTERED_PLUGINS,
|
||||||
|
ENABLED_PLUGINS as USED_PLUGINS,
|
||||||
|
} from './registry';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
|
||||||
|
|
||||||
export interface OAuthProviderConfig {
|
export interface OAuthProviderConfig {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
@@ -29,6 +31,15 @@ type OAuthProviderConfigMapping = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface OAuthConfig {
|
export interface OAuthConfig {
|
||||||
enabled: boolean;
|
|
||||||
providers: Partial<OAuthProviderConfigMapping>;
|
providers: Partial<OAuthProviderConfigMapping>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '../config' {
|
||||||
|
interface PluginsConfig {
|
||||||
|
oauth: ModuleConfig<OAuthConfig>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineStartupConfig('plugins.oauth', {
|
||||||
|
providers: {},
|
||||||
|
});
|
||||||
@@ -12,10 +12,10 @@ import type { Request, Response } from 'express';
|
|||||||
import { AuthService, Public } from '../../core/auth';
|
import { AuthService, Public } from '../../core/auth';
|
||||||
import { UserService } from '../../core/user';
|
import { UserService } from '../../core/user';
|
||||||
import { URLHelper } from '../../fundamentals';
|
import { URLHelper } from '../../fundamentals';
|
||||||
|
import { OAuthProviderName } from './config';
|
||||||
import { OAuthAccount, Tokens } from './providers/def';
|
import { OAuthAccount, Tokens } from './providers/def';
|
||||||
import { OAuthProviderFactory } from './register';
|
import { OAuthProviderFactory } from './register';
|
||||||
import { OAuthService } from './service';
|
import { OAuthService } from './service';
|
||||||
import { OAuthProviderName } from './types';
|
|
||||||
|
|
||||||
@Controller('/oauth')
|
@Controller('/oauth')
|
||||||
export class OAuthController {
|
export class OAuthController {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { AuthModule } from '../../core/auth';
|
import { AuthModule } from '../../core/auth';
|
||||||
import { ServerFeature } from '../../core/config';
|
import { ServerFeature } from '../../core/config';
|
||||||
import { UserModule } from '../../core/user';
|
import { UserModule } from '../../core/user';
|
||||||
@@ -22,4 +24,3 @@ import { OAuthService } from './service';
|
|||||||
if: config => !!config.plugins.oauth,
|
if: config => !!config.plugins.oauth,
|
||||||
})
|
})
|
||||||
export class OAuthModule {}
|
export class OAuthModule {}
|
||||||
export type { OAuthConfig } from './types';
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { OAuthProviderName } from '../types';
|
import { OAuthProviderName } from '../config';
|
||||||
|
|
||||||
export interface OAuthAccount {
|
export interface OAuthAccount {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config, URLHelper } from '../../../fundamentals';
|
import { Config, URLHelper } from '../../../fundamentals';
|
||||||
|
import { OAuthProviderName } from '../config';
|
||||||
import { AutoRegisteredOAuthProvider } from '../register';
|
import { AutoRegisteredOAuthProvider } from '../register';
|
||||||
import { OAuthProviderName } from '../types';
|
|
||||||
|
|
||||||
interface AuthTokenResponse {
|
interface AuthTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config, URLHelper } from '../../../fundamentals';
|
import { Config, URLHelper } from '../../../fundamentals';
|
||||||
|
import { OAuthProviderName } from '../config';
|
||||||
import { AutoRegisteredOAuthProvider } from '../register';
|
import { AutoRegisteredOAuthProvider } from '../register';
|
||||||
import { OAuthProviderName } from '../types';
|
|
||||||
|
|
||||||
interface GoogleOAuthTokenResponse {
|
interface GoogleOAuthTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import {
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Config, URLHelper } from '../../../fundamentals';
|
import { Config, URLHelper } from '../../../fundamentals';
|
||||||
|
import {
|
||||||
|
OAuthOIDCProviderConfig,
|
||||||
|
OAuthProviderName,
|
||||||
|
OIDCArgs,
|
||||||
|
} from '../config';
|
||||||
import { AutoRegisteredOAuthProvider } from '../register';
|
import { AutoRegisteredOAuthProvider } from '../register';
|
||||||
import { OAuthOIDCProviderConfig, OAuthProviderName, OIDCArgs } from '../types';
|
|
||||||
import { OAuthAccount, Tokens } from './def';
|
import { OAuthAccount, Tokens } from './def';
|
||||||
|
|
||||||
const OIDCTokenSchema = z.object({
|
const OIDCTokenSchema = z.object({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config } from '../../fundamentals';
|
import { Config } from '../../fundamentals';
|
||||||
|
import { OAuthProviderName } from './config';
|
||||||
import { OAuthProvider } from './providers/def';
|
import { OAuthProvider } from './providers/def';
|
||||||
import { OAuthProviderName } from './types';
|
|
||||||
|
|
||||||
const PROVIDERS: Map<OAuthProviderName, OAuthProvider> = new Map();
|
const PROVIDERS: Map<OAuthProviderName, OAuthProvider> = new Map();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
|
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
|
||||||
|
|
||||||
import { ServerConfigType } from '../../core/config';
|
import { ServerConfigType } from '../../core/config';
|
||||||
|
import { OAuthProviderName } from './config';
|
||||||
import { OAuthProviderFactory } from './register';
|
import { OAuthProviderFactory } from './register';
|
||||||
import { OAuthProviderName } from './types';
|
|
||||||
|
|
||||||
registerEnumType(OAuthProviderName, { name: 'OAuthProviderType' });
|
registerEnumType(OAuthProviderName, { name: 'OAuthProviderType' });
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { SessionCache } from '../../fundamentals';
|
import { SessionCache } from '../../fundamentals';
|
||||||
|
import { OAuthProviderName } from './config';
|
||||||
import { OAuthProviderFactory } from './register';
|
import { OAuthProviderFactory } from './register';
|
||||||
import { OAuthProviderName } from './types';
|
|
||||||
|
|
||||||
const OAUTH_STATE_KEY = 'OAUTH_STATE';
|
const OAUTH_STATE_KEY = 'OAUTH_STATE';
|
||||||
|
|
||||||
|
|||||||
20
packages/backend/server/src/plugins/payment/config.ts
Normal file
20
packages/backend/server/src/plugins/payment/config.ts
Normal 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', {});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { ServerFeature } from '../../core/config';
|
import { ServerFeature } from '../../core/config';
|
||||||
import { FeatureModule } from '../../core/features';
|
import { FeatureModule } from '../../core/features';
|
||||||
import { Plugin } from '../registry';
|
import { Plugin } from '../registry';
|
||||||
@@ -26,5 +28,3 @@ import { StripeWebhook } from './webhook';
|
|||||||
if: config => config.flavor.graphql,
|
if: config => config.flavor.graphql,
|
||||||
})
|
})
|
||||||
export class PaymentModule {}
|
export class PaymentModule {}
|
||||||
|
|
||||||
export type { PaymentConfig } from './types';
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { groupBy } from 'lodash-es';
|
|||||||
|
|
||||||
import { CurrentUser, Public } from '../../core/auth';
|
import { CurrentUser, Public } from '../../core/auth';
|
||||||
import { UserType } from '../../core/user';
|
import { UserType } from '../../core/user';
|
||||||
import { Config } from '../../fundamentals';
|
import { Config, URLHelper } from '../../fundamentals';
|
||||||
import { decodeLookupKey, SubscriptionService } from './service';
|
import { decodeLookupKey, SubscriptionService } from './service';
|
||||||
import {
|
import {
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
@@ -146,8 +146,8 @@ class CreateCheckoutSessionInput {
|
|||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
coupon!: string | null;
|
coupon!: string | null;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String)
|
||||||
successCallbackLink!: string | null;
|
successCallbackLink!: string;
|
||||||
|
|
||||||
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
|
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
@@ -158,7 +158,7 @@ class CreateCheckoutSessionInput {
|
|||||||
export class SubscriptionResolver {
|
export class SubscriptionResolver {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly service: SubscriptionService,
|
private readonly service: SubscriptionService,
|
||||||
private readonly config: Config
|
private readonly url: URLHelper
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -222,8 +222,7 @@ export class SubscriptionResolver {
|
|||||||
plan: input.plan,
|
plan: input.plan,
|
||||||
recurring: input.recurring,
|
recurring: input.recurring,
|
||||||
promotionCode: input.coupon,
|
promotionCode: input.coupon,
|
||||||
redirectUrl:
|
redirectUrl: this.url.link(input.successCallbackLink),
|
||||||
input.successCallbackLink ?? `${this.config.baseUrl}/upgrade-success`,
|
|
||||||
idempotencyKey: input.idempotencyKey,
|
idempotencyKey: input.idempotencyKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { Config } from '../../fundamentals';
|
|||||||
export const StripeProvider: FactoryProvider = {
|
export const StripeProvider: FactoryProvider = {
|
||||||
provide: Stripe,
|
provide: Stripe,
|
||||||
useFactory: (config: Config) => {
|
useFactory: (config: Config) => {
|
||||||
assert(config.plugins.payment);
|
|
||||||
const stripeConfig = config.plugins.payment.stripe;
|
const stripeConfig = config.plugins.payment.stripe;
|
||||||
|
assert(stripeConfig, 'Stripe configuration is missing');
|
||||||
|
|
||||||
return new Stripe(stripeConfig.keys.APIKey, omit(stripeConfig, 'keys'));
|
return new Stripe(stripeConfig.keys.APIKey, omit(stripeConfig, 'keys'));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import type { Stripe } from 'stripe';
|
|
||||||
|
|
||||||
import type { Payload } from '../../fundamentals/event/def';
|
import type { Payload } from '../../fundamentals/event/def';
|
||||||
|
|
||||||
export interface PaymentConfig {
|
|
||||||
stripe: {
|
|
||||||
keys: {
|
|
||||||
APIKey: string;
|
|
||||||
webhookKey: string;
|
|
||||||
};
|
|
||||||
} & Stripe.StripeConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SubscriptionRecurring {
|
export enum SubscriptionRecurring {
|
||||||
Monthly = 'monthly',
|
Monthly = 'monthly',
|
||||||
Yearly = 'yearly',
|
Yearly = 'yearly',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class StripeWebhook {
|
|||||||
private readonly stripe: Stripe,
|
private readonly stripe: Stripe,
|
||||||
private readonly event: EventEmitter2
|
private readonly event: EventEmitter2
|
||||||
) {
|
) {
|
||||||
assert(config.plugins.payment);
|
assert(config.plugins.payment.stripe);
|
||||||
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
|
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
packages/backend/server/src/plugins/redis/config.ts
Normal file
11
packages/backend/server/src/plugins/redis/config.ts
Normal 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', {});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { Global, Provider, Type } from '@nestjs/common';
|
import { Global, Provider, Type } from '@nestjs/common';
|
||||||
import type { RedisOptions } from 'ioredis';
|
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis';
|
import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis';
|
||||||
|
|
||||||
@@ -64,5 +65,3 @@ const mutexRedisAdapterProvider: Provider = {
|
|||||||
requires: ['plugins.redis.host'],
|
requires: ['plugins.redis.host'],
|
||||||
})
|
})
|
||||||
export class RedisModule {}
|
export class RedisModule {}
|
||||||
|
|
||||||
export { RedisOptions };
|
|
||||||
|
|||||||
@@ -30,20 +30,20 @@ class Redis extends IORedis implements OnModuleDestroy, OnModuleInit {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacheRedis extends Redis {
|
export class CacheRedis extends Redis {
|
||||||
constructor(config: Config) {
|
constructor(config: Config) {
|
||||||
super(config.plugins.redis ?? {});
|
super(config.plugins.redis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionRedis extends Redis {
|
export class SessionRedis extends Redis {
|
||||||
constructor(config: Config) {
|
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()
|
@Injectable()
|
||||||
export class SocketIoRedis extends Redis {
|
export class SocketIoRedis extends Redis {
|
||||||
constructor(config: Config) {
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { RedisOptions } from 'ioredis';
|
|
||||||
|
|
||||||
export type { RedisOptions };
|
|
||||||
@@ -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 { OptionalModule, OptionalModuleMetadata } from '../fundamentals/nestjs';
|
||||||
|
import { AvailablePlugins } from './config';
|
||||||
|
|
||||||
export const REGISTERED_PLUGINS = new Map<AvailablePlugins, AFFiNEModule>();
|
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);
|
REGISTERED_PLUGINS.set(plugin, module);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,8 +16,15 @@ interface PluginModuleMetadata extends OptionalModuleMetadata {
|
|||||||
|
|
||||||
export const Plugin = (options: PluginModuleMetadata) => {
|
export const Plugin = (options: PluginModuleMetadata) => {
|
||||||
return (target: any) => {
|
return (target: any) => {
|
||||||
register(options.name, target);
|
registerPlugin(options.name, target);
|
||||||
|
|
||||||
return OptionalModule(omit(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);
|
||||||
|
}
|
||||||
|
|||||||
27
packages/backend/server/src/plugins/storage/config.ts
Normal file
27
packages/backend/server/src/plugins/storage/config.ts
Normal 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', {});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import './config';
|
||||||
|
|
||||||
import { registerStorageProvider } from '../../fundamentals/storage';
|
import { registerStorageProvider } from '../../fundamentals/storage';
|
||||||
import { Plugin } from '../registry';
|
import { Plugin } from '../registry';
|
||||||
import { R2StorageProvider } from './providers/r2';
|
import { R2StorageProvider } from './providers/r2';
|
||||||
@@ -38,5 +40,3 @@ export class CloudflareR2Module {}
|
|||||||
if: config => config.flavor.graphql,
|
if: config => config.flavor.graphql,
|
||||||
})
|
})
|
||||||
export class AwsS3Module {}
|
export class AwsS3Module {}
|
||||||
|
|
||||||
export type { R2StorageConfig, S3StorageConfig } from './types';
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import type { R2StorageConfig } from '../types';
|
import type { R2StorageConfig } from '../config';
|
||||||
import { S3StorageProvider } from './s3';
|
import { S3StorageProvider } from './s3';
|
||||||
|
|
||||||
export class R2StorageProvider extends S3StorageProvider {
|
export class R2StorageProvider extends S3StorageProvider {
|
||||||
override readonly type = 'cloudflare-r2' as any /* cast 'r2' to 's3' */;
|
override readonly type = 'cloudflare-r2' as any /* cast 'r2' to 's3' */;
|
||||||
|
|
||||||
constructor(config: R2StorageConfig, bucket: string) {
|
constructor(config: R2StorageConfig, bucket: string) {
|
||||||
|
assert(config.accountId, 'accountId is required for R2 storage provider');
|
||||||
super(
|
super(
|
||||||
{
|
{
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
StorageProvider,
|
StorageProvider,
|
||||||
toBuffer,
|
toBuffer,
|
||||||
} from '../../../fundamentals/storage';
|
} from '../../../fundamentals/storage';
|
||||||
import type { S3StorageConfig } from '../types';
|
import type { S3StorageConfig } from '../config';
|
||||||
|
|
||||||
export class S3StorageProvider implements StorageProvider {
|
export class S3StorageProvider implements StorageProvider {
|
||||||
protected logger: Logger;
|
protected logger: Logger;
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user