Skip to content

Commit

Permalink
feat(server): enable share og information for docs
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Sep 6, 2024
1 parent 8a0f033 commit 3d53a37
Show file tree
Hide file tree
Showing 24 changed files with 449 additions and 40 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "workspaces" ADD COLUMN "enable_url_preview" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions packages/backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"ws": "^8.16.0",
"xss": "^1.0.15",
"yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch",
"zod": "^3.22.4"
},
Expand Down
7 changes: 4 additions & 3 deletions packages/backend/server/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ model VerificationToken {
}

model Workspace {
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
id String @id @default(uuid()) @db.VarChar
public Boolean
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
pages WorkspacePage[]
permissions WorkspaceUserPermission[]
Expand Down
12 changes: 6 additions & 6 deletions packages/backend/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AppController } from './app.controller';
import { AuthModule } from './core/auth';
import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config';
import { DocStorageModule } from './core/doc';
import { DocRendererModule } from './core/doc-renderer';
import { FeatureModule } from './core/features';
import { PermissionModule } from './core/permission';
import { QuotaModule } from './core/quota';
Expand Down Expand Up @@ -42,7 +43,6 @@ import { ENABLED_PLUGINS } from './plugins/registry';

export const FunctionalityModules = [
ConfigModule.forRoot(),
ScheduleModule.forRoot(),
EventModule,
CacheModule,
MutexModule,
Expand Down Expand Up @@ -156,24 +156,24 @@ export function buildAppModule() {
.use(UserModule, AuthModule, PermissionModule)

// business modules
.use(DocStorageModule)
.use(FeatureModule, QuotaModule, DocStorageModule)

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

// graphql server only
.useIf(
config => config.flavor.graphql,
ScheduleModule.forRoot(),
GqlModule,
StorageModule,
ServerConfigModule,
WorkspaceModule,
FeatureModule,
QuotaModule
WorkspaceModule
)

// self hosted server only
.useIf(config => config.isSelfhosted, SelfhostModule);
.useIf(config => config.isSelfhosted, SelfhostModule)
.useIf(config => config.flavor.renderer, DocRendererModule);

// plugin modules
ENABLED_PLUGINS.forEach(name => {
Expand Down
95 changes: 95 additions & 0 deletions packages/backend/server/src/core/doc-renderer/controller.ts
Original file line number Diff line number Diff line change
@@ -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>
`;
}
}
16 changes: 16 additions & 0 deletions packages/backend/server/src/core/doc-renderer/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
88 changes: 88 additions & 0 deletions packages/backend/server/src/core/doc-renderer/service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
8 changes: 4 additions & 4 deletions packages/backend/server/src/core/doc/job.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';

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

constructor(
private readonly registry: SchedulerRegistry,
private readonly config: Config,
private readonly db: PrismaClient,
private readonly workspace: PgWorkspaceDocStorageAdapter
private readonly workspace: PgWorkspaceDocStorageAdapter,
@Optional() private readonly registry?: SchedulerRegistry
) {}

onModuleInit() {
if (this.config.doc.manager.enableUpdateAutoMerging) {
if (this.registry && this.config.doc.manager.enableUpdateAutoMerging) {
this.registry.addInterval(
this.autoMergePendingDocUpdates.name,
// scheduler registry will clean up the interval when the app is stopped
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/server/src/core/permission/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export class PermissionService {
const count = await this.prisma.workspace.count({
where: {
id: ws,
public: true,
enableUrlPreview: true,
},
});

Expand Down
Loading

0 comments on commit 3d53a37

Please sign in to comment.