feat: init renderer server (#8088)
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user