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:
@@ -26,6 +26,7 @@ export const Feature = {
|
||||
OAUTH: 'oauth',
|
||||
AI_CONTROLS: 'ai:controls',
|
||||
MCP_CONTROLS: 'mcp:controls',
|
||||
PUBLIC_SPACE_APPEARANCE: 'public-space:appearance',
|
||||
SIEM: 'siem',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
||||
@@ -88,6 +89,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
PublicSpaceRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
@@ -113,6 +115,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
PublicSpaceRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('public_spaces')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade').notNull().unique(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(false))
|
||||
.addColumn('search_indexing', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.addColumn('settings', 'jsonb', (col) => col)
|
||||
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('public_spaces_workspace_id_idx')
|
||||
.on('public_spaces')
|
||||
.column('workspace_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('public_spaces').execute();
|
||||
}
|
||||
@@ -605,4 +605,83 @@ export class PageRepo {
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All pages of a space excluding restricted subtrees.
|
||||
* Used by public spaces; a restricted page hides its whole subtree.
|
||||
*/
|
||||
async getSpacePagesExcludingRestricted(spaceId: string) {
|
||||
return this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.position',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.where('pages.spaceId', '=', spaceId)
|
||||
.where('pages.parentPageId', 'is', null)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('ph.isRestricted', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
.where('isRestricted', '=', false)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getFirstUnrestrictedRootPage(spaceId: string) {
|
||||
return this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slugId'])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('parentPageId', 'is', null)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('pageAccess')
|
||||
.select('pageAccess.id')
|
||||
.whereRef('pageAccess.pageId', '=', 'pages.id'),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy('position', (ob) => ob.collate('C').asc())
|
||||
.executeTakeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import { PublicSpace } from '@docmost/db/types/entity.types';
|
||||
import { DB, Json } from '@docmost/db/types/db';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
|
||||
@Injectable()
|
||||
export class PublicSpaceRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findBySpaceId(
|
||||
spaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PublicSpace> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('publicSpaces')
|
||||
.selectAll()
|
||||
.where('spaceId', '=', spaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async upsert(opts: {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
enabled: boolean;
|
||||
searchIndexing?: boolean;
|
||||
creatorId: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}): Promise<PublicSpace> {
|
||||
const settingsColumn =
|
||||
typeof opts.settings !== 'undefined'
|
||||
? { settings: sql<Json>`${JSON.stringify(opts.settings)}::text::jsonb` }
|
||||
: {};
|
||||
|
||||
return this.db
|
||||
.insertInto('publicSpaces')
|
||||
.values({
|
||||
spaceId: opts.spaceId,
|
||||
workspaceId: opts.workspaceId,
|
||||
enabled: opts.enabled,
|
||||
searchIndexing: opts.searchIndexing ?? false,
|
||||
creatorId: opts.creatorId,
|
||||
...settingsColumn,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column('spaceId').doUpdateSet({
|
||||
enabled: opts.enabled,
|
||||
updatedAt: new Date(),
|
||||
...(typeof opts.searchIndexing !== 'undefined'
|
||||
? { searchIndexing: opts.searchIndexing }
|
||||
: {}),
|
||||
...settingsColumn,
|
||||
}),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
// Published spaces are public by definition, so the list spans the whole
|
||||
// workspace; userId only resolves the viewer's per-space role.
|
||||
async getPublishedSpaces(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
const query = this.db
|
||||
.selectFrom('publicSpaces')
|
||||
.select([
|
||||
'id',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'searchIndexing',
|
||||
'settings',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
])
|
||||
.select((eb) => this.withSpace(eb, userId))
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('enabled', '=', true)
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom('spaces')
|
||||
.select('spaces.id')
|
||||
.whereRef('spaces.id', '=', 'publicSpaces.spaceId')
|
||||
.where('spaces.deletedAt', 'is', null),
|
||||
),
|
||||
);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'updatedAt', direction: 'desc' },
|
||||
{ expression: 'id', direction: 'desc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
withSpace(eb: ExpressionBuilder<DB, 'publicSpaces'>, userId: string) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.select((eb) => this.withUserSpaceRole(eb, userId))
|
||||
.whereRef('spaces.id', '=', 'publicSpaces.spaceId'),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
withUserSpaceRole(eb: ExpressionBuilder<DB, 'spaces'>, userId: string) {
|
||||
return eb
|
||||
.selectFrom(
|
||||
eb
|
||||
.selectFrom('spaceMembers')
|
||||
.select(['spaceMembers.role'])
|
||||
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
|
||||
.where('spaceMembers.userId', '=', userId)
|
||||
.unionAll(
|
||||
eb
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin(
|
||||
'groupUsers',
|
||||
'groupUsers.groupId',
|
||||
'spaceMembers.groupId',
|
||||
)
|
||||
.select(['spaceMembers.role'])
|
||||
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
|
||||
.where('groupUsers.userId', '=', userId),
|
||||
)
|
||||
.as('roles_union'),
|
||||
)
|
||||
.select('roles_union.role')
|
||||
.orderBy(
|
||||
sql`CASE roles_union.role
|
||||
WHEN 'admin' THEN 3
|
||||
WHEN 'writer' THEN 2
|
||||
WHEN 'reader' THEN 1
|
||||
ELSE 0
|
||||
END`,
|
||||
'desc',
|
||||
)
|
||||
.limit(1)
|
||||
.as('userRole');
|
||||
}
|
||||
|
||||
withCreator(eb: ExpressionBuilder<DB, 'publicSpaces'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'publicSpaces.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
|
||||
async findEnabledWithSpaceByWorkspaceId(workspaceId: string) {
|
||||
return this.db
|
||||
.selectFrom('publicSpaces')
|
||||
.innerJoin('spaces', 'spaces.id', 'publicSpaces.spaceId')
|
||||
.select([
|
||||
'publicSpaces.settings',
|
||||
'publicSpaces.searchIndexing',
|
||||
'spaces.name',
|
||||
'spaces.slug',
|
||||
'spaces.description',
|
||||
'spaces.logo',
|
||||
])
|
||||
.where('publicSpaces.workspaceId', '=', workspaceId)
|
||||
.where('publicSpaces.enabled', '=', true)
|
||||
.where('spaces.deletedAt', 'is', null)
|
||||
.orderBy('spaces.name', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async disableByWorkspaceId(
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('publicSpaces')
|
||||
.set({ enabled: false, updatedAt: new Date() })
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export class SpaceRepo {
|
||||
.selectFrom('spaces')
|
||||
.selectAll('spaces')
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.select((eb) => this.withIsPublished(eb))
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
if (isValidUUID(spaceId)) {
|
||||
@@ -52,6 +53,7 @@ export class SpaceRepo {
|
||||
.selectFrom('spaces')
|
||||
.selectAll('spaces')
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.select((eb) => this.withIsPublished(eb))
|
||||
.where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -170,6 +172,7 @@ export class SpaceRepo {
|
||||
.selectFrom('spaces')
|
||||
.selectAll('spaces')
|
||||
.select((eb) => [this.withMemberCount(eb)])
|
||||
.select((eb) => this.withIsPublished(eb))
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
if (pagination.query) {
|
||||
@@ -221,6 +224,18 @@ export class SpaceRepo {
|
||||
.as('memberCount');
|
||||
}
|
||||
|
||||
withIsPublished(eb: ExpressionBuilder<DB, 'spaces'>) {
|
||||
return eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('publicSpaces')
|
||||
.select('publicSpaces.id')
|
||||
.whereRef('publicSpaces.spaceId', '=', 'spaces.id')
|
||||
.where('publicSpaces.enabled', '=', true),
|
||||
)
|
||||
.as('isPublished');
|
||||
}
|
||||
|
||||
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('spaces')
|
||||
|
||||
@@ -249,6 +249,26 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updatePublicSpacesSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('publicSpaces', COALESCE(settings->'publicSpaces', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateTemplateSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
|
||||
+13
@@ -336,6 +336,18 @@ export interface Pages {
|
||||
ydoc: Buffer | null;
|
||||
}
|
||||
|
||||
export interface PublicSpaces {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
enabled: Generated<boolean>;
|
||||
id: Generated<string>;
|
||||
searchIndexing: Generated<boolean>;
|
||||
settings: Json | null;
|
||||
spaceId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Shares {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@@ -747,6 +759,7 @@ export interface DB {
|
||||
pageVerifications: PageVerifications;
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
publicSpaces: PublicSpaces;
|
||||
scimTokens: ScimTokens;
|
||||
shares: Shares;
|
||||
siemDestinations: SiemDestinations;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
PublicSpaces,
|
||||
Favorites,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
@@ -152,6 +153,11 @@ export type Share = Selectable<Shares>;
|
||||
export type InsertableShare = Insertable<Shares>;
|
||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
|
||||
// PublicSpace
|
||||
export type PublicSpace = Selectable<PublicSpaces>;
|
||||
export type InsertablePublicSpace = Insertable<PublicSpaces>;
|
||||
export type UpdatablePublicSpace = Updateable<Omit<PublicSpaces, 'id'>>;
|
||||
|
||||
// Favorite
|
||||
export type Favorite = Selectable<Favorites>;
|
||||
export type InsertableFavorite = Insertable<Favorites>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 32f1664543...039bd87f8a
@@ -248,6 +248,13 @@ export class EnvironmentService {
|
||||
return disable === 'true';
|
||||
}
|
||||
|
||||
isBetaPublicSpaces(): boolean {
|
||||
const enabled = this.configService
|
||||
.get<string>('BETA_PUBLIC_SPACES', 'false')
|
||||
.toLowerCase();
|
||||
return enabled === 'true';
|
||||
}
|
||||
|
||||
getPostHogHost(): string {
|
||||
return this.configService.get<string>('POSTHOG_HOST');
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export class StaticModule implements OnModuleInit {
|
||||
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
|
||||
? this.environmentService.getBillingTrialDays()
|
||||
: undefined,
|
||||
BETA_PUBLIC_SPACES: this.environmentService.isBetaPublicSpaces(),
|
||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||
AI_VECTOR_DRIVER:
|
||||
|
||||
@@ -49,6 +49,9 @@ async function bootstrap() {
|
||||
'.well-known/oauth-authorization-server',
|
||||
'.well-known/oauth-protected-resource',
|
||||
'.well-known/oauth-protected-resource/mcp',
|
||||
'docs',
|
||||
'docs/:spaceSlug',
|
||||
'docs/:spaceSlug/:pageSlug',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -70,7 +73,8 @@ async function bootstrap() {
|
||||
// Skipped routes:
|
||||
// /api/files/ - attachment controller sets its own CSP we'd overwrite
|
||||
// /share/ - public share pages are safe to embed
|
||||
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
|
||||
// /docs/ - public space pages are safe to embed
|
||||
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/', '/docs/'];
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
|
||||
Reference in New Issue
Block a user