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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user