mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +08:00
fix(server): redirect to setup page if not initialized (#7875)
This commit is contained in:
1
packages/backend/server/.gitignore
vendored
1
packages/backend/server/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.env
|
||||
static/
|
||||
|
||||
@@ -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.*"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
17
packages/backend/server/src/core/config/service.ts
Normal file
17
packages/backend/server/src/core/config/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
99
packages/backend/server/src/core/selfhost/index.ts
Normal file
99
packages/backend/server/src/core/selfhost/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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), '../../../../'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
166
packages/backend/server/tests/selfhost/app.e2e.ts
Normal file
166
packages/backend/server/tests/selfhost/app.e2e.ts
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user