diff --git a/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts b/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts index 8dfcda3b5..56d75b112 100644 --- a/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts +++ b/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts @@ -109,3 +109,45 @@ test('should record page view when rendering shared page', async t => { docContent.restore(); record.restore(); }); + +test('should return markdown content and skip page view when accept is text/markdown', async t => { + const docId = randomUUID(); + const { app, adapter, models, docReader } = t.context; + + const doc = new YDoc(); + const text = doc.getText('content'); + const updates: Buffer[] = []; + + doc.on('update', update => { + updates.push(Buffer.from(update)); + }); + + text.insert(0, 'markdown'); + await adapter.pushDocUpdates(workspace.id, docId, updates, user.id); + await models.doc.publish(workspace.id, docId); + + const markdown = Sinon.stub(docReader, 'getDocMarkdown').resolves({ + title: 'markdown-doc', + markdown: '# markdown-doc', + }); + const docContent = Sinon.stub(docReader, 'getDocContent'); + const record = Sinon.stub( + models.workspaceAnalytics, + 'recordDocView' + ).resolves(); + + const res = await app + .GET(`/workspace/${workspace.id}/${docId}`) + .set('accept', 'text/markdown') + .expect(200); + + t.true(markdown.calledOnceWithExactly(workspace.id, docId, false)); + t.is(res.text, '# markdown-doc'); + t.true((res.headers['content-type'] as string).startsWith('text/markdown')); + t.true(docContent.notCalled); + t.true(record.notCalled); + + markdown.restore(); + docContent.restore(); + record.restore(); +}); diff --git a/packages/backend/server/src/core/doc-renderer/controller.ts b/packages/backend/server/src/core/doc-renderer/controller.ts index 3246daad2..6cc3cc8bc 100644 --- a/packages/backend/server/src/core/doc-renderer/controller.ts +++ b/packages/backend/server/src/core/doc-renderer/controller.ts @@ -44,6 +44,12 @@ const staticPaths = new Set([ 'trash', ]); +const markdownType = [ + 'text/markdown', + 'application/markdown', + 'text/x-markdown', +]; + @Controller('/workspace') export class DocRendererController { private readonly logger = new Logger(DocRendererController.name); @@ -68,6 +74,21 @@ export class DocRendererController { .digest('hex'); } + private async allowDocPreview(workspaceId: string, docId: string) { + const allowSharing = await this.models.workspace.allowSharing(workspaceId); + if (!allowSharing) return false; + + let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId); + + if (!allowUrlPreview) { + // if page is private, but workspace url preview is on + allowUrlPreview = + await this.models.workspace.allowUrlPreview(workspaceId); + } + + return allowUrlPreview; + } + @Public() @Get('/*path') async render(@Req() req: Request, @Res() res: Response) { @@ -81,28 +102,55 @@ export class DocRendererController { let opts: RenderOptions | null = null; // /workspace/:workspaceId/{:docId | staticPaths} - const [, , workspaceId, subPath, ...restPaths] = req.path.split('/'); + const [, , workspaceId, sub, ...rest] = req.path.split('/'); + const isWorkspace = + workspaceId && sub && !staticPaths.has(sub) && rest.length === 0; + const isDocPath = isWorkspace && workspaceId !== sub; + + if ( + isDocPath && + req.accepts().some(t => markdownType.includes(t.toLowerCase())) + ) { + try { + const allowPreview = await this.allowDocPreview(workspaceId, sub); + if (!allowPreview) { + res.status(404).end(); + return; + } + + const markdown = await this.doc.getDocMarkdown(workspaceId, sub, false); + if (markdown) { + res.setHeader('Content-Type', 'text/markdown; charset=utf-8'); + res.send(markdown.markdown); + return; + } + } catch (e) { + this.logger.error('failed to render markdown page', e); + } + + res.status(404).end(); + return; + } // /:workspaceId/:docId - if (workspaceId && !staticPaths.has(subPath) && restPaths.length === 0) { + if (isWorkspace) { try { - opts = - workspaceId === subPath - ? await this.getWorkspaceContent(workspaceId) - : await this.getPageContent(workspaceId, subPath); + opts = isDocPath + ? await this.getPageContent(workspaceId, sub) + : await this.getWorkspaceContent(workspaceId); metrics.doc.counter('render').add(1); - if (opts && workspaceId !== subPath) { + if (opts && isDocPath) { void this.models.workspaceAnalytics .recordDocView({ workspaceId, - docId: subPath, - visitorId: this.buildVisitorId(req, workspaceId, subPath), + docId: sub, + visitorId: this.buildVisitorId(req, workspaceId, sub), isGuest: true, }) .catch(error => { this.logger.warn( - `Failed to record shared page view: ${workspaceId}/${subPath}`, + `Failed to record shared page view: ${workspaceId}/${sub}`, error as Error ); }); @@ -124,20 +172,7 @@ export class DocRendererController { workspaceId: string, docId: string ): Promise { - const allowSharing = await this.models.workspace.allowSharing(workspaceId); - if (!allowSharing) { - return null; - } - - let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId); - - if (!allowUrlPreview) { - // if page is private, but workspace url preview is on - allowUrlPreview = - await this.models.workspace.allowUrlPreview(workspaceId); - } - - if (allowUrlPreview) { + if (await this.allowDocPreview(workspaceId, docId)) { return this.doc.getDocContent(workspaceId, docId); }