Skip to content

Commit 0add891

Browse files
committed
feat(server): enable share og information for docs (#7794)
1 parent 34eac4c commit 0add891

File tree

24 files changed

+449
-40
lines changed

24 files changed

+449
-40
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "workspaces" ADD COLUMN "enable_url_preview" BOOLEAN NOT NULL DEFAULT false;

packages/backend/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"ts-node": "^10.9.2",
9595
"typescript": "^5.4.5",
9696
"ws": "^8.16.0",
97+
"xss": "^1.0.15",
9798
"yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch",
9899
"zod": "^3.22.4"
99100
},

packages/backend/server/schema.prisma

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,10 @@ model VerificationToken {
9797
}
9898

9999
model Workspace {
100-
id String @id @default(uuid()) @db.VarChar
101-
public Boolean
102-
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
100+
id String @id @default(uuid()) @db.VarChar
101+
public Boolean
102+
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
103+
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
103104
104105
pages WorkspacePage[]
105106
permissions WorkspaceUserPermission[]

packages/backend/server/src/app.module.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AppController } from './app.controller';
1111
import { AuthModule } from './core/auth';
1212
import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config';
1313
import { DocStorageModule } from './core/doc';
14+
import { DocRendererModule } from './core/doc-renderer';
1415
import { FeatureModule } from './core/features';
1516
import { PermissionModule } from './core/permission';
1617
import { QuotaModule } from './core/quota';
@@ -42,7 +43,6 @@ import { ENABLED_PLUGINS } from './plugins/registry';
4243

4344
export const FunctionalityModules = [
4445
ConfigModule.forRoot(),
45-
ScheduleModule.forRoot(),
4646
EventModule,
4747
CacheModule,
4848
MutexModule,
@@ -156,24 +156,24 @@ export function buildAppModule() {
156156
.use(UserModule, AuthModule, PermissionModule)
157157

158158
// business modules
159-
.use(DocStorageModule)
159+
.use(FeatureModule, QuotaModule, DocStorageModule)
160160

161161
// sync server only
162162
.useIf(config => config.flavor.sync, SyncModule)
163163

164164
// graphql server only
165165
.useIf(
166166
config => config.flavor.graphql,
167+
ScheduleModule.forRoot(),
167168
GqlModule,
168169
StorageModule,
169170
ServerConfigModule,
170-
WorkspaceModule,
171-
FeatureModule,
172-
QuotaModule
171+
WorkspaceModule
173172
)
174173

175174
// self hosted server only
176-
.useIf(config => config.isSelfhosted, SelfhostModule);
175+
.useIf(config => config.isSelfhosted, SelfhostModule)
176+
.useIf(config => config.flavor.renderer, DocRendererModule);
177177

178178
// plugin modules
179179
ENABLED_PLUGINS.forEach(name => {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Controller, Get, Param, Res } from '@nestjs/common';
2+
import type { Response } from 'express';
3+
import xss from 'xss';
4+
5+
import { DocNotFound } from '../../fundamentals';
6+
import { PermissionService } from '../permission';
7+
import { PageDocContent } from '../utils/blocksuite';
8+
import { DocContentService } from './service';
9+
10+
interface RenderOptions {
11+
og: boolean;
12+
content: boolean;
13+
}
14+
15+
@Controller('/workspace/:workspaceId/:docId')
16+
export class DocRendererController {
17+
constructor(
18+
private readonly doc: DocContentService,
19+
private readonly permission: PermissionService
20+
) {}
21+
22+
@Get()
23+
async render(
24+
@Res() res: Response,
25+
@Param('workspaceId') workspaceId: string,
26+
@Param('docId') docId: string
27+
) {
28+
if (workspaceId === docId) {
29+
throw new DocNotFound({ spaceId: workspaceId, docId });
30+
}
31+
32+
// if page is public, show all
33+
// if page is private, but workspace public og is on, show og but not content
34+
const opts: RenderOptions = {
35+
og: false,
36+
content: false,
37+
};
38+
const isPagePublic = await this.permission.isPublicPage(workspaceId, docId);
39+
40+
if (isPagePublic) {
41+
opts.og = true;
42+
opts.content = true;
43+
} else {
44+
const allowPreview = await this.permission.allowUrlPreview(workspaceId);
45+
46+
if (allowPreview) {
47+
opts.og = true;
48+
}
49+
}
50+
51+
let docContent = opts.og
52+
? await this.doc.getPageContent(workspaceId, docId)
53+
: null;
54+
if (!docContent) {
55+
docContent = { title: 'untitled', summary: '' };
56+
}
57+
58+
res.setHeader('Content-Type', 'text/html');
59+
if (!opts.og) {
60+
res.setHeader('X-Robots-Tag', 'noindex');
61+
}
62+
res.send(this._render(docContent, opts));
63+
}
64+
65+
_render(doc: PageDocContent, { og }: RenderOptions): string {
66+
const title = xss(doc.title);
67+
const summary = xss(doc.summary);
68+
69+
return `
70+
<!DOCTYPE html>
71+
<html>
72+
<head>
73+
<title>${title} | AFFiNE</title>
74+
<meta name="theme-color" content="#fafafa" />
75+
<link rel="manifest" href="/manifest.json" />
76+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
77+
<link rel="icon" sizes="192x192" href="/favicon-192.png" />
78+
${!og ? '<meta name="robots" content="noindex, nofollow" />' : ''}
79+
<meta
80+
name="twitter:title"
81+
content="AFFiNE: There can be more than Notion and Miro."
82+
/>
83+
<meta name="twitter:description" content="${title}" />
84+
<meta name="twitter:site" content="@AffineOfficial" />
85+
<meta name="twitter:image" content="https://affine.pro/og.jpeg" />
86+
<meta property="og:title" content="${title}" />
87+
<meta property="og:description" content="${summary}" />
88+
<meta property="og:image" content="https://affine.pro/og.jpeg" />
89+
</head>
90+
<body>
91+
</body>
92+
</html>
93+
`;
94+
}
95+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { DocStorageModule } from '../doc';
4+
import { PermissionModule } from '../permission';
5+
import { DocRendererController } from './controller';
6+
import { DocContentService } from './service';
7+
8+
@Module({
9+
imports: [DocStorageModule, PermissionModule],
10+
providers: [DocContentService],
11+
controllers: [DocRendererController],
12+
exports: [DocContentService],
13+
})
14+
export class DocRendererModule {}
15+
16+
export { DocContentService };
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { applyUpdate, Doc } from 'yjs';
3+
4+
import { Cache } from '../../fundamentals';
5+
import { PgWorkspaceDocStorageAdapter } from '../doc';
6+
import {
7+
type PageDocContent,
8+
parsePageDoc,
9+
parseWorkspaceDoc,
10+
type WorkspaceDocContent,
11+
} from '../utils/blocksuite';
12+
13+
@Injectable()
14+
export class DocContentService {
15+
constructor(
16+
private readonly cache: Cache,
17+
private readonly workspace: PgWorkspaceDocStorageAdapter
18+
) {}
19+
20+
async getPageContent(
21+
workspaceId: string,
22+
guid: string
23+
): Promise<PageDocContent | null> {
24+
const cacheKey = `workspace:${workspaceId}:doc:${guid}:content`;
25+
const cachedResult = await this.cache.get<PageDocContent>(cacheKey);
26+
27+
if (cachedResult) {
28+
return cachedResult;
29+
}
30+
31+
const docRecord = await this.workspace.getDoc(workspaceId, guid);
32+
if (!docRecord) {
33+
return null;
34+
}
35+
36+
const doc = new Doc();
37+
applyUpdate(doc, docRecord.bin);
38+
39+
const content = parsePageDoc(doc);
40+
41+
if (content) {
42+
await this.cache.set(cacheKey, content, {
43+
ttl:
44+
7 *
45+
24 *
46+
60 *
47+
60 *
48+
1000 /* TODO(@forehalo): we need time constants helper */,
49+
});
50+
}
51+
return content;
52+
}
53+
54+
async getWorkspaceContent(
55+
workspaceId: string
56+
): Promise<WorkspaceDocContent | null> {
57+
const cacheKey = `workspace:${workspaceId}:content`;
58+
const cachedResult = await this.cache.get<WorkspaceDocContent>(cacheKey);
59+
60+
if (cachedResult) {
61+
return cachedResult;
62+
}
63+
64+
const docRecord = await this.workspace.getDoc(workspaceId, workspaceId);
65+
if (!docRecord) {
66+
return null;
67+
}
68+
69+
const doc = new Doc();
70+
applyUpdate(doc, docRecord.bin);
71+
72+
const content = parseWorkspaceDoc(doc);
73+
74+
if (content) {
75+
await this.cache.set(cacheKey, content);
76+
}
77+
78+
return content;
79+
}
80+
81+
async markDocContentCacheStale(workspaceId: string, guid: string) {
82+
const key =
83+
workspaceId === guid
84+
? `workspace:${workspaceId}:content`
85+
: `workspace:${workspaceId}:doc:${guid}:content`;
86+
await this.cache.delete(key);
87+
}
88+
}

packages/backend/server/src/core/doc/job.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
1+
import { Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
22
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
33
import { PrismaClient } from '@prisma/client';
44

@@ -11,14 +11,14 @@ export class DocStorageCronJob implements OnModuleInit {
1111
private busy = false;
1212

1313
constructor(
14-
private readonly registry: SchedulerRegistry,
1514
private readonly config: Config,
1615
private readonly db: PrismaClient,
17-
private readonly workspace: PgWorkspaceDocStorageAdapter
16+
private readonly workspace: PgWorkspaceDocStorageAdapter,
17+
@Optional() private readonly registry?: SchedulerRegistry
1818
) {}
1919

2020
onModuleInit() {
21-
if (this.config.doc.manager.enableUpdateAutoMerging) {
21+
if (this.registry && this.config.doc.manager.enableUpdateAutoMerging) {
2222
this.registry.addInterval(
2323
this.autoMergePendingDocUpdates.name,
2424
// scheduler registry will clean up the interval when the app is stopped

packages/backend/server/src/core/permission/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export class PermissionService {
212212
const count = await this.prisma.workspace.count({
213213
where: {
214214
id: ws,
215-
public: true,
215+
enableUrlPreview: true,
216216
},
217217
});
218218

0 commit comments

Comments
 (0)