feat(beta): public spaces (#2473)

This commit is contained in:
Philip Okugbe
2026-09-05 11:52:10 +01:00
committed by GitHub
parent f4796c982e
commit 876f3da1b2
117 changed files with 7592 additions and 715 deletions
+2
View File
@@ -18,6 +18,7 @@ import { PageAccessModule } from './page/page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
import { ShareModule } from './share/share.module';
import { PublicSpaceModule } from './public-space/public-space.module';
import { LabelModule } from './label/label.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
@@ -40,6 +41,7 @@ import { ClsMiddleware } from 'nestjs-cls';
CaslModule,
PageAccessModule,
ShareModule,
PublicSpaceModule,
LabelModule,
NotificationModule,
WatcherModule,
@@ -0,0 +1,79 @@
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { LookupDto } from '../../page/transclusion/dto/lookup.dto';
export const APPEARANCE_HEX_REGEX = /^#[0-9a-fA-F]{6}$/;
export class PublicSpaceSlugDto {
@IsString()
@IsNotEmpty()
spaceSlug: string;
}
export class PublicSpacePageDto extends PublicSpaceSlugDto {
@IsString()
@IsOptional()
pageSlugId?: string;
@IsOptional()
@IsBoolean()
contentless?: boolean;
}
export class PublicSpaceTransclusionLookupDto extends LookupDto {
@IsString()
@IsNotEmpty()
spaceSlug!: string;
}
export class PublicSpaceAppearanceDto {
@IsOptional()
@Matches(APPEARANCE_HEX_REGEX)
primaryColorLight?: string | null;
@IsOptional()
@Matches(APPEARANCE_HEX_REGEX)
primaryColorDark?: string | null;
}
export class PublishSpaceDto {
@IsUUID()
spaceId: string;
@IsBoolean()
enabled: boolean;
@IsOptional()
@IsBoolean()
searchIndexing?: boolean;
@IsOptional()
@ValidateNested()
@Type(() => PublicSpaceAppearanceDto)
appearance?: PublicSpaceAppearanceDto;
@IsOptional()
@IsBoolean()
bylineAuthor?: boolean;
@IsOptional()
@IsBoolean()
bylineUpdatedAt?: boolean;
@IsOptional()
@IsBoolean()
directory?: boolean;
}
export class PublicSpaceForSpaceDto {
@IsUUID()
spaceId: string;
}
@@ -0,0 +1,173 @@
import { Controller, Get, Logger, Param, Req, Res } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { join } from 'path';
import * as fs from 'node:fs';
import { validate as isValidUUID } from 'uuid';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
import { htmlEscape } from '../../common/helpers/html-escaper';
import { PublicSpaceService } from './public-space.service';
@Controller('docs')
export class PublicSpaceSeoController {
private readonly logger = new Logger(PublicSpaceSeoController.name);
constructor(
private readonly publicSpaceService: PublicSpaceService,
private workspaceRepo: WorkspaceRepo,
private environmentService: EnvironmentService,
) {}
/*
* The /docs hub: inject meta only when the directory is enabled;
* otherwise the untouched SPA shell (uniform with 404s).
*/
@Get()
async getDirectoryHub(
@Res({ passthrough: false }) res: FastifyReply,
@Req() req: FastifyRequest,
) {
const workspace = await this.resolveWorkspace(req);
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
if (!fs.existsSync(clientDistPath)) {
return;
}
const indexFilePath = join(clientDistPath, 'index.html');
if (!workspace) {
return this.sendIndex(indexFilePath, res);
}
try {
await this.publicSpaceService.getPublicSpaceDirectory(workspace);
} catch (err) {
return this.sendIndex(indexFilePath, res);
}
const metaTitle = 'Documentation';
const metaTagVar = '<!--meta-tags-->';
const metaTags = `<meta property="og:title" content="${metaTitle}" />`;
const html = fs.readFileSync(indexFilePath, 'utf8');
const transformedHtml = html
.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${metaTitle}</title>`)
.replace(metaTagVar, () => metaTags);
res.type('text/html').send(transformedHtml);
}
/*
* add meta tags to public space pages
*/
@Get([':spaceSlug', ':spaceSlug/:pageSlug'])
async getPublicSpacePage(
@Res({ passthrough: false }) res: FastifyReply,
@Req() req: FastifyRequest,
@Param('spaceSlug') spaceSlug: string,
@Param('pageSlug') pageSlug: string,
) {
const workspace = await this.resolveWorkspace(req);
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
if (!fs.existsSync(clientDistPath)) {
return;
}
const indexFilePath = join(clientDistPath, 'index.html');
if (!workspace) {
return this.sendIndex(indexFilePath, res);
}
let title: string = null;
let searchIndexing = false;
try {
if (pageSlug) {
const pageSlugId = this.extractPageSlugId(pageSlug);
const pageData = await this.publicSpaceService.getPublicPage(
spaceSlug,
pageSlugId,
workspace,
{ includeContent: false },
);
title = pageData.page?.title ?? pageData.space.name;
searchIndexing = pageData.searchIndexing;
} else {
const info = await this.publicSpaceService.getPublicSpaceInfo(
spaceSlug,
workspace,
);
title = info.space.name;
searchIndexing = info.searchIndexing;
}
} catch (err) {
// Not public: serve the untouched SPA shell with zero injected meta.
this.logger.debug(`no public meta for ${spaceSlug}`);
return this.sendIndex(indexFilePath, res);
}
const rawTitle = htmlEscape(title ?? 'untitled');
const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
const metaTagVar = '<!--meta-tags-->';
const metaTags = [
`<meta property="og:title" content="${metaTitle}" />`,
`<meta property="twitter:title" content="${metaTitle}" />`,
!searchIndexing ? `<meta name="robots" content="noindex" />` : '',
]
.filter(Boolean)
.join('\n ');
const html = fs.readFileSync(indexFilePath, 'utf8');
const transformedHtml = html
.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${metaTitle}</title>`)
.replace(metaTagVar, () => metaTags);
res.type('text/html').send(transformedHtml);
}
// Prefix-excluded routes skip middleware, so resolve the workspace inline
// exactly like ShareSeoController does.
private async resolveWorkspace(req: FastifyRequest): Promise<Workspace> {
if (this.environmentService.isSelfHosted()) {
return this.workspaceRepo.findFirst();
}
const header = req.raw.headers.host;
const subdomain = header.split('.')[0];
return this.workspaceRepo.findByHostname(subdomain);
}
sendIndex(indexFilePath: string, res: FastifyReply) {
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
}
extractPageSlugId(slug: string): string {
if (!slug) {
return undefined;
}
if (isValidUUID(slug)) {
return slug;
}
const parts = slug.split('-');
return parts.length > 1 ? parts[parts.length - 1] : slug;
}
}
@@ -0,0 +1,249 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { PublicSpaceService } from './public-space.service';
import {
PublicSpaceForSpaceDto,
PublicSpacePageDto,
PublicSpaceSlugDto,
PublicSpaceTransclusionLookupDto,
PublishSpaceDto,
} from './dto/public-space.dto';
import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { Feature, FeatureKey } from '../../common/features';
const PUBLIC_SPACE_FEATURES: FeatureKey[] = [Feature.PUBLIC_SPACE_APPEARANCE];
@UseGuards(JwtAuthGuard)
@Controller('public-spaces')
export class PublicSpaceController {
constructor(
private readonly publicSpaceService: PublicSpaceService,
private readonly publicSpaceRepo: PublicSpaceRepo,
private readonly spaceRepo: SpaceRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly licenseCheckService: LicenseCheckService,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
private assertBetaPublicSpaces() {
if (!this.environmentService.isBetaPublicSpaces()) {
throw new ForbiddenException(
'Public spaces are not enabled on this instance',
);
}
}
@HttpCode(HttpStatus.OK)
@Post('/')
async getPublishedSpaces(
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertBetaPublicSpaces();
return this.publicSpaceRepo.getPublishedSpaces(
user.id,
workspace.id,
pagination,
);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/info')
async getInfo(
@Body() dto: PublicSpaceSlugDto,
@AuthWorkspace() workspace: Workspace,
) {
const info = await this.publicSpaceService.getPublicSpaceInfo(
dto.spaceSlug,
workspace,
);
return {
...info,
features: this.publicFeatures(workspace),
};
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/directory')
async getDirectory(@AuthWorkspace() workspace: Workspace) {
const directory =
await this.publicSpaceService.getPublicSpaceDirectory(workspace);
return {
...directory,
features: this.publicFeatures(workspace),
};
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/tree')
async getTree(
@Body() dto: PublicSpaceSlugDto,
@AuthWorkspace() workspace: Workspace,
) {
const treeData = await this.publicSpaceService.getPublicSpaceTree(
dto.spaceSlug,
workspace,
);
return {
...treeData,
features: this.publicFeatures(workspace),
};
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/page-info')
async getPageInfo(
@Body() dto: PublicSpacePageDto,
@AuthWorkspace() workspace: Workspace,
) {
const pageData = await this.publicSpaceService.getPublicPage(
dto.spaceSlug,
dto.pageSlugId,
workspace,
{ includeContent: dto.contentless !== true },
);
return {
...pageData,
features: this.publicFeatures(workspace),
};
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/transclusion/lookup')
async transclusionLookup(
@Body() dto: PublicSpaceTransclusionLookupDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.publicSpaceService.lookupTransclusionForPublicSpace(
dto.spaceSlug,
dto.references,
workspace,
);
}
private publicFeatures(workspace: Workspace): string[] {
const features = this.licenseCheckService.resolveFeatures(
workspace.licenseKey,
workspace.plan,
);
return PUBLIC_SPACE_FEATURES.filter((feature) =>
features.includes(feature),
);
}
@HttpCode(HttpStatus.OK)
@Post('/for-space')
async getForSpace(
@Body() dto: PublicSpaceForSpaceDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertBetaPublicSpaces();
const space = await this.spaceRepo.findById(dto.spaceId, workspace.id);
if (!space || space.deletedAt) {
throw new NotFoundException('Space not found');
}
const ability = await this.spaceAbility.createForUser(user, space.id);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return (await this.publicSpaceRepo.findBySpaceId(space.id)) ?? null;
}
@HttpCode(HttpStatus.OK)
@Post('/publish')
async publish(
@Body() dto: PublishSpaceDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const space = await this.spaceRepo.findById(dto.spaceId, workspace.id);
if (!space || space.deletedAt) {
throw new NotFoundException('Space not found');
}
const ability = await this.spaceAbility.createForUser(user, space.id);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
const prev = await this.publicSpaceRepo.findBySpaceId(space.id);
const publicSpace = await this.publicSpaceService.publish({
space,
workspace,
authUserId: user.id,
enabled: dto.enabled,
searchIndexing: dto.searchIndexing,
appearance: dto.appearance,
bylineAuthor: dto.bylineAuthor,
bylineUpdatedAt: dto.bylineUpdatedAt,
directory: dto.directory,
});
const prevByline = (prev?.settings as any)?.byline;
const nextSettings = publicSpace?.settings as any;
this.auditService.log({
event: AuditEvent.SPACE_UPDATED,
resourceType: AuditResource.SPACE,
resourceId: space.id,
spaceId: space.id,
changes: {
before: {
isPublished: prev?.enabled ?? false,
searchIndexing: prev?.searchIndexing === true,
bylineAuthor: prevByline?.author === true,
bylineUpdatedAt: prevByline?.updatedAt !== false,
directory: (prev?.settings as any)?.directory === true,
},
after: {
isPublished: publicSpace?.enabled ?? dto.enabled,
searchIndexing: publicSpace?.searchIndexing === true,
bylineAuthor: nextSettings?.byline?.author === true,
bylineUpdatedAt: nextSettings?.byline?.updatedAt !== false,
directory: nextSettings?.directory === true,
},
},
});
return publicSpace;
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PublicSpaceController } from './public-space.controller';
import { PublicSpaceSeoController } from './public-space-seo.controller';
import { PublicSpaceService } from './public-space.service';
import { ShareModule } from '../share/share.module';
import { TransclusionModule } from '../page/transclusion/transclusion.module';
@Module({
imports: [ShareModule, TransclusionModule],
controllers: [PublicSpaceController, PublicSpaceSeoController],
providers: [PublicSpaceService],
exports: [PublicSpaceService],
})
export class PublicSpaceModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,397 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { ShareService } from '../share/share.service';
import { TransclusionService } from '../page/transclusion/transclusion.service';
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
import {
Page,
PublicSpace,
Space,
Workspace,
} from '@docmost/db/types/entity.types';
import { PublicSpaceAppearanceDto } from './dto/public-space.dto';
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Feature, FeatureKey } from '../../common/features';
@Injectable()
export class PublicSpaceService {
constructor(
private readonly publicSpaceRepo: PublicSpaceRepo,
private readonly spaceRepo: SpaceRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly shareService: ShareService,
private readonly transclusionService: TransclusionService,
private readonly licenseCheckService: LicenseCheckService,
private readonly environmentService: EnvironmentService,
) {}
hasFeature(workspace: Workspace, feature: FeatureKey): boolean {
return this.licenseCheckService
.resolveFeatures(workspace.licenseKey, workspace.plan)
.includes(feature);
}
isPublicSpacesAllowed(workspace: Workspace): boolean {
const settings = workspace.settings as any;
return (
this.environmentService.isBetaPublicSpaces() &&
settings?.publicSpaces?.enabled === true
);
}
private isDirectoryEnabled(workspace: Workspace): boolean {
return (workspace.settings as any)?.publicSpaces?.directory === true;
}
async getPublicSpace(spaceSlug: string, workspace: Workspace) {
if (!this.isPublicSpacesAllowed(workspace)) {
throw new NotFoundException('Space not found');
}
const space = await this.spaceRepo.findBySlug(spaceSlug, workspace.id);
if (!space || space.deletedAt) {
throw new NotFoundException('Space not found');
}
const publicSpace = await this.publicSpaceRepo.findBySpaceId(space.id);
if (!publicSpace?.enabled) {
throw new NotFoundException('Space not found');
}
return { space, publicSpace };
}
async getPublicSpaceInfo(spaceSlug: string, workspace: Workspace) {
const { space, publicSpace } = await this.getPublicSpace(
spaceSlug,
workspace,
);
return {
space: this.toPublicSpaceFields(space),
searchIndexing: publicSpace.searchIndexing,
appearance: this.toPublicAppearance(publicSpace, workspace),
};
}
async getPublicSpaceTree(spaceSlug: string, workspace: Workspace) {
const { space, publicSpace } = await this.getPublicSpace(
spaceSlug,
workspace,
);
const pageTree = await this.pageRepo.getSpacePagesExcludingRestricted(
space.id,
);
return {
space: this.toPublicSpaceFields(space),
pageTree,
appearance: this.toPublicAppearance(publicSpace, workspace),
};
}
async getPublicPage(
spaceSlug: string,
pageSlugId: string | undefined,
workspace: Workspace,
opts?: { includeContent?: boolean },
) {
const includeContent = opts?.includeContent !== false;
const { space, publicSpace } = await this.getPublicSpace(
spaceSlug,
workspace,
);
const byline = this.getBylineSettings(publicSpace);
let pageId = pageSlugId;
if (!pageId) {
const firstRoot = await this.pageRepo.getFirstUnrestrictedRootPage(
space.id,
);
if (!firstRoot) {
return {
page: null,
space: this.toPublicSpaceFields(space),
searchIndexing: publicSpace.searchIndexing,
appearance: this.toPublicAppearance(publicSpace, workspace),
byline,
};
}
pageId = firstRoot.id;
}
const page = includeContent
? await this.pageRepo.findById(pageId, {
includeContent: true,
includeCreator: byline.author,
})
: await this.pageRepo.findById(pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
// cross-space targets resolve only as contentless link probes, and only
// into published spaces; content stays canonical under its own space URL
if (page.spaceId !== space.id) {
if (includeContent) {
throw new NotFoundException('Page not found');
}
return this.resolveCrossSpacePublicPage(page, workspace);
}
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
page.id,
);
if (isRestricted) {
throw new NotFoundException('Page not found');
}
// never ship creator details the space admin chose to hide
if (!byline.author && 'creator' in page) {
delete (page as any).creator;
}
if (includeContent) {
page.content = await this.shareService.updatePublicAttachments(page);
}
return {
page,
space: this.toPublicSpaceFields(space),
searchIndexing: publicSpace.searchIndexing,
appearance: this.toPublicAppearance(publicSpace, workspace),
byline,
};
}
/** Uniform 404 unless the target page's own space is published, not deleted, in this workspace, and unrestricted. */
private async resolveCrossSpacePublicPage(page: Page, workspace: Workspace) {
const space = await this.spaceRepo.findById(page.spaceId, workspace.id);
if (!space || space.deletedAt) {
throw new NotFoundException('Page not found');
}
const publicSpace = await this.publicSpaceRepo.findBySpaceId(space.id);
if (!publicSpace?.enabled) {
throw new NotFoundException('Page not found');
}
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
page.id,
);
if (isRestricted) {
throw new NotFoundException('Page not found');
}
return {
page,
space: this.toPublicSpaceFields(space),
searchIndexing: publicSpace.searchIndexing,
appearance: this.toPublicAppearance(publicSpace, workspace),
byline: this.getBylineSettings(publicSpace),
};
}
/** Share-style transclusion resolution scoped to one public space; viewer permissions are never consulted. */
async lookupTransclusionForPublicSpace(
spaceSlug: string,
references: Array<{ sourcePageId: string; transclusionId: string }>,
workspace: Workspace,
): Promise<{ items: TransclusionLookup[] }> {
const { space } = await this.getPublicSpace(spaceSlug, workspace);
const candidatePageIds = Array.from(
new Set(references.map((r) => r.sourcePageId)),
);
const accessibleResults = await Promise.all(
candidatePageIds.map(async (pageId) => {
const page = await this.pageRepo.findById(pageId);
if (!page || page.deletedAt || page.spaceId !== space.id) return null;
const restricted =
await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
if (restricted) return null;
return page.id;
}),
);
const accessibleSet = new Set<string>(
accessibleResults.filter((id): id is string => id !== null),
);
const { items } = await this.transclusionService.lookupWithAccessSet(
references,
accessibleSet,
workspace.id,
);
return {
items: await this.shareService.sanitizeTransclusionItemsForPublic(
items,
workspace.id,
),
};
}
async publish(opts: {
space: Space;
workspace: Workspace;
authUserId: string;
enabled: boolean;
searchIndexing?: boolean;
appearance?: PublicSpaceAppearanceDto;
bylineAuthor?: boolean;
bylineUpdatedAt?: boolean;
directory?: boolean;
}) {
const {
space,
workspace,
authUserId,
enabled,
appearance,
bylineAuthor,
bylineUpdatedAt,
} = opts;
let { searchIndexing, directory } = opts;
if (!this.environmentService.isBetaPublicSpaces()) {
throw new ForbiddenException(
'Public spaces are not enabled on this instance',
);
}
if (enabled && !this.isPublicSpacesAllowed(workspace)) {
throw new ForbiddenException(
'Public spaces are not enabled for this workspace',
);
}
if (appearance && !this.hasFeature(workspace, Feature.PUBLIC_SPACE_APPEARANCE)) {
throw new ForbiddenException(
'Public docs appearance requires a paid license',
);
}
const prev = await this.publicSpaceRepo.findBySpaceId(space.id);
// first publish defaults every option on except the author byline; republish keeps prior customization
if (enabled && !prev) {
searchIndexing ??= true;
if (this.isDirectoryEnabled(workspace)) {
directory ??= true;
}
}
const hasByline =
typeof bylineAuthor !== 'undefined' ||
typeof bylineUpdatedAt !== 'undefined';
let settings: Record<string, unknown> | undefined;
if (appearance || hasByline || typeof directory !== 'undefined') {
const prevSettings = (prev?.settings as Record<string, any>) ?? {};
settings = { ...prevSettings };
if (typeof directory !== 'undefined') {
settings.directory = directory;
}
if (appearance) {
const nextAppearance: Record<string, string> = {
...(prevSettings.appearance ?? {}),
};
for (const key of ['primaryColorLight', 'primaryColorDark'] as const) {
const value = appearance[key];
if (value === null) delete nextAppearance[key];
else if (typeof value !== 'undefined') nextAppearance[key] = value;
}
settings.appearance = nextAppearance;
}
if (hasByline) {
const prevByline = this.getBylineSettings(prev);
settings.byline = {
author: bylineAuthor ?? prevByline.author,
updatedAt: bylineUpdatedAt ?? prevByline.updatedAt,
};
}
}
return this.publicSpaceRepo.upsert({
spaceId: space.id,
workspaceId: space.workspaceId,
enabled,
searchIndexing,
creatorId: authUserId,
settings,
});
}
/** The /docs hub: listed public spaces only, behind the workspace double opt-in. */
async getPublicSpaceDirectory(workspace: Workspace) {
if (
!this.isPublicSpacesAllowed(workspace) ||
!this.isDirectoryEnabled(workspace)
) {
throw new NotFoundException('Not found');
}
const rows = await this.publicSpaceRepo.findEnabledWithSpaceByWorkspaceId(
workspace.id,
);
const listed = rows.filter(
(row) => (row.settings as any)?.directory === true,
);
return {
spaces: listed.map((row) => ({
name: row.name,
slug: row.slug,
description: row.description,
logo: row.logo,
})),
};
}
private toPublicSpaceFields(space: Space) {
return {
id: space.id,
name: space.name,
slug: space.slug,
description: space.description,
logo: space.logo,
};
}
private getBylineSettings(publicSpace: PublicSpace) {
const byline = (publicSpace?.settings as any)?.byline;
return {
author: byline?.author === true,
updatedAt: byline?.updatedAt !== false,
};
}
private toPublicAppearance(publicSpace: PublicSpace, workspace: Workspace) {
if (!this.hasFeature(workspace, Feature.PUBLIC_SPACE_APPEARANCE)) {
return undefined;
}
const appearance = (publicSpace?.settings as any)?.appearance;
if (!appearance) return undefined;
const result: { primaryColorLight?: string; primaryColorDark?: string } =
{};
if (typeof appearance.primaryColorLight === 'string') {
result.primaryColorLight = appearance.primaryColorLight;
}
if (typeof appearance.primaryColorDark === 'string') {
result.primaryColorDark = appearance.primaryColorDark;
}
return Object.keys(result).length > 0 ? result : undefined;
}
}
@@ -53,6 +53,12 @@ export class SearchShareDTO extends SearchDTO {
spaceId: string;
}
export class SearchPublicSpaceDTO extends SearchDTO {
@IsNotEmpty()
@IsString()
spaceSlug: string;
}
export class SearchSuggestionDTO {
@IsString()
query: string;
@@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { SearchController } from './search.controller';
describe('SearchController', () => {
@@ -16,3 +17,77 @@ describe('SearchController', () => {
expect(controller).toBeDefined();
});
});
describe('SearchController public-space-search gate', () => {
function makeController(overrides: any = {}) {
const searchService = { searchPage: jest.fn().mockResolvedValue([]) };
const environmentService = {
getSearchDriver: jest.fn().mockReturnValue('postgres'),
};
const publicSpaceService = {
getPublicSpace: jest
.fn()
.mockRejectedValue(new NotFoundException('Space not found')),
...overrides.publicSpaceService,
};
const pageRepo = {
getSpacePagesExcludingRestricted: jest
.fn()
.mockResolvedValue([{ id: 'p1' }, { id: 'p2' }]),
};
const controller = new SearchController(
searchService as any,
{} as any,
environmentService as any,
publicSpaceService as any,
pageRepo as any,
{} as any,
);
return { controller, searchService, publicSpaceService, pageRepo };
}
const workspace = { id: 'ws1' } as any;
it('does not read pages or search when the public space gate rejects', async () => {
const { controller, searchService, pageRepo } = makeController();
await expect(
controller.searchPublicSpace(
{ query: 'roadmap', spaceSlug: 'handbook' } as any,
workspace,
),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageRepo.getSpacePagesExcludingRestricted).not.toHaveBeenCalled();
expect(searchService.searchPage).not.toHaveBeenCalled();
});
it('searches only the unrestricted pages of the gated space, ignoring client filters', async () => {
const { controller, searchService, publicSpaceService, pageRepo } =
makeController({
publicSpaceService: {
getPublicSpace: jest
.fn()
.mockResolvedValue({ space: { id: 's1' }, publicSpace: {} }),
},
});
await controller.searchPublicSpace(
{
query: 'roadmap',
spaceSlug: 'handbook',
spaceId: 'attacker-space',
shareId: 'share1',
creatorId: 'u1',
labelIds: ['l1'],
} as any,
workspace,
);
expect(publicSpaceService.getPublicSpace).toHaveBeenCalledWith(
'handbook',
workspace,
);
expect(pageRepo.getSpacePagesExcludingRestricted).toHaveBeenCalledWith('s1');
expect(searchService.searchPage).toHaveBeenCalledWith(
{ query: 'roadmap', spaceSlug: 'handbook' },
{ workspaceId: 'ws1', publicPageIds: ['p1', 'p2'] },
);
});
});
@@ -12,6 +12,7 @@ import {
import { SearchService } from './search.service';
import {
SearchDTO,
SearchPublicSpaceDTO,
SearchShareDTO,
SearchSuggestionDTO,
} from './dto/search.dto';
@@ -28,6 +29,8 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { Public } from 'src/common/decorators/public.decorator';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { ModuleRef } from '@nestjs/core';
import { PublicSpaceService } from '../public-space/public-space.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
@UseGuards(JwtAuthGuard)
@Controller('search')
@@ -38,6 +41,8 @@ export class SearchController {
private readonly searchService: SearchService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly environmentService: EnvironmentService,
private readonly publicSpaceService: PublicSpaceService,
private readonly pageRepo: PageRepo,
private moduleRef: ModuleRef,
) {}
@@ -109,14 +114,51 @@ export class SearchController {
});
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('public-space-search')
async searchPublicSpace(
@Body() searchDto: SearchPublicSpaceDTO,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.spaceId;
delete searchDto.shareId;
// Member-facing filters stay internal: creator identities and label
// taxonomy must not become a grouping oracle on the anonymous surface.
delete searchDto.creatorId;
delete searchDto.labelIds;
const { space } = await this.publicSpaceService.getPublicSpace(
searchDto.spaceSlug,
workspace,
);
const pages = await this.pageRepo.getSpacePagesExcludingRestricted(
space.id,
);
const publicPageIds = pages.map((page) => page.id);
if (this.environmentService.getSearchDriver() === 'typesense') {
return this.searchTypesense(searchDto, {
workspaceId: workspace.id,
publicPageIds,
});
}
return this.searchService.searchPage(searchDto, {
workspaceId: workspace.id,
publicPageIds,
});
}
async searchTypesense(
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
publicPageIds?: string[];
},
) {
const { userId, workspaceId } = opts;
const { userId, workspaceId, publicPageIds } = opts;
let TypesenseModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -132,6 +174,7 @@ export class SearchController {
return PageSearchService.searchPage(searchParams, {
userId: userId,
workspaceId,
publicPageIds,
});
} catch (err) {
this.logger.debug(
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { PublicSpaceModule } from '../public-space/public-space.module';
@Module({
imports: [PublicSpaceModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
+11 -1
View File
@@ -27,6 +27,7 @@ export class SearchService {
opts: {
userId?: string;
workspaceId: string;
publicPageIds?: string[];
},
): Promise<{ items: SearchResponseDto[] }> {
const query = searchParams.query?.trim() ?? '';
@@ -109,7 +110,7 @@ export class SearchService {
.limit(searchParams.limit || 25)
.offset(searchParams.offset || 0);
if (!searchParams.shareId) {
if (!searchParams.shareId && !opts.publicPageIds) {
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
}
@@ -124,6 +125,15 @@ export class SearchService {
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
} else if (opts.publicPageIds && !opts.userId) {
// Public space search: the allowed id set is computed from live DB
// state by the controller on every request.
if (opts.publicPageIds.length === 0) {
return { items: [] };
}
queryResults = queryResults
.where('id', 'in', opts.publicPageIds)
.where('workspaceId', '=', opts.workspaceId);
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
@@ -85,8 +85,8 @@ export class ShareSeoController {
const html = fs.readFileSync(indexFilePath, 'utf8');
const transformedHtml = html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${metaTitle}</title>`)
.replace(metaTagVar, metaTags);
.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${metaTitle}</title>`)
.replace(metaTagVar, () => metaTags);
res.type('text/html').send(transformedHtml);
}
+35 -29
View File
@@ -365,35 +365,9 @@ export class ShareService {
workspaceId,
);
// Sanitize each item's content for public delivery
// generate per-attachment tokens scoped to the source page
// and strip comment marks.
const tokenized = await Promise.all(
items.map(async (item) => {
if ('status' in item) return item;
const doc = await this.prepareContentForShare(
item.content,
item.sourcePageId,
workspaceId,
);
return { ...item, content: doc?.toJSON() ?? item.content };
}),
);
// Collapse `not_found` to `no_access` for share viewers so the response
// can't be used to tell "page is shared but transclusion id doesn't
// match" from "page isn't shared at all".
const sanitized = tokenized.map((item) =>
'status' in item && item.status === 'not_found'
? {
sourcePageId: item.sourcePageId,
transclusionId: item.transclusionId,
status: 'no_access' as const,
}
: item,
);
return { items: sanitized };
return {
items: await this.sanitizeTransclusionItemsForPublic(items, workspaceId),
};
}
async isSharingAllowed(
@@ -430,6 +404,38 @@ export class ShareService {
return doc?.toJSON() ?? page.content;
}
/**
* Sanitization tail shared by every public transclusion surface: tokenize
* each content item against its source page, then hide lookup misses.
*/
async sanitizeTransclusionItemsForPublic(
items: TransclusionLookup[],
workspaceId: string,
): Promise<TransclusionLookup[]> {
const tokenized = await Promise.all(
items.map(async (item) => {
if ('status' in item) return item;
const doc = await this.prepareContentForShare(
item.content,
item.sourcePageId,
workspaceId,
);
return { ...item, content: doc?.toJSON() ?? item.content };
}),
);
// Collapse not_found to no_access so hidden sources are indistinguishable from missing ids.
return tokenized.map((item) =>
'status' in item && item.status === 'not_found'
? {
sourcePageId: item.sourcePageId,
transclusionId: item.transclusionId,
status: 'no_access' as const,
}
: item,
);
}
/**
* Prepare a ProseMirror JSON doc for delivery to a public share viewer.
* Performs the two transforms required by the share threat model:
@@ -39,6 +39,14 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean()
disablePublicSharing: boolean;
@IsOptional()
@IsBoolean()
allowPublicSpaces: boolean;
@IsOptional()
@IsBoolean()
publicSpacesDirectory: boolean;
@IsOptional()
@IsBoolean()
mcpEnabled: boolean;
@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WorkspaceService } from './workspace.service';
describe('WorkspaceService', () => {
let service: WorkspaceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [WorkspaceService],
}).compile();
service = module.get<WorkspaceService>(WorkspaceService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -42,6 +42,7 @@ import {
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
@@ -65,6 +66,7 @@ export class WorkspaceService {
private domainService: DomainService,
private licenseCheckService: LicenseCheckService,
private shareRepo: ShareRepo,
private readonly publicSpaceRepo: PublicSpaceRepo,
private watcherRepo: WatcherRepo,
private favoriteRepo: FavoriteRepo,
@InjectKysely() private readonly db: KyselyDB,
@@ -504,6 +506,47 @@ export class WorkspaceService {
}
}
if (
!this.environmentService.isBetaPublicSpaces() &&
(typeof updateWorkspaceDto.allowPublicSpaces !== 'undefined' ||
typeof updateWorkspaceDto.publicSpacesDirectory !== 'undefined')
) {
throw new ForbiddenException(
'Public spaces are not enabled on this instance',
);
}
if (typeof updateWorkspaceDto.allowPublicSpaces !== 'undefined') {
const prev = settingsBefore?.publicSpaces?.enabled ?? false;
if (prev !== updateWorkspaceDto.allowPublicSpaces) {
before.allowPublicSpaces = prev;
after.allowPublicSpaces = updateWorkspaceDto.allowPublicSpaces;
}
await this.workspaceRepo.updatePublicSpacesSettings(
workspaceId,
'enabled',
updateWorkspaceDto.allowPublicSpaces,
trx,
);
if (!updateWorkspaceDto.allowPublicSpaces) {
await this.publicSpaceRepo.disableByWorkspaceId(workspaceId, trx);
}
}
if (typeof updateWorkspaceDto.publicSpacesDirectory !== 'undefined') {
const prev = settingsBefore?.publicSpaces?.directory ?? false;
if (prev !== updateWorkspaceDto.publicSpacesDirectory) {
before.publicSpacesDirectory = prev;
after.publicSpacesDirectory = updateWorkspaceDto.publicSpacesDirectory;
}
await this.workspaceRepo.updatePublicSpacesSettings(
workspaceId,
'directory',
updateWorkspaceDto.publicSpacesDirectory,
trx,
);
}
if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') {
const prev = settingsBefore?.ai?.mcp ?? false;
if (prev !== updateWorkspaceDto.mcpEnabled) {
@@ -620,6 +663,8 @@ export class WorkspaceService {
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
delete updateWorkspaceDto.disablePublicSharing;
delete updateWorkspaceDto.allowPublicSpaces;
delete updateWorkspaceDto.publicSpacesDirectory;
delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;