feat(server): add workspace avatar support in doc reader (#10390)

This commit is contained in:
fengmk2
2025-02-28 12:41:26 +00:00
parent 008fdfc234
commit b59f60c60b
5 changed files with 132 additions and 30 deletions

View File

@@ -5,7 +5,7 @@ import { Controller, Get, Logger, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import isMobile from 'is-mobile'; import isMobile from 'is-mobile';
import { Config, metrics, URLHelper } from '../../base'; import { Config, metrics } from '../../base';
import { htmlSanitize } from '../../native'; import { htmlSanitize } from '../../native';
import { Public } from '../auth'; import { Public } from '../auth';
import { DocReader } from '../doc'; import { DocReader } from '../doc';
@@ -52,8 +52,7 @@ export class DocRendererController {
constructor( constructor(
private readonly doc: DocReader, private readonly doc: DocReader,
private readonly permission: PermissionService, private readonly permission: PermissionService,
private readonly config: Config, private readonly config: Config
private readonly url: URLHelper
) { ) {
this.webAssets = this.readHtmlAssets( this.webAssets = this.readHtmlAssets(
join(this.config.projectRoot, 'static') join(this.config.projectRoot, 'static')
@@ -132,11 +131,7 @@ export class DocRendererController {
return { return {
title: workspaceContent.name, title: workspaceContent.name,
summary: '', summary: '',
avatar: workspaceContent.avatarKey avatar: workspaceContent.avatarUrl,
? this.url.link(
`/api/workspaces/${workspaceId}/blobs/${workspaceContent.avatarKey}`
)
: undefined,
}; };
} }
} }

View File

@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
import { User, Workspace } from '@prisma/client'; import { User, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
@@ -8,6 +9,7 @@ import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { AppModule } from '../../../app.module'; import { AppModule } from '../../../app.module';
import { ConfigModule } from '../../../base/config'; import { ConfigModule } from '../../../base/config';
import { Models } from '../../../models'; import { Models } from '../../../models';
import { WorkspaceBlobStorage } from '../../storage/wrappers/blob';
import { DocReader, PgWorkspaceDocStorageAdapter } from '..'; import { DocReader, PgWorkspaceDocStorageAdapter } from '..';
import { DatabaseDocReader } from '../reader'; import { DatabaseDocReader } from '../reader';
@@ -16,6 +18,7 @@ const test = ava as TestFn<{
app: TestingApp; app: TestingApp;
docReader: DocReader; docReader: DocReader;
adapter: PgWorkspaceDocStorageAdapter; adapter: PgWorkspaceDocStorageAdapter;
blobStorage: WorkspaceBlobStorage;
}>; }>;
test.before(async t => { test.before(async t => {
@@ -26,6 +29,7 @@ test.before(async t => {
t.context.models = app.get(Models); t.context.models = app.get(Models);
t.context.docReader = app.get(DocReader); t.context.docReader = app.get(DocReader);
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter); t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
t.context.blobStorage = app.get(WorkspaceBlobStorage);
t.context.app = app; t.context.app = app;
}); });
@@ -40,6 +44,10 @@ test.beforeEach(async t => {
workspace = await t.context.models.workspace.create(user.id); workspace = await t.context.models.workspace.create(user.id);
}); });
test.afterEach.always(() => {
mock.reset();
});
test.after.always(async t => { test.after.always(async t => {
await t.context.app.close(); await t.context.app.close();
}); });
@@ -162,8 +170,9 @@ test('should get doc content', async t => {
t.is(docContent, null); t.is(docContent, null);
}); });
test('should get workspace content', async t => { test('should get workspace content with default avatar', async t => {
const { docReader } = t.context; const { docReader } = t.context;
t.true(docReader instanceof DatabaseDocReader);
const doc = new YDoc(); const doc = new YDoc();
const text = doc.getText('content'); const text = doc.getText('content');
@@ -184,7 +193,66 @@ test('should get workspace content', async t => {
user.id user.id
); );
// @ts-expect-error parseWorkspaceContent is private
const track = mock.method(docReader, 'parseWorkspaceContent', () => ({
name: 'Test Workspace',
avatarKey: '',
}));
const workspaceContent = await docReader.getWorkspaceContent(workspace.id); const workspaceContent = await docReader.getWorkspaceContent(workspace.id);
// TODO(@fengmk2): should create a test ydoc with blocks t.truthy(workspaceContent);
t.is(workspaceContent, null); t.deepEqual(workspaceContent, {
id: workspace.id,
name: 'Test Workspace',
avatarKey: '',
avatarUrl: undefined,
});
t.is(track.mock.callCount(), 1);
});
test('should get workspace content with custom avatar', async t => {
const { docReader, blobStorage } = t.context;
t.true(docReader instanceof DatabaseDocReader);
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
await t.context.adapter.pushDocUpdates(
workspace.id,
workspace.id,
updates,
user.id
);
const avatarKey = randomUUID();
await blobStorage.put(
workspace.id,
avatarKey,
Buffer.from('mock avatar image data here')
);
// @ts-expect-error parseWorkspaceContent is private
const track = mock.method(docReader, 'parseWorkspaceContent', () => ({
name: 'Test Workspace',
avatarKey,
}));
const workspaceContent = await docReader.getWorkspaceContent(workspace.id);
t.truthy(workspaceContent);
t.deepEqual(workspaceContent, {
id: workspace.id,
name: 'Test Workspace',
avatarKey,
avatarUrl: `http://localhost:3010/api/workspaces/${workspace.id}/blobs/${avatarKey}`,
});
t.is(track.mock.callCount(), 1);
}); });

View File

@@ -302,17 +302,24 @@ test('should return null when doc content not exists', async t => {
test('should get workspace content from doc service rpc', async t => { test('should get workspace content from doc service rpc', async t => {
const { docReader, databaseDocReader } = t.context; const { docReader, databaseDocReader } = t.context;
mock.method(databaseDocReader, 'getWorkspaceContent', async () => { const track = mock.method(
databaseDocReader,
'getWorkspaceContent',
async () => {
return { return {
id: workspace.id,
name: 'test name', name: 'test name',
avatarKey: 'avatar key', avatarKey: '',
}; };
}); }
);
const workspaceContent = await docReader.getWorkspaceContent(workspace.id); const workspaceContent = await docReader.getWorkspaceContent(workspace.id);
t.is(track.mock.callCount(), 1);
t.deepEqual(workspaceContent, { t.deepEqual(workspaceContent, {
id: workspace.id,
name: 'test name', name: 'test name',
avatarKey: 'avatar key', avatarKey: '',
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
import { PermissionModule } from '../permission'; import { PermissionModule } from '../permission';
import { QuotaModule } from '../quota'; import { QuotaModule } from '../quota';
import { StorageModule } from '../storage';
import { PgUserspaceDocStorageAdapter } from './adapters/userspace'; import { PgUserspaceDocStorageAdapter } from './adapters/userspace';
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace'; import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
import { DocEventsListener } from './event'; import { DocEventsListener } from './event';
@@ -12,7 +13,7 @@ import { DocStorageOptions } from './options';
import { DatabaseDocReader, DocReader, DocReaderProvider } from './reader'; import { DatabaseDocReader, DocReader, DocReaderProvider } from './reader';
@Module({ @Module({
imports: [QuotaModule, PermissionModule], imports: [QuotaModule, PermissionModule, StorageModule],
providers: [ providers: [
DocStorageOptions, DocStorageOptions,
PgWorkspaceDocStorageAdapter, PgWorkspaceDocStorageAdapter,

View File

@@ -12,19 +12,27 @@ import {
Config, Config,
CryptoHelper, CryptoHelper,
getOrGenRequestId, getOrGenRequestId,
URLHelper,
UserFriendlyError, UserFriendlyError,
} from '../../base'; } from '../../base';
import { WorkspaceBlobStorage } from '../storage';
import { import {
type PageDocContent, type PageDocContent,
parsePageDoc, parsePageDoc,
parseWorkspaceDoc, parseWorkspaceDoc,
type WorkspaceDocContent,
} from '../utils/blocksuite'; } from '../utils/blocksuite';
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace'; import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
import { type DocDiff, type DocRecord } from './storage'; import { type DocDiff, type DocRecord } from './storage';
const DOC_CONTENT_CACHE_7_DAYS = 7 * 24 * 60 * 60 * 1000; const DOC_CONTENT_CACHE_7_DAYS = 7 * 24 * 60 * 60 * 1000;
export interface WorkspaceDocInfo {
id: string;
name: string;
avatarKey?: string;
avatarUrl?: string;
}
export abstract class DocReader { export abstract class DocReader {
constructor(protected readonly cache: Cache) {} constructor(protected readonly cache: Cache) {}
@@ -60,9 +68,9 @@ export abstract class DocReader {
async getWorkspaceContent( async getWorkspaceContent(
workspaceId: string workspaceId: string
): Promise<WorkspaceDocContent | null> { ): Promise<WorkspaceDocInfo | null> {
const cacheKey = this.cacheKey(workspaceId, workspaceId); const cacheKey = this.cacheKey(workspaceId, workspaceId);
const cachedResult = await this.cache.get<WorkspaceDocContent>(cacheKey); const cachedResult = await this.cache.get<WorkspaceDocInfo>(cacheKey);
if (cachedResult) { if (cachedResult) {
return cachedResult; return cachedResult;
} }
@@ -91,7 +99,7 @@ export abstract class DocReader {
protected abstract getWorkspaceContentWithoutCache( protected abstract getWorkspaceContentWithoutCache(
workspaceId: string workspaceId: string
): Promise<WorkspaceDocContent | null>; ): Promise<WorkspaceDocInfo | null>;
protected docDiff(update: Uint8Array, stateVector?: Uint8Array) { protected docDiff(update: Uint8Array, stateVector?: Uint8Array) {
const missing = stateVector ? diffUpdate(update, stateVector) : update; const missing = stateVector ? diffUpdate(update, stateVector) : update;
@@ -107,7 +115,9 @@ export abstract class DocReader {
export class DatabaseDocReader extends DocReader { export class DatabaseDocReader extends DocReader {
constructor( constructor(
protected override readonly cache: Cache, protected override readonly cache: Cache,
protected readonly workspace: PgWorkspaceDocStorageAdapter protected readonly workspace: PgWorkspaceDocStorageAdapter,
protected readonly blobStorage: WorkspaceBlobStorage,
protected readonly url: URLHelper
) { ) {
super(cache); super(cache);
} }
@@ -146,13 +156,32 @@ export class DatabaseDocReader extends DocReader {
protected override async getWorkspaceContentWithoutCache( protected override async getWorkspaceContentWithoutCache(
workspaceId: string workspaceId: string
): Promise<WorkspaceDocContent | null> { ): Promise<WorkspaceDocInfo | null> {
const docRecord = await this.workspace.getDoc(workspaceId, workspaceId); const docRecord = await this.workspace.getDoc(workspaceId, workspaceId);
if (!docRecord) { if (!docRecord) {
return null; return null;
} }
const content = this.parseWorkspaceContent(docRecord.bin);
if (!content) {
return null;
}
let avatarUrl: string | undefined;
if (content.avatarKey) {
avatarUrl = this.url.link(
`/api/workspaces/${workspaceId}/blobs/${content.avatarKey}`
);
}
return {
id: workspaceId,
name: content.name,
avatarKey: content.avatarKey,
avatarUrl,
};
}
private parseWorkspaceContent(bin: Uint8Array) {
const doc = new YDoc(); const doc = new YDoc();
applyUpdate(doc, docRecord.bin); applyUpdate(doc, bin);
return parseWorkspaceDoc(doc); return parseWorkspaceDoc(doc);
} }
} }
@@ -165,9 +194,11 @@ export class RpcDocReader extends DatabaseDocReader {
private readonly config: Config, private readonly config: Config,
private readonly crypto: CryptoHelper, private readonly crypto: CryptoHelper,
protected override readonly cache: Cache, protected override readonly cache: Cache,
protected override readonly workspace: PgWorkspaceDocStorageAdapter protected override readonly workspace: PgWorkspaceDocStorageAdapter,
protected override readonly blobStorage: WorkspaceBlobStorage,
protected override readonly url: URLHelper
) { ) {
super(cache, workspace); super(cache, workspace, blobStorage, url);
} }
private async fetch( private async fetch(
@@ -305,7 +336,7 @@ export class RpcDocReader extends DatabaseDocReader {
protected override async getWorkspaceContentWithoutCache( protected override async getWorkspaceContentWithoutCache(
workspaceId: string workspaceId: string
): Promise<WorkspaceDocContent | null> { ): Promise<WorkspaceDocInfo | null> {
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/content`; const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/content`;
const accessToken = this.crypto.sign(workspaceId); const accessToken = this.crypto.sign(workspaceId);
try { try {
@@ -313,7 +344,7 @@ export class RpcDocReader extends DatabaseDocReader {
if (!res) { if (!res) {
return null; return null;
} }
return (await res.json()) as WorkspaceDocContent; return (await res.json()) as WorkspaceDocInfo;
} catch (e) { } catch (e) {
if (e instanceof UserFriendlyError) { if (e instanceof UserFriendlyError) {
throw e; throw e;