diff --git a/.github/helm/affine/charts/graphql/templates/deployment.yaml b/.github/helm/affine/charts/graphql/templates/deployment.yaml index 7c1fd1dac8..679bea6057 100644 --- a/.github/helm/affine/charts/graphql/templates/deployment.yaml +++ b/.github/helm/affine/charts/graphql/templates/deployment.yaml @@ -204,12 +204,12 @@ spec: protocol: TCP livenessProbe: httpGet: - path: / + path: /info port: http initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }} readinessProbe: httpGet: - path: / + path: /info port: http initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }} resources: diff --git a/packages/backend/server/.gitignore b/packages/backend/server/.gitignore index 4c49bd78f1..5d609db0b7 100644 --- a/packages/backend/server/.gitignore +++ b/packages/backend/server/.gitignore @@ -1 +1,2 @@ .env +static/ diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 0d346b1e51..b0bf7c2dae 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -34,7 +34,6 @@ "@nestjs/platform-express": "^10.3.7", "@nestjs/platform-socket.io": "^10.3.7", "@nestjs/schedule": "^4.0.1", - "@nestjs/serve-static": "^4.0.2", "@nestjs/throttler": "5.2.0", "@nestjs/websockets": "^10.3.7", "@node-rs/argon2": "^1.8.0", @@ -138,6 +137,7 @@ ], "watchMode": { "ignoreChanges": [ + "static/**", "**/*.gen.*" ] }, diff --git a/packages/backend/server/src/app.controller.ts b/packages/backend/server/src/app.controller.ts index ff09b3b05a..f98b3a6a9a 100644 --- a/packages/backend/server/src/app.controller.ts +++ b/packages/backend/server/src/app.controller.ts @@ -3,7 +3,7 @@ import { Controller, Get } from '@nestjs/common'; import { Public } from './core/auth'; import { Config, SkipThrottle } from './fundamentals'; -@Controller('/') +@Controller('/info') export class AppController { constructor(private readonly config: Config) {} diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index ac80a57801..1b3154b5fd 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -1,5 +1,3 @@ -import { join } from 'node:path'; - import { DynamicModule, ForwardReference, @@ -7,7 +5,6 @@ import { Module, } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; -import { ServeStaticModule } from '@nestjs/serve-static'; import { get } from 'lodash-es'; import { AppController } from './app.controller'; @@ -16,7 +13,7 @@ import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config'; import { DocModule } from './core/doc'; import { FeatureModule } from './core/features'; import { QuotaModule } from './core/quota'; -import { CustomSetupModule } from './core/setup'; +import { SelfhostModule } from './core/selfhost'; import { StorageModule } from './core/storage'; import { SyncModule } from './core/sync'; import { UserModule } from './core/user'; @@ -137,7 +134,7 @@ export class AppModuleBuilder { compile() { @Module({ imports: this.modules, - controllers: this.config.isSelfhosted ? [] : [AppController], + controllers: [AppController], }) class AppModule {} @@ -145,7 +142,7 @@ export class AppModuleBuilder { } } -function buildAppModule() { +export function buildAppModule() { AFFiNE = mergeConfigOverride(AFFiNE); const factor = new AppModuleBuilder(AFFiNE); @@ -175,18 +172,7 @@ function buildAppModule() { ) // self hosted server only - .useIf( - config => config.isSelfhosted, - CustomSetupModule, - ServeStaticModule.forRoot({ - rootPath: join('/app', 'static'), - exclude: ['/admin*'], - }), - ServeStaticModule.forRoot({ - rootPath: join('/app', 'static', 'admin'), - serveRoot: '/admin', - }) - ); + .useIf(config => config.isSelfhosted, SelfhostModule); // plugin modules ENABLED_PLUGINS.forEach(name => { diff --git a/packages/backend/server/src/core/config/index.ts b/packages/backend/server/src/core/config/index.ts index 174f34a7f5..1ea38185b9 100644 --- a/packages/backend/server/src/core/config/index.ts +++ b/packages/backend/server/src/core/config/index.ts @@ -8,15 +8,19 @@ import { ServerRuntimeConfigResolver, ServerServiceConfigResolver, } from './resolver'; +import { ServerService } from './service'; @Module({ providers: [ + ServerService, ServerConfigResolver, ServerFeatureConfigResolver, ServerRuntimeConfigResolver, ServerServiceConfigResolver, ], + exports: [ServerService], }) export class ServerConfigModule {} +export { ServerService }; export { ADD_ENABLED_FEATURES } from './server-feature'; export { ServerFeature } from './types'; diff --git a/packages/backend/server/src/core/config/resolver.ts b/packages/backend/server/src/core/config/resolver.ts index cd78c10d47..d3e34629d3 100644 --- a/packages/backend/server/src/core/config/resolver.ts +++ b/packages/backend/server/src/core/config/resolver.ts @@ -9,7 +9,7 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { PrismaClient, RuntimeConfig, RuntimeConfigType } from '@prisma/client'; +import { RuntimeConfig, RuntimeConfigType } from '@prisma/client'; import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'; import { Config, URLHelper } from '../../fundamentals'; @@ -19,6 +19,7 @@ import { FeatureType } from '../features'; import { AvailableUserFeatureConfig } from '../features/resolver'; import { ServerFlags } from './config'; import { ENABLED_FEATURES } from './server-feature'; +import { ServerService } from './service'; import { ServerConfigType } from './types'; @ObjectType() @@ -76,7 +77,7 @@ export class ServerConfigResolver { constructor( private readonly config: Config, private readonly url: URLHelper, - private readonly db: PrismaClient + private readonly server: ServerService ) {} @Public() @@ -131,7 +132,7 @@ export class ServerConfigResolver { description: 'whether server has been initialized', }) async initialized() { - return (await this.db.user.count()) > 0; + return this.server.initialized(); } } diff --git a/packages/backend/server/src/core/config/service.ts b/packages/backend/server/src/core/config/service.ts new file mode 100644 index 0000000000..810d62cfbf --- /dev/null +++ b/packages/backend/server/src/core/config/service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class ServerService { + private _initialized: boolean | null = null; + constructor(private readonly db: PrismaClient) {} + + async initialized() { + if (!this._initialized) { + const userCount = await this.db.user.count(); + this._initialized = userCount > 0; + } + + return this._initialized; + } +} diff --git a/packages/backend/server/src/core/setup/controller.ts b/packages/backend/server/src/core/selfhost/controller.ts similarity index 89% rename from packages/backend/server/src/core/setup/controller.ts rename to packages/backend/server/src/core/selfhost/controller.ts index 6ceeb87772..9a19db3b2a 100644 --- a/packages/backend/server/src/core/setup/controller.ts +++ b/packages/backend/server/src/core/selfhost/controller.ts @@ -1,5 +1,4 @@ import { Body, Controller, Post, Req, Res } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; import type { Request, Response } from 'express'; import { @@ -10,6 +9,7 @@ import { PasswordRequired, } from '../../fundamentals'; import { AuthService, Public } from '../auth'; +import { ServerService } from '../config'; import { UserService } from '../user/service'; interface CreateUserInput { @@ -20,11 +20,11 @@ interface CreateUserInput { @Controller('/api/setup') export class CustomSetupController { constructor( - private readonly db: PrismaClient, private readonly user: UserService, private readonly auth: AuthService, private readonly event: EventEmitter, - private readonly mutex: MutexService + private readonly mutex: MutexService, + private readonly server: ServerService ) {} @Public() @@ -44,7 +44,7 @@ export class CustomSetupController { throw new InternalServerError(); } - if ((await this.db.user.count()) > 0) { + if (await this.server.initialized()) { throw new ActionForbidden('First user already created'); } diff --git a/packages/backend/server/src/core/selfhost/index.ts b/packages/backend/server/src/core/selfhost/index.ts new file mode 100644 index 0000000000..58a780d170 --- /dev/null +++ b/packages/backend/server/src/core/selfhost/index.ts @@ -0,0 +1,99 @@ +import { join } from 'node:path'; + +import { + Injectable, + Module, + NestMiddleware, + OnModuleInit, +} from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import type { Application, Request, Response } from 'express'; +import { static as serveStatic } from 'express'; + +import { Config } from '../../fundamentals'; +import { AuthModule } from '../auth'; +import { ServerConfigModule, ServerService } from '../config'; +import { UserModule } from '../user'; +import { CustomSetupController } from './controller'; + +@Injectable() +export class SetupMiddleware implements NestMiddleware { + constructor(private readonly server: ServerService) {} + + use = (req: Request, res: Response, next: (error?: Error | any) => void) => { + // never throw + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.server + .initialized() + .then(initialized => { + // Redirect to setup page if not initialized + if (!initialized && req.path !== '/admin/setup') { + res.redirect('/admin/setup'); + return; + } + + // redirect to admin page if initialized + if (initialized && req.path === '/admin/setup') { + res.redirect('/admin'); + return; + } + + next(); + }) + .catch(() => { + next(); + }); + }; +} + +@Module({ + imports: [AuthModule, UserModule, ServerConfigModule], + providers: [SetupMiddleware], + controllers: [CustomSetupController], +}) +export class SelfhostModule implements OnModuleInit { + constructor( + private readonly config: Config, + private readonly adapterHost: HttpAdapterHost, + private readonly check: SetupMiddleware + ) {} + + onModuleInit() { + const staticPath = join(this.config.projectRoot, 'static'); + const app = this.adapterHost.httpAdapter.getInstance(); + const basePath = this.config.server.path; + + app.get(basePath + '/admin/index.html', (_req, res) => { + res.redirect(basePath + '/admin'); + }); + app.use( + basePath + '/admin', + serveStatic(join(staticPath, 'admin'), { + redirect: false, + index: false, + }) + ); + + app.get( + [basePath + '/admin', basePath + '/admin/*'], + this.check.use, + (_req, res) => { + res.sendFile(join(staticPath, 'admin', 'index.html')); + } + ); + + app.get(basePath + '/index.html', (_req, res) => { + res.redirect(basePath); + }); + app.use( + basePath, + serveStatic(staticPath, { + redirect: false, + index: false, + }) + ); + app.get('*', this.check.use, (_req, res) => { + res.sendFile(join(staticPath, 'index.html')); + }); + } +} diff --git a/packages/backend/server/src/core/setup/index.ts b/packages/backend/server/src/core/setup/index.ts deleted file mode 100644 index 56fe7cfd79..0000000000 --- a/packages/backend/server/src/core/setup/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AuthModule } from '../auth'; -import { UserModule } from '../user'; -import { CustomSetupController } from './controller'; - -@Module({ - imports: [AuthModule, UserModule], - controllers: [CustomSetupController], -}) -export class CustomSetupModule {} diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index a565c36dac..90051e2dd2 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -17,6 +17,7 @@ export interface PreDefinedAFFiNEConfig { ENV_MAP: Record; serverId: string; serverName: string; + readonly projectRoot: string; readonly AFFINE_ENV: AFFINE_ENV; readonly NODE_ENV: NODE_ENV; readonly version: string; diff --git a/packages/backend/server/src/fundamentals/config/default.ts b/packages/backend/server/src/fundamentals/config/default.ts index ac12f7fff7..25727ab50b 100644 --- a/packages/backend/server/src/fundamentals/config/default.ts +++ b/packages/backend/server/src/fundamentals/config/default.ts @@ -1,3 +1,6 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import pkg from '../../../package.json' assert { type: 'json' }; import { AFFINE_ENV, @@ -62,6 +65,7 @@ function getPredefinedAFFiNEConfig(): PreDefinedAFFiNEConfig { affine, node, deploy: !node.dev && !node.test, + projectRoot: resolve(fileURLToPath(import.meta.url), '../../../../'), }; } diff --git a/packages/backend/server/tests/config.spec.ts b/packages/backend/server/tests/config.spec.ts index 23e9345927..00cc1cdcf9 100644 --- a/packages/backend/server/tests/config.spec.ts +++ b/packages/backend/server/tests/config.spec.ts @@ -17,6 +17,7 @@ test.afterEach.always(async () => { test('should be able to get config', t => { t.true(typeof config.server.host === 'string'); + t.is(config.projectRoot, process.cwd()); t.is(config.NODE_ENV, 'test'); }); diff --git a/packages/backend/server/tests/selfhost/app.e2e.ts b/packages/backend/server/tests/selfhost/app.e2e.ts new file mode 100644 index 0000000000..ad47be64b6 --- /dev/null +++ b/packages/backend/server/tests/selfhost/app.e2e.ts @@ -0,0 +1,166 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import type { TestFn } from 'ava'; +import ava from 'ava'; +import request from 'supertest'; + +import { buildAppModule } from '../../src/app.module'; +import { ServerService } from '../../src/core/config'; +import { Config } from '../../src/fundamentals'; +import { createTestingApp, initTestingDB } from '../utils'; + +const test = ava as TestFn<{ + app: INestApplication; + db: PrismaClient; +}>; + +function initTestStaticFiles(staticPath: string) { + mkdirSync(path.join(staticPath, 'admin'), { recursive: true }); + const files = { + 'index.html': `AFFiNE