fix(server): redirect to setup page if not initialized (#7875)

This commit is contained in:
liuyi
2024-08-14 21:02:16 +08:00
committed by GitHub
parent 89537e6892
commit 57449c1530
20 changed files with 328 additions and 84 deletions

View File

@@ -1 +1,2 @@
.env
static/

View File

@@ -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.*"
]
},

View File

@@ -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) {}

View File

@@ -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 => {

View File

@@ -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';

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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');
}

View File

@@ -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<Application>();
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'));
});
}
}

View File

@@ -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 {}

View File

@@ -17,6 +17,7 @@ export interface PreDefinedAFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
serverId: string;
serverName: string;
readonly projectRoot: string;
readonly AFFINE_ENV: AFFINE_ENV;
readonly NODE_ENV: NODE_ENV;
readonly version: string;

View File

@@ -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), '../../../../'),
};
}

View File

@@ -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');
});

View File

@@ -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': `<!DOCTYPE html><html><body>AFFiNE</body><script src="main.js"/></html>`,
'main.js': `const name = 'affine'`,
'admin/index.html': `<!DOCTYPE html><html><body>AFFiNE Admin</body><script src="/admin/main.js"/></html>`,
'admin/main.js': `const name = 'affine-admin'`,
};
for (const [filename, content] of Object.entries(files)) {
writeFileSync(path.join(staticPath, filename), content);
}
}
test.before('init selfhost server', async t => {
// @ts-expect-error override
AFFiNE.isSelfhosted = true;
const { app } = await createTestingApp({
imports: [buildAppModule()],
});
t.context.app = app;
t.context.db = t.context.app.get(PrismaClient);
const config = app.get(Config);
const staticPath = path.join(config.projectRoot, 'static');
initTestStaticFiles(staticPath);
});
test.beforeEach(async t => {
await initTestingDB(t.context.db);
const server = t.context.app.get(ServerService);
// @ts-expect-error disable cache
server._initialized = false;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('do not allow visit index.html directly', async t => {
let res = await request(t.context.app.getHttpServer())
.get('/index.html')
.expect(302);
t.is(res.header.location, '');
res = await request(t.context.app.getHttpServer())
.get('/admin/index.html')
.expect(302);
t.is(res.header.location, '/admin');
});
test('should always return static asset files', async t => {
let res = await request(t.context.app.getHttpServer())
.get('/main.js')
.expect(200);
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/admin/main.js')
.expect(200);
t.is(res.text, "const name = 'affine-admin'");
await t.context.db.user.create({
data: {
name: 'test',
email: 'test@affine.pro',
},
});
res = await request(t.context.app.getHttpServer())
.get('/main.js')
.expect(200);
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/admin/main.js')
.expect(200);
t.is(res.text, "const name = 'affine-admin'");
});
test('should be able to call apis', async t => {
await request(t.context.app.getHttpServer()).get('/info').expect(200);
t.pass();
});
const blockedPages = [
'/',
'/workspace',
'/admin',
'/admin/',
'/admin/accounts',
];
test('should redirect to setup if server is not initialized', async t => {
for (const path of blockedPages) {
const res = await request(t.context.app.getHttpServer()).get(path);
t.is(res.status, 302, `Failed to redirect ${path}`);
t.is(res.header.location, '/admin/setup');
}
t.pass();
});
test('should allow visiting all pages if initialized', async t => {
await t.context.db.user.create({
data: {
name: 'test',
email: 'test@affine.pro',
},
});
for (const path of blockedPages) {
const res = await request(t.context.app.getHttpServer()).get(path);
t.is(res.status, 200, `Failed to visit ${path}`);
}
t.pass();
});
test('should allow visiting setup page if not initialized', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/admin/setup')
.expect(200);
t.true(res.text.includes('AFFiNE Admin'));
});
test('should redirect to admin if initialized', async t => {
await t.context.db.user.create({
data: {
name: 'test',
email: 'test@affine.pro',
},
});
const res = await request(t.context.app.getHttpServer())
.get('/admin/setup')
.expect(302);
t.is(res.header.location, '/admin');
});