feat(beta): public spaces (#2473)

This commit is contained in:
Philip Okugbe
2026-09-05 11:52:10 +01:00
committed by GitHub
parent f4796c982e
commit 876f3da1b2
117 changed files with 7592 additions and 715 deletions
@@ -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;
}
}