feat(share): custom /l/:alias pretty links (share_aliases table) (#205)
Add a retargetable, human-readable vanity link namespace /l/<alias> that sits alongside the untouched /share/... routes. - New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias), page_id nullable ON DELETE SET NULL so the address outlives its target). - ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard / availability / request-time readable-target resolution through the single existing share boundary). - Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301, the target is mutable) to the canonical /share/:key/p/:slug page; unknown / dangling / no-longer-readable aliases serve the SPA index with no leak. 'l/:alias' excluded from the global /api prefix. - Authenticated ShareAliasController (set/remove/availability/for-page). - Shared ASCII-only normalize/validate util (server + client copies). - Client: Custom address block in the share modal (live normalize + debounced availability + copy + reassign confirmation dialog). - Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity (server jest) + client alias util (vitest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
44
apps/server/src/core/share/dto/share-alias.dto.ts
Normal file
44
apps/server/src/core/share/dto/share-alias.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Create/retarget a vanity alias for a page. `confirmReassign` is the
|
||||
* two-step guard for the "address already points at another page" case: the
|
||||
* first call without it gets a 409 carrying the current target, the client
|
||||
* confirms, and retries with `confirmReassign: true`.
|
||||
*/
|
||||
export class SetShareAliasDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
alias: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
confirmReassign?: boolean;
|
||||
}
|
||||
|
||||
export class RemoveShareAliasDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
aliasId: string;
|
||||
}
|
||||
|
||||
export class ShareAliasAvailabilityDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export class ShareAliasForPageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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 slugify from '@sindresorhus/slugify';
|
||||
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 { ShareAliasService } from './share-alias.service';
|
||||
|
||||
/**
|
||||
* Public resolver for vanity links `GET /l/:alias`. Excluded from the global
|
||||
* `/api` prefix (see main.ts) and parallel to ShareSeoController.
|
||||
*
|
||||
* On a hit it issues a 302 (NEVER 301) to the canonical
|
||||
* `/share/:key/p/:slug` page, so:
|
||||
* - the existing share render + SSR meta is reused verbatim (crawlers follow
|
||||
* the 302 and get the correct preview);
|
||||
* - because the alias target is mutable, a temporary redirect is always
|
||||
* re-resolved — a cached 301 would pin clients to the pre-swap page.
|
||||
*
|
||||
* Any unknown / dangling / no-longer-readable alias serves the plain SPA index
|
||||
* (same as any unknown path) so the existence of a name never leaks.
|
||||
*/
|
||||
@Controller('l')
|
||||
export class ShareAliasRedirectController {
|
||||
private readonly logger = new Logger(ShareAliasRedirectController.name);
|
||||
|
||||
constructor(
|
||||
private readonly shareAliasService: ShareAliasService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@Get(':alias')
|
||||
async resolve(
|
||||
@Param('alias') rawAlias: string,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res({ passthrough: false }) res: FastifyReply,
|
||||
) {
|
||||
// NestJS does not apply middlewares to paths excluded from the global /api
|
||||
// prefix, so the DomainMiddleware workspace resolution is duplicated here
|
||||
// (same workaround as ShareSeoController).
|
||||
let workspace: Workspace = null;
|
||||
if (this.environmentService.isSelfHosted()) {
|
||||
workspace = await this.workspaceRepo.findFirst();
|
||||
} else {
|
||||
const header = req.raw.headers.host;
|
||||
const subdomain = header?.split('.')[0];
|
||||
workspace = subdomain
|
||||
? await this.workspaceRepo.findByHostname(subdomain)
|
||||
: null;
|
||||
}
|
||||
|
||||
const clientDistPath = join(__dirname, '..', '..', '..', '..', 'client/dist');
|
||||
const indexFilePath = join(clientDistPath, 'index.html');
|
||||
|
||||
let decoded = rawAlias;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawAlias);
|
||||
} catch {
|
||||
// Malformed percent-encoding -> treat as unknown alias.
|
||||
}
|
||||
|
||||
const resolved = workspace
|
||||
? await this.shareAliasService.resolveReadableTarget(
|
||||
decoded,
|
||||
workspace.id,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!resolved) {
|
||||
return this.sendIndex(indexFilePath, res);
|
||||
}
|
||||
|
||||
const slug = buildPageSlug(resolved.page.slugId, resolved.page.title);
|
||||
// 302, NOT 301: the alias is retargetable, so the redirect must always be
|
||||
// re-resolved by clients/crawlers.
|
||||
return res.redirect(`/share/${resolved.share.key}/p/${slug}`, 302);
|
||||
}
|
||||
|
||||
private sendIndex(indexFilePath: string, res: FastifyReply) {
|
||||
if (!fs.existsSync(indexFilePath)) {
|
||||
// No built client (e.g. API-only dev): nothing to serve.
|
||||
res.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
const stream = fs.createReadStream(indexFilePath);
|
||||
res.type('text/html').send(stream);
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical share page slug: `<title-slug>-<slugId>` (mirrors the client). */
|
||||
function buildPageSlug(slugId: string, title?: string): string {
|
||||
const titleSlug = slugify(title?.substring(0, 70) || 'untitled');
|
||||
return `${titleSlug}-${slugId}`;
|
||||
}
|
||||
141
apps/server/src/core/share/share-alias.controller.ts
Normal file
141
apps/server/src/core/share/share-alias.controller.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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 { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.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 pagePermissionRepo: PagePermissionRepo,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
252
apps/server/src/core/share/share-alias.service.spec.ts
Normal file
252
apps/server/src/core/share/share-alias.service.spec.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
|
||||
/**
|
||||
* Behaviour tests for the alias write/resolve semantics: create vs no-op vs the
|
||||
* 409 reassign guard, uniqueness-race handling, availability probe, and the
|
||||
* request-time readable-target resolution (which re-runs the share boundary).
|
||||
*/
|
||||
describe('ShareAliasService', () => {
|
||||
function makeService() {
|
||||
const shareAliasRepo = {
|
||||
findByAliasAndWorkspace: jest.fn(),
|
||||
findByPageId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
updatePageId: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
const shareService = {
|
||||
resolveReadableSharePage: jest.fn(),
|
||||
isSharingAllowed: jest.fn(),
|
||||
};
|
||||
const service = new ShareAliasService(
|
||||
shareAliasRepo as any,
|
||||
pageRepo as any,
|
||||
shareService as any,
|
||||
);
|
||||
return { service, shareAliasRepo, pageRepo, shareService };
|
||||
}
|
||||
|
||||
describe('setAlias', () => {
|
||||
it('rejects an invalid alias before touching the db', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
await expect(
|
||||
service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'A', // too short + uppercase
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes then inserts a brand-new alias', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
|
||||
|
||||
const res = await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: ' My Page ',
|
||||
});
|
||||
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
|
||||
'my-page',
|
||||
'ws-1',
|
||||
);
|
||||
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'my-page',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
});
|
||||
expect(res).toMatchObject({ id: 'a-1' });
|
||||
});
|
||||
|
||||
it('is a no-op when the alias already points at the same page', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
|
||||
|
||||
const res = await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
});
|
||||
|
||||
expect(res).toBe(existing);
|
||||
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
|
||||
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 409 with current target when name is taken and not confirmed', async () => {
|
||||
const { service, shareAliasRepo, pageRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-other',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue({ id: 'p-other', title: 'Other' });
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'ALIAS_REASSIGN_REQUIRED',
|
||||
currentPageId: 'p-other',
|
||||
currentPageTitle: 'Other',
|
||||
});
|
||||
}
|
||||
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retargets (UPDATE page_id) when confirmReassign is set', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-other',
|
||||
});
|
||||
shareAliasRepo.updatePageId.mockResolvedValue({ id: 'a-1', pageId: 'p-1' });
|
||||
|
||||
const res = await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
confirmReassign: true,
|
||||
});
|
||||
|
||||
expect(shareAliasRepo.updatePageId).toHaveBeenCalledWith(
|
||||
'a-1',
|
||||
'p-1',
|
||||
'ws-1',
|
||||
);
|
||||
expect(res).toMatchObject({ pageId: 'p-1' });
|
||||
});
|
||||
|
||||
it('maps a unique-violation race to 409', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
|
||||
|
||||
await expect(
|
||||
service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAvailability', () => {
|
||||
it('reports invalid for a bad slug without a db hit', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
const res = await service.checkAvailability('Bad Slug!', 'ws-1');
|
||||
expect(res).toMatchObject({ valid: false, available: false });
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports available when no row exists', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
const res = await service.checkAvailability('free-name', 'ws-1');
|
||||
expect(res).toMatchObject({
|
||||
alias: 'free-name',
|
||||
valid: true,
|
||||
available: true,
|
||||
currentPageId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports taken with the current target page', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-9',
|
||||
});
|
||||
const res = await service.checkAvailability('taken', 'ws-1');
|
||||
expect(res).toMatchObject({ available: false, currentPageId: 'p-9' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveReadableTarget', () => {
|
||||
it('returns null for an invalid alias', async () => {
|
||||
const { service } = makeService();
|
||||
expect(await service.resolveReadableTarget('!!', 'ws-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unknown or dangling alias', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce(undefined);
|
||||
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
||||
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce({
|
||||
id: 'a-1',
|
||||
pageId: null,
|
||||
});
|
||||
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the page is no longer publicly readable', async () => {
|
||||
const { service, shareAliasRepo, shareService } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
shareService.resolveReadableSharePage.mockResolvedValue(null);
|
||||
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when sharing is disabled for the space', async () => {
|
||||
const { service, shareAliasRepo, shareService } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
shareService.resolveReadableSharePage.mockResolvedValue({
|
||||
share: { key: 'k', spaceId: 's-1' },
|
||||
page: { slugId: 'sid', title: 'T' },
|
||||
});
|
||||
shareService.isSharingAllowed.mockResolvedValue(false);
|
||||
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the resolved share+page on success', async () => {
|
||||
const { service, shareAliasRepo, shareService } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
const resolved = {
|
||||
share: { key: 'k', spaceId: 's-1' },
|
||||
page: { slugId: 'sid', title: 'T' },
|
||||
};
|
||||
shareService.resolveReadableSharePage.mockResolvedValue(resolved);
|
||||
shareService.isSharingAllowed.mockResolvedValue(true);
|
||||
|
||||
const res = await service.resolveReadableTarget('FOO', 'ws-1');
|
||||
expect(res).toBe(resolved);
|
||||
// alias was normalized to lowercase before lookup
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
|
||||
'foo',
|
||||
'ws-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
187
apps/server/src/core/share/share-alias.service.ts
Normal file
187
apps/server/src/core/share/share-alias.service.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { ShareService } from './share.service';
|
||||
import { Page, ShareAlias } from '@docmost/db/types/entity.types';
|
||||
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
|
||||
|
||||
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
|
||||
export interface ResolvedAliasTarget {
|
||||
share: NonNullable<
|
||||
Awaited<ReturnType<ShareService['resolveReadableSharePage']>>
|
||||
>['share'];
|
||||
page: Page;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShareAliasService {
|
||||
private readonly logger = new Logger(ShareAliasService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shareAliasRepo: ShareAliasRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly shareService: ShareService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create or retarget a vanity alias. The alias is workspace-scoped:
|
||||
* - no row for this name -> INSERT a new pointer
|
||||
* - row already points at pageId -> no-op (idempotent)
|
||||
* - row points elsewhere -> the "swap". Without confirmReassign we
|
||||
* throw 409 carrying the current target so the client can confirm; with
|
||||
* it we UPDATE the single row's page_id (every /l/<alias> link follows the
|
||||
* 302 to the new page instantly — no stale 301 cache).
|
||||
*
|
||||
* Caller is responsible for authorizing the page (edit rights + public
|
||||
* readability); this method owns only the alias-name semantics.
|
||||
*/
|
||||
async setAlias(opts: {
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
creatorId: string;
|
||||
alias: string;
|
||||
confirmReassign?: boolean;
|
||||
}): Promise<ShareAlias> {
|
||||
const { workspaceId, pageId, creatorId, confirmReassign } = opts;
|
||||
const alias = normalizeShareAlias(opts.alias);
|
||||
if (!isValidShareAlias(alias)) {
|
||||
throw new BadRequestException(
|
||||
'Invalid alias. Use 2-60 lowercase letters, digits and hyphens.',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
return await this.shareAliasRepo.insert({
|
||||
workspaceId,
|
||||
alias,
|
||||
pageId,
|
||||
creatorId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Lost a uniqueness race: another request claimed the name first.
|
||||
if (err?.code === PG_UNIQUE_VIOLATION) {
|
||||
throw new ConflictException({ message: 'Alias already taken' });
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to set alias');
|
||||
}
|
||||
}
|
||||
|
||||
// Already points at this page -> nothing to do.
|
||||
if (existing.pageId === pageId) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Name occupied by a different (or dangling) target: require confirmation.
|
||||
if (!confirmReassign) {
|
||||
const currentPage = existing.pageId
|
||||
? await this.pageRepo.findById(existing.pageId)
|
||||
: null;
|
||||
throw new ConflictException({
|
||||
message: 'Alias already in use',
|
||||
code: 'ALIAS_REASSIGN_REQUIRED',
|
||||
currentPageId: existing.pageId,
|
||||
currentPageTitle: currentPage?.title ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
|
||||
}
|
||||
|
||||
/** Free a vanity name (no history kept). */
|
||||
async removeAlias(aliasId: string, workspaceId: string): Promise<void> {
|
||||
await this.shareAliasRepo.delete(aliasId, workspaceId);
|
||||
}
|
||||
|
||||
/** Debounced availability probe for the modal. */
|
||||
async checkAvailability(
|
||||
rawAlias: string,
|
||||
workspaceId: string,
|
||||
): Promise<{
|
||||
alias: string;
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
}> {
|
||||
const alias = normalizeShareAlias(rawAlias);
|
||||
if (!isValidShareAlias(alias)) {
|
||||
return { alias, valid: false, available: false, currentPageId: null };
|
||||
}
|
||||
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
workspaceId,
|
||||
);
|
||||
return {
|
||||
alias,
|
||||
valid: true,
|
||||
available: !existing,
|
||||
currentPageId: existing?.pageId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A single alias row scoped to the workspace, or undefined. */
|
||||
getAliasById(
|
||||
aliasId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return this.shareAliasRepo.findById(aliasId, workspaceId);
|
||||
}
|
||||
|
||||
/** The alias currently targeting a page (modal display), or undefined. */
|
||||
getAliasForPage(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return this.shareAliasRepo.findByPageId(pageId, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a vanity alias to the canonical, publicly-READABLE share page, or
|
||||
* null. This re-runs the authoritative share boundary at request time (so a
|
||||
* later-unshared / restricted / sharing-disabled target collapses to null and
|
||||
* the caller serves the generic SPA 404 — no existence leak). The alias row
|
||||
* itself is just a pointer; this is where access is actually decided.
|
||||
*/
|
||||
async resolveReadableTarget(
|
||||
rawAlias: string,
|
||||
workspaceId: string,
|
||||
): Promise<ResolvedAliasTarget | null> {
|
||||
const alias = normalizeShareAlias(rawAlias);
|
||||
if (!isValidShareAlias(alias)) return null;
|
||||
|
||||
const aliasRow = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
workspaceId,
|
||||
);
|
||||
// Unknown name or a dangling alias (target page deleted) -> not resolvable.
|
||||
if (!aliasRow?.pageId) return null;
|
||||
|
||||
const resolved = await this.shareService.resolveReadableSharePage(
|
||||
undefined,
|
||||
aliasRow.pageId,
|
||||
workspaceId,
|
||||
);
|
||||
if (!resolved) return null;
|
||||
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspaceId,
|
||||
resolved.share.spaceId,
|
||||
);
|
||||
if (!sharingAllowed) return null;
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
60
apps/server/src/core/share/share-alias.util.spec.ts
Normal file
60
apps/server/src/core/share/share-alias.util.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
|
||||
|
||||
describe('normalizeShareAlias', () => {
|
||||
it('lowercases and trims', () => {
|
||||
expect(normalizeShareAlias(' HelloWorld ')).toBe('helloworld');
|
||||
});
|
||||
|
||||
it('converts spaces and underscores to single hyphens', () => {
|
||||
expect(normalizeShareAlias('my cool page')).toBe('my-cool-page');
|
||||
expect(normalizeShareAlias('my_cool_page')).toBe('my-cool-page');
|
||||
});
|
||||
|
||||
it('collapses repeated hyphens and trims edge hyphens', () => {
|
||||
expect(normalizeShareAlias('--a---b--')).toBe('a-b');
|
||||
});
|
||||
|
||||
it('handles null/undefined defensively', () => {
|
||||
expect(normalizeShareAlias(undefined as unknown as string)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidShareAlias', () => {
|
||||
it('accepts ascii lowercase hyphen-separated slugs', () => {
|
||||
expect(isValidShareAlias('hello')).toBe(true);
|
||||
expect(isValidShareAlias('hello-world-2')).toBe(true);
|
||||
expect(isValidShareAlias('a1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects too short / too long', () => {
|
||||
expect(isValidShareAlias('a')).toBe(false);
|
||||
expect(isValidShareAlias('a'.repeat(61))).toBe(false);
|
||||
expect(isValidShareAlias('a'.repeat(60))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects leading/trailing/double hyphens', () => {
|
||||
expect(isValidShareAlias('-abc')).toBe(false);
|
||||
expect(isValidShareAlias('abc-')).toBe(false);
|
||||
expect(isValidShareAlias('a--b')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects uppercase, cyrillic and other non-ascii', () => {
|
||||
expect(isValidShareAlias('Hello')).toBe(false);
|
||||
expect(isValidShareAlias('привет')).toBe(false);
|
||||
expect(isValidShareAlias('a b')).toBe(false);
|
||||
expect(isValidShareAlias('a_b')).toBe(false);
|
||||
expect(isValidShareAlias('a.b')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalize + validate round-trips a messy input to a valid slug', () => {
|
||||
const alias = normalizeShareAlias(' My Cool_Page!! ');
|
||||
// "!!" is not stripped by normalize (only case/separators), so the result
|
||||
// still fails validation — the charset gate is intentionally separate.
|
||||
expect(alias).toBe('my-cool-page!!');
|
||||
expect(isValidShareAlias(alias)).toBe(false);
|
||||
|
||||
const ok = normalizeShareAlias(' My Cool Page ');
|
||||
expect(ok).toBe('my-cool-page');
|
||||
expect(isValidShareAlias(ok)).toBe(true);
|
||||
});
|
||||
});
|
||||
30
apps/server/src/core/share/share-alias.util.ts
Normal file
30
apps/server/src/core/share/share-alias.util.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Vanity share-alias helpers shared by the write path (set/availability) and the
|
||||
* `/l/:alias` resolve path. Aliases are ASCII-only, lowercase, hyphen-separated
|
||||
* slugs — deliberately no Cyrillic / transliteration: the user types the exact
|
||||
* canonical form. Keep this in sync with the client copy in
|
||||
* `apps/client/src/features/share/share-alias.util.ts`.
|
||||
*/
|
||||
|
||||
// Normalize a user-provided vanity alias into canonical ASCII storage form.
|
||||
// This only canonicalizes shape (case, separators); it does NOT enforce the
|
||||
// charset — call isValidShareAlias afterwards to reject anything illegal.
|
||||
export function normalizeShareAlias(raw: string): string {
|
||||
return (raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-') // spaces/underscores -> single hyphen
|
||||
.replace(/-{2,}/g, '-') // collapse repeated hyphens
|
||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
// ASCII only: lowercase letters/digits in hyphen-separated groups, length 2..60.
|
||||
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
export function isValidShareAlias(alias: string): boolean {
|
||||
return (
|
||||
typeof alias === 'string' &&
|
||||
alias.length >= 2 &&
|
||||
alias.length <= 60 &&
|
||||
ALIAS_RE.test(alias)
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,22 @@ import { TokenModule } from '../auth/token.module';
|
||||
import { ShareSeoController } from './share-seo.controller';
|
||||
import { TransclusionModule } from '../page/transclusion/transclusion.module';
|
||||
import { AiModule } from '../../integrations/ai/ai.module';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
import { ShareAliasController } from './share-alias.controller';
|
||||
import { ShareAliasRedirectController } from './share-alias-redirect.controller';
|
||||
|
||||
@Module({
|
||||
// AiModule (AiSettingsService) is used by the page-info route to surface
|
||||
// whether the anonymous public-share assistant is enabled for the workspace.
|
||||
imports: [TokenModule, TransclusionModule, AiModule],
|
||||
controllers: [ShareController, ShareSeoController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
controllers: [
|
||||
ShareController,
|
||||
ShareSeoController,
|
||||
// Vanity /l/:alias: authenticated management + public 302 resolver.
|
||||
ShareAliasController,
|
||||
ShareAliasRedirectController,
|
||||
],
|
||||
providers: [ShareService, ShareAliasService],
|
||||
exports: [ShareService, ShareAliasService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
|
||||
Reference in New Issue
Block a user