feat: init renderer server (#8088)

This commit is contained in:
Brooooooklyn
2024-09-10 04:03:58 +00:00
parent 0add8917f9
commit fe1eefdbb2
52 changed files with 827 additions and 330 deletions

View File

@@ -1,93 +1,191 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import xss from 'xss';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DocNotFound } from '../../fundamentals';
import { Controller, Get, Logger, Param, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import isMobile from 'is-mobile';
import { Config, metrics, URLHelper } from '../../fundamentals';
import { htmlSanitize } from '../../native';
import { Public } from '../auth';
import { PermissionService } from '../permission';
import { PageDocContent } from '../utils/blocksuite';
import { DocContentService } from './service';
interface RenderOptions {
og: boolean;
content: boolean;
title: string;
summary: string;
avatar?: string;
}
interface HtmlAssets {
css: string[];
js: string[];
publicPath: string;
gitHash: string;
description: string;
}
const defaultAssets: HtmlAssets = {
css: [],
js: [],
publicPath: '/',
gitHash: '',
description: '',
};
@Controller('/workspace/:workspaceId/:docId')
export class DocRendererController {
private readonly logger = new Logger(DocRendererController.name);
private readonly webAssets: HtmlAssets = defaultAssets;
private readonly mobileAssets: HtmlAssets = defaultAssets;
constructor(
private readonly doc: DocContentService,
private readonly permission: PermissionService
) {}
private readonly permission: PermissionService,
private readonly config: Config,
private readonly url: URLHelper
) {
try {
const webConfigMapsPath = join(
this.config.projectRoot,
this.config.isSelfhosted ? 'static/selfhost' : 'static',
'assets-manifest.json'
);
const mobileConfigMapsPath = join(
this.config.projectRoot,
this.config.isSelfhosted ? 'static/mobile/selfhost' : 'static/mobile',
'assets-manifest.json'
);
this.webAssets = JSON.parse(readFileSync(webConfigMapsPath, 'utf-8'));
this.mobileAssets = JSON.parse(
readFileSync(mobileConfigMapsPath, 'utf-8')
);
} catch (e) {
if (this.config.node.prod) {
throw e;
}
}
}
@Public()
@Get()
async render(
@Req() req: Request,
@Res() res: Response,
@Param('workspaceId') workspaceId: string,
@Param('docId') docId: string
) {
if (workspaceId === docId) {
throw new DocNotFound({ spaceId: workspaceId, docId });
}
const assets: HtmlAssets =
this.config.affine.canary &&
isMobile({
ua: req.headers['user-agent'] ?? undefined,
})
? this.mobileAssets
: this.webAssets;
// if page is public, show all
// if page is private, but workspace public og is on, show og but not content
const opts: RenderOptions = {
og: false,
content: false,
};
const isPagePublic = await this.permission.isPublicPage(workspaceId, docId);
if (isPagePublic) {
opts.og = true;
opts.content = true;
} else {
const allowPreview = await this.permission.allowUrlPreview(workspaceId);
if (allowPreview) {
opts.og = true;
}
}
let docContent = opts.og
? await this.doc.getPageContent(workspaceId, docId)
: null;
if (!docContent) {
docContent = { title: 'untitled', summary: '' };
let opts: RenderOptions | null = null;
try {
opts =
workspaceId === docId
? await this.renderWorkspace(workspaceId)
: await this.getPageContent(workspaceId, docId);
metrics.doc.counter('render').add(1);
} catch (e) {
this.logger.error('failed to render page', e);
}
res.setHeader('Content-Type', 'text/html');
if (!opts.og) {
if (!opts) {
res.setHeader('X-Robots-Tag', 'noindex');
}
res.send(this._render(docContent, opts));
res.send(this._render(opts, assets));
}
_render(doc: PageDocContent, { og }: RenderOptions): string {
const title = xss(doc.title);
const summary = xss(doc.summary);
private async getPageContent(
workspaceId: string,
docId: string
): Promise<RenderOptions | null> {
let allowUrlPreview = await this.permission.isPublicPage(
workspaceId,
docId
);
if (!allowUrlPreview) {
// if page is private, but workspace url preview is on
allowUrlPreview = await this.permission.allowUrlPreview(workspaceId);
}
if (allowUrlPreview) {
return this.doc.getPageContent(workspaceId, docId);
}
return null;
}
private async renderWorkspace(
workspaceId: string
): Promise<RenderOptions | null> {
const allowUrlPreview = await this.permission.allowUrlPreview(workspaceId);
if (allowUrlPreview) {
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
if (workspaceContent) {
return {
title: workspaceContent.name,
summary: '',
avatar: workspaceContent.avatarKey
? this.url.link(
`/api/workspaces/${workspaceId}/blobs/${workspaceContent.avatarKey}`
)
: undefined,
};
}
}
return null;
}
_render(opts: RenderOptions | null, assets: HtmlAssets): string {
const title = opts?.title
? htmlSanitize(`${opts.title} | AFFiNE`)
: 'AFFiNE';
const summary = opts ? htmlSanitize(opts.summary) : assets.description;
const image = opts?.avatar ?? 'https://affine.pro/og.jpeg';
return `
<!DOCTYPE html>
<html>
<head>
<title>${title} | AFFiNE</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<title>${title}</title>
<meta name="theme-color" content="#fafafa" />
<link rel="preconnect" href="${assets.publicPath}">
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" sizes="192x192" href="/favicon-192.png" />
${!og ? '<meta name="robots" content="noindex, nofollow" />' : ''}
<meta name="emotion-insertion-point" content="" />
${!opts ? '<meta name="robots" content="noindex, nofollow" />' : ''}
<meta
name="twitter:title"
content="AFFiNE: There can be more than Notion and Miro."
content="${title}"
/>
<meta name="twitter:description" content="${title}" />
<meta name="twitter:description" content="${summary}" />
<meta name="twitter:site" content="@AffineOfficial" />
<meta name="twitter:image" content="https://affine.pro/og.jpeg" />
<meta name="twitter:image" content="${image}" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${summary}" />
<meta property="og:image" content="https://affine.pro/og.jpeg" />
<meta property="og:image" content="${image}" />
${assets.css.map(url => `<link rel="stylesheet" href="${url}" />`).join('\n')}
</head>
<body>
<div id="app" data-version="${assets.gitHash}"></div>
${assets.js.map(url => `<script type="module" src="${url}"></script>`).join('\n')}
</body>
</html>
`;

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { applyUpdate, Doc } from 'yjs';
import { Cache } from '../../fundamentals';
import { Cache, type EventPayload, OnEvent } from '../../fundamentals';
import { PgWorkspaceDocStorageAdapter } from '../doc';
import {
type PageDocContent,
@@ -78,11 +78,15 @@ export class DocContentService {
return content;
}
async markDocContentCacheStale(workspaceId: string, guid: string) {
@OnEvent('snapshot.updated')
async markDocContentCacheStale({
workspaceId,
id,
}: EventPayload<'snapshot.updated'>) {
const key =
workspaceId === guid
workspaceId === id
? `workspace:${workspaceId}:content`
: `workspace:${workspaceId}:doc:${guid}:content`;
: `workspace:${workspaceId}:doc:${id}:content`;
await this.cache.delete(key);
}
}

View File

@@ -6,6 +6,7 @@ import {
Cache,
DocHistoryNotFound,
DocNotFound,
EventEmitter,
FailedToSaveUpdates,
FailedToUpsertSnapshot,
metrics,
@@ -30,6 +31,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
private readonly db: PrismaClient,
private readonly mutex: Mutex,
private readonly cache: Cache,
private readonly event: EventEmitter,
protected override readonly options: DocStorageOptions
) {
super(options);
@@ -97,7 +99,6 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
metrics.doc.counter('doc_update_insert_failed').add(1);
throw new FailedToSaveUpdates();
}
return timestamp;
}
@@ -463,6 +464,14 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
// the updates has been applied to current `doc` must have been seen by the other process as well.
// The `updatedSnapshot` will be `undefined` in this case.
const updatedSnapshot = result.at(0);
if (updatedSnapshot) {
this.event.emit('snapshot.updated', {
workspaceId: snapshot.spaceId,
id: snapshot.docId,
});
}
return !!updatedSnapshot;
} catch (e) {
metrics.doc.counter('snapshot_upsert_failed').add(1);

View File

@@ -22,7 +22,6 @@ export class SetupMiddleware implements NestMiddleware {
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 => {
@@ -59,6 +58,10 @@ export class SelfhostModule implements OnModuleInit {
) {}
onModuleInit() {
// selfhost static file location
// web => 'static/selfhost'
// admin => 'static/admin/selfhost'
// mobile => 'static/mobile/selfhost'
const staticPath = join(this.config.projectRoot, 'static');
// in command line mode
if (!this.adapterHost.httpAdapter) {
@@ -73,7 +76,7 @@ export class SelfhostModule implements OnModuleInit {
});
app.use(
basePath + '/admin',
serveStatic(join(staticPath, 'admin'), {
serveStatic(join(staticPath, 'admin', 'selfhost'), {
redirect: false,
index: false,
})
@@ -83,7 +86,7 @@ export class SelfhostModule implements OnModuleInit {
[basePath + '/admin', basePath + '/admin/*'],
this.check.use,
(_req, res) => {
res.sendFile(join(staticPath, 'admin', 'index.html'));
res.sendFile(join(staticPath, 'admin', 'selfhost', 'index.html'));
}
);
@@ -92,13 +95,13 @@ export class SelfhostModule implements OnModuleInit {
});
app.use(
basePath,
serveStatic(staticPath, {
serveStatic(join(staticPath, 'selfhost'), {
redirect: false,
index: false,
})
);
app.get('*', this.check.use, (_req, res) => {
res.sendFile(join(staticPath, 'index.html'));
res.sendFile(join(staticPath, 'selfhost', 'index.html'));
});
}
}

View File

@@ -13,12 +13,8 @@ export interface WorkspaceEvents {
}
export interface DocEvents {
updated: Payload<
Pick<Snapshot, 'id' | 'workspaceId'> & {
previous: Pick<Snapshot, 'blob' | 'state' | 'updatedAt'>;
}
>;
deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
updated: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
}
export interface UserEvents {

View File

@@ -32,3 +32,4 @@ export const mintChallengeResponse = async (resource: string, bits: number) => {
export const getMime = serverNativeModule.getMime;
export const Tokenizer = serverNativeModule.Tokenizer;
export const fromModelName = serverNativeModule.fromModelName;
export const htmlSanitize = serverNativeModule.htmlSanitize;