feat(server): enable share og information for docs (#7794)

This commit is contained in:
forehalo
2024-09-10 04:03:52 +00:00
parent 34eac4c24e
commit 0add8917f9
24 changed files with 449 additions and 40 deletions

View File

@@ -0,0 +1,95 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import xss from 'xss';
import { DocNotFound } from '../../fundamentals';
import { PermissionService } from '../permission';
import { PageDocContent } from '../utils/blocksuite';
import { DocContentService } from './service';
interface RenderOptions {
og: boolean;
content: boolean;
}
@Controller('/workspace/:workspaceId/:docId')
export class DocRendererController {
constructor(
private readonly doc: DocContentService,
private readonly permission: PermissionService
) {}
@Get()
async render(
@Res() res: Response,
@Param('workspaceId') workspaceId: string,
@Param('docId') docId: string
) {
if (workspaceId === docId) {
throw new DocNotFound({ spaceId: workspaceId, docId });
}
// 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: '' };
}
res.setHeader('Content-Type', 'text/html');
if (!opts.og) {
res.setHeader('X-Robots-Tag', 'noindex');
}
res.send(this._render(docContent, opts));
}
_render(doc: PageDocContent, { og }: RenderOptions): string {
const title = xss(doc.title);
const summary = xss(doc.summary);
return `
<!DOCTYPE html>
<html>
<head>
<title>${title} | AFFiNE</title>
<meta name="theme-color" content="#fafafa" />
<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="twitter:title"
content="AFFiNE: There can be more than Notion and Miro."
/>
<meta name="twitter:description" content="${title}" />
<meta name="twitter:site" content="@AffineOfficial" />
<meta name="twitter:image" content="https://affine.pro/og.jpeg" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${summary}" />
<meta property="og:image" content="https://affine.pro/og.jpeg" />
</head>
<body>
</body>
</html>
`;
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { DocStorageModule } from '../doc';
import { PermissionModule } from '../permission';
import { DocRendererController } from './controller';
import { DocContentService } from './service';
@Module({
imports: [DocStorageModule, PermissionModule],
providers: [DocContentService],
controllers: [DocRendererController],
exports: [DocContentService],
})
export class DocRendererModule {}
export { DocContentService };

View File

@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { applyUpdate, Doc } from 'yjs';
import { Cache } from '../../fundamentals';
import { PgWorkspaceDocStorageAdapter } from '../doc';
import {
type PageDocContent,
parsePageDoc,
parseWorkspaceDoc,
type WorkspaceDocContent,
} from '../utils/blocksuite';
@Injectable()
export class DocContentService {
constructor(
private readonly cache: Cache,
private readonly workspace: PgWorkspaceDocStorageAdapter
) {}
async getPageContent(
workspaceId: string,
guid: string
): Promise<PageDocContent | null> {
const cacheKey = `workspace:${workspaceId}:doc:${guid}:content`;
const cachedResult = await this.cache.get<PageDocContent>(cacheKey);
if (cachedResult) {
return cachedResult;
}
const docRecord = await this.workspace.getDoc(workspaceId, guid);
if (!docRecord) {
return null;
}
const doc = new Doc();
applyUpdate(doc, docRecord.bin);
const content = parsePageDoc(doc);
if (content) {
await this.cache.set(cacheKey, content, {
ttl:
7 *
24 *
60 *
60 *
1000 /* TODO(@forehalo): we need time constants helper */,
});
}
return content;
}
async getWorkspaceContent(
workspaceId: string
): Promise<WorkspaceDocContent | null> {
const cacheKey = `workspace:${workspaceId}:content`;
const cachedResult = await this.cache.get<WorkspaceDocContent>(cacheKey);
if (cachedResult) {
return cachedResult;
}
const docRecord = await this.workspace.getDoc(workspaceId, workspaceId);
if (!docRecord) {
return null;
}
const doc = new Doc();
applyUpdate(doc, docRecord.bin);
const content = parseWorkspaceDoc(doc);
if (content) {
await this.cache.set(cacheKey, content);
}
return content;
}
async markDocContentCacheStale(workspaceId: string, guid: string) {
const key =
workspaceId === guid
? `workspace:${workspaceId}:content`
: `workspace:${workspaceId}:doc:${guid}:content`;
await this.cache.delete(key);
}
}