mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user