Add the two blocking test-coverage specs requested in the PR #214 review and clear the cheap non-blocking items. Must-fix: - share-alias-redirect.controller.spec.ts: routing/leak guard for the public GET /l/:alias resolver (modeled on share-seo.controller.routing.spec). Pins 302-to-canonical on a hit; SPA index without a 302 for unknown/dangling/ unreadable aliases and a null workspace (no name-existence leak); defensive percent-decoding treated as unknown; self-hosted findFirst vs subdomain findByHostname workspace resolution; 404 when no built client index exists. - share-alias.controller.spec.ts: authz gates with mocked PageRepo/ShareService/ ShareAliasService/PageAccessService. Covers cross-workspace/nonexistent page -> NotFoundException, validateCanEdit, resolveReadableSharePage null -> BadRequestException, isSharingAllowed false -> ForbiddenException, set happy path delegation, remove() of a dangling alias (pageId null) skipping validateCanEdit but still deleting, and for-page validateCanView. Cheap review items: - Remove dead Logger import/field from ShareAliasRedirectController. - Remove dead PagePermissionRepo import/dependency from ShareAliasController. - Register the new share-alias UI strings in en-US and ru-RU catalogs. - Add an [Unreleased]/Added CHANGELOG entry for /l/:alias (#205). - Drop the tautological boilerplate assertions from the migration spec (exports up/down; runtime checks of typed entity literals). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
4.1 KiB
TypeScript
140 lines
4.1 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Body,
|
|
Controller,
|
|
ForbiddenException,
|
|
HttpCode,
|
|
HttpStatus,
|
|
NotFoundException,
|
|
Post,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
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 { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|
import { PageAccessService } from '../page/page-access/page-access.service';
|
|
import { ShareService } from './share.service';
|
|
import { ShareAliasService } from './share-alias.service';
|
|
import {
|
|
RemoveShareAliasDto,
|
|
SetShareAliasDto,
|
|
ShareAliasAvailabilityDto,
|
|
ShareAliasForPageDto,
|
|
} from './dto/share-alias.dto';
|
|
|
|
/**
|
|
* Authenticated management of vanity `/l/:alias` links. The PUBLIC resolve path
|
|
* lives in `ShareAliasRedirectController` (`/l/:alias`); this controller only
|
|
* creates/retargets/removes/looks-up aliases for editors.
|
|
*/
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('share-aliases')
|
|
export class ShareAliasController {
|
|
constructor(
|
|
private readonly shareAliasService: ShareAliasService,
|
|
private readonly shareService: ShareService,
|
|
private readonly pageRepo: PageRepo,
|
|
private readonly pageAccessService: PageAccessService,
|
|
) {}
|
|
|
|
@HttpCode(HttpStatus.OK)
|
|
@Post('set')
|
|
async set(
|
|
@Body() dto: SetShareAliasDto,
|
|
@AuthUser() user: User,
|
|
@AuthWorkspace() workspace: Workspace,
|
|
) {
|
|
const page = await this.pageRepo.findById(dto.pageId);
|
|
if (!page || page.workspaceId !== workspace.id) {
|
|
throw new NotFoundException('Page not found');
|
|
}
|
|
|
|
// Editing the page is required to point an address at it.
|
|
await this.pageAccessService.validateCanEdit(page, user);
|
|
|
|
// The page must currently be publicly readable through the share graph; an
|
|
// alias to a non-shared page would only ever 404.
|
|
const resolved = await this.shareService.resolveReadableSharePage(
|
|
undefined,
|
|
page.id,
|
|
workspace.id,
|
|
);
|
|
if (!resolved) {
|
|
throw new BadRequestException('Page is not publicly shared');
|
|
}
|
|
|
|
const sharingAllowed = await this.shareService.isSharingAllowed(
|
|
workspace.id,
|
|
resolved.share.spaceId,
|
|
);
|
|
if (!sharingAllowed) {
|
|
throw new ForbiddenException('Public sharing is disabled');
|
|
}
|
|
|
|
return this.shareAliasService.setAlias({
|
|
workspaceId: workspace.id,
|
|
pageId: page.id,
|
|
creatorId: user.id,
|
|
alias: dto.alias,
|
|
confirmReassign: dto.confirmReassign,
|
|
});
|
|
}
|
|
|
|
@HttpCode(HttpStatus.OK)
|
|
@Post('remove')
|
|
async remove(
|
|
@Body() dto: RemoveShareAliasDto,
|
|
@AuthUser() user: User,
|
|
@AuthWorkspace() workspace: Workspace,
|
|
) {
|
|
const alias = await this.shareAliasService.getAliasById(
|
|
dto.aliasId,
|
|
workspace.id,
|
|
);
|
|
if (!alias) {
|
|
throw new NotFoundException('Alias not found');
|
|
}
|
|
|
|
// Only someone who can edit the (current) target page may free the address.
|
|
// A dangling alias (page deleted) can be removed by any workspace member.
|
|
if (alias.pageId) {
|
|
const page = await this.pageRepo.findById(alias.pageId);
|
|
if (page) {
|
|
await this.pageAccessService.validateCanEdit(page, user);
|
|
}
|
|
}
|
|
|
|
await this.shareAliasService.removeAlias(alias.id, workspace.id);
|
|
}
|
|
|
|
@HttpCode(HttpStatus.OK)
|
|
@Post('availability')
|
|
async availability(
|
|
@Body() dto: ShareAliasAvailabilityDto,
|
|
@AuthWorkspace() workspace: Workspace,
|
|
) {
|
|
return this.shareAliasService.checkAvailability(dto.alias, workspace.id);
|
|
}
|
|
|
|
@HttpCode(HttpStatus.OK)
|
|
@Post('for-page')
|
|
async forPage(
|
|
@Body() dto: ShareAliasForPageDto,
|
|
@AuthUser() user: User,
|
|
@AuthWorkspace() workspace: Workspace,
|
|
) {
|
|
const page = await this.pageRepo.findById(dto.pageId);
|
|
if (!page || page.workspaceId !== workspace.id) {
|
|
throw new NotFoundException('Page not found');
|
|
}
|
|
await this.pageAccessService.validateCanView(page, user);
|
|
|
|
return (
|
|
(await this.shareAliasService.getAliasForPage(page.id, workspace.id)) ??
|
|
null
|
|
);
|
|
}
|
|
}
|