diff --git a/packages/backend/server/migrations/20240903033137_workspace_url_preview/migration.sql b/packages/backend/server/migrations/20240903033137_workspace_url_preview/migration.sql
new file mode 100644
index 000000000..6853fb298
--- /dev/null
+++ b/packages/backend/server/migrations/20240903033137_workspace_url_preview/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "enable_url_preview" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json
index 9a705b08d..7ca019042 100644
--- a/packages/backend/server/package.json
+++ b/packages/backend/server/package.json
@@ -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"
},
diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma
index 643f14b56..0028280b2 100644
--- a/packages/backend/server/schema.prisma
+++ b/packages/backend/server/schema.prisma
@@ -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[]
diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts
index e9a111501..b9e8bf28e 100644
--- a/packages/backend/server/src/app.module.ts
+++ b/packages/backend/server/src/app.module.ts
@@ -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';
@@ -42,7 +43,6 @@ import { ENABLED_PLUGINS } from './plugins/registry';
export const FunctionalityModules = [
ConfigModule.forRoot(),
- ScheduleModule.forRoot(),
EventModule,
CacheModule,
MutexModule,
@@ -156,7 +156,7 @@ export function buildAppModule() {
.use(UserModule, AuthModule, PermissionModule)
// business modules
- .use(DocStorageModule)
+ .use(FeatureModule, QuotaModule, DocStorageModule)
// sync server only
.useIf(config => config.flavor.sync, SyncModule)
@@ -164,16 +164,16 @@ export function buildAppModule() {
// 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 => {
diff --git a/packages/backend/server/src/core/doc-renderer/controller.ts b/packages/backend/server/src/core/doc-renderer/controller.ts
new file mode 100644
index 000000000..adbbcccd9
--- /dev/null
+++ b/packages/backend/server/src/core/doc-renderer/controller.ts
@@ -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 `
+
+
+
+ ${title} | AFFiNE
+
+
+
+
+ ${!og ? '' : ''}
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/packages/backend/server/src/core/doc-renderer/index.ts b/packages/backend/server/src/core/doc-renderer/index.ts
new file mode 100644
index 000000000..51a83913a
--- /dev/null
+++ b/packages/backend/server/src/core/doc-renderer/index.ts
@@ -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 };
diff --git a/packages/backend/server/src/core/doc-renderer/service.ts b/packages/backend/server/src/core/doc-renderer/service.ts
new file mode 100644
index 000000000..d07180e63
--- /dev/null
+++ b/packages/backend/server/src/core/doc-renderer/service.ts
@@ -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 {
+ const cacheKey = `workspace:${workspaceId}:doc:${guid}:content`;
+ const cachedResult = await this.cache.get(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 {
+ const cacheKey = `workspace:${workspaceId}:content`;
+ const cachedResult = await this.cache.get(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);
+ }
+}
diff --git a/packages/backend/server/src/core/doc/job.ts b/packages/backend/server/src/core/doc/job.ts
index b6ecc2d38..13c73afbb 100644
--- a/packages/backend/server/src/core/doc/job.ts
+++ b/packages/backend/server/src/core/doc/job.ts
@@ -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';
@@ -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
diff --git a/packages/backend/server/src/core/permission/service.ts b/packages/backend/server/src/core/permission/service.ts
index 65e926949..7fba7c688 100644
--- a/packages/backend/server/src/core/permission/service.ts
+++ b/packages/backend/server/src/core/permission/service.ts
@@ -212,7 +212,7 @@ export class PermissionService {
const count = await this.prisma.workspace.count({
where: {
id: ws,
- public: true,
+ enableUrlPreview: true,
},
});
diff --git a/packages/backend/server/src/core/utils/blocksuite.ts b/packages/backend/server/src/core/utils/blocksuite.ts
new file mode 100644
index 000000000..885536435
--- /dev/null
+++ b/packages/backend/server/src/core/utils/blocksuite.ts
@@ -0,0 +1,129 @@
+// TODO(@forehalo):
+// Because of the `@affine/server` package can't import directly from workspace packages,
+// this is a temprory solution to get the block suite data(title, description) from given yjs binary or yjs doc.
+// The logic is mainly copied from
+// - packages/frontend/core/src/modules/docs-search/worker/in-worker.ts
+// - packages/frontend/core/src/components/page-list/use-block-suite-page-preview.ts
+// and it's better to be provided by blocksuite
+
+import { Array, Doc, Map } from 'yjs';
+
+export interface PageDocContent {
+ title: string;
+ summary: string;
+}
+
+export interface WorkspaceDocContent {
+ name: string;
+ avatarKey: string;
+}
+
+type KnownFlavour =
+ | 'affine:page'
+ | 'affine:note'
+ | 'affine:surface'
+ | 'affine:paragraph'
+ | 'affine:list'
+ | 'affine:code'
+ | 'affine:image';
+
+export function parseWorkspaceDoc(doc: Doc): WorkspaceDocContent | null {
+ // not a workspace doc
+ if (!doc.share.has('meta')) {
+ return null;
+ }
+
+ const meta = doc.getMap('meta');
+
+ return {
+ name: meta.get('name') as string,
+ avatarKey: meta.get('avatar') as string,
+ };
+}
+
+export interface ParsePageOptions {
+ maxSummaryLength: number;
+}
+
+export function parsePageDoc(
+ doc: Doc,
+ opts: ParsePageOptions = { maxSummaryLength: 150 }
+): PageDocContent | null {
+ // not a page doc
+ if (!doc.share.has('blocks')) {
+ return null;
+ }
+
+ const blocks = doc.getMap