mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
feat(beta): public spaces (#2473)
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user