Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
83 changes: 59 additions & 24 deletions packages/backend/server/src/core/doc-renderer/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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
);
});
Expand All @@ -124,20 +172,7 @@ export class DocRendererController {
workspaceId: string,
docId: string
): Promise<RenderOptions | null> {
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);
}

Expand Down
Loading