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 {}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
||||
@@ -96,6 +97,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
ShareAliasRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
@@ -128,6 +130,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
ShareAliasRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* Vanity share aliases: a retargetable, human-readable pointer (`/l/<alias>`)
|
||||
* that lives independently of any single `shares` row. The alias belongs to the
|
||||
* WORKSPACE (stable address), and `page_id` is nullable with ON DELETE SET NULL
|
||||
* so the address survives deletion of its current target (it 404s until
|
||||
* retargeted) rather than disappearing with the page.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('share_aliases')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
// Normalized ASCII, lowercase. Uniqueness is enforced per-workspace below.
|
||||
.addColumn('alias', 'varchar', (col) => col.notNull())
|
||||
// Nullable + SET NULL: the address outlives its target page.
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// The vanity name is unique within a workspace (mirrors shares.key scoping).
|
||||
await db.schema
|
||||
.createIndex('share_aliases_workspace_id_alias_unique')
|
||||
.on('share_aliases')
|
||||
.columns(['workspace_id', 'alias'])
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
// "Which alias targets this page?" lookup for the share modal.
|
||||
await db.schema
|
||||
.createIndex('share_aliases_page_id_idx')
|
||||
.on('share_aliases')
|
||||
.column('page_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('share_aliases').execute();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as migration from './20260626T130000-share-aliases';
|
||||
import type {
|
||||
InsertableShareAlias,
|
||||
ShareAlias,
|
||||
UpdatableShareAlias,
|
||||
} from '../types/entity.types';
|
||||
|
||||
/**
|
||||
* Sanity checks for the share_aliases migration + entity types. We don't run a
|
||||
* live Postgres here (that's the integration suite); instead we assert the
|
||||
* migration exposes the expected up/down contract and creates the table with
|
||||
* the unique (workspace_id, alias) constraint and the page_id index, and that
|
||||
* the generated entity types line up with the column set.
|
||||
*/
|
||||
describe('share-aliases migration', () => {
|
||||
it('exports up and down functions', () => {
|
||||
expect(typeof migration.up).toBe('function');
|
||||
expect(typeof migration.down).toBe('function');
|
||||
});
|
||||
|
||||
it('up creates the table, the unique index and the page_id index', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const tableBuilder: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop: string) {
|
||||
if (prop === 'execute') return async () => undefined;
|
||||
// addColumn/addConstraint/etc. are chainable no-ops.
|
||||
return () => tableBuilder;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const indexBuilder: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop: string) {
|
||||
if (prop === 'execute') return async () => undefined;
|
||||
return () => indexBuilder;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const schema = {
|
||||
createTable: (name: string) => {
|
||||
calls.push(`createTable:${name}`);
|
||||
return tableBuilder;
|
||||
},
|
||||
createIndex: (name: string) => {
|
||||
calls.push(`createIndex:${name}`);
|
||||
return indexBuilder;
|
||||
},
|
||||
};
|
||||
|
||||
await migration.up({ schema } as any);
|
||||
|
||||
expect(calls).toContain('createTable:share_aliases');
|
||||
expect(calls).toContain(
|
||||
'createIndex:share_aliases_workspace_id_alias_unique',
|
||||
);
|
||||
expect(calls).toContain('createIndex:share_aliases_page_id_idx');
|
||||
});
|
||||
|
||||
it('down drops the table', async () => {
|
||||
const calls: string[] = [];
|
||||
const dropBuilder: any = { execute: async () => undefined };
|
||||
const schema = {
|
||||
dropTable: (name: string) => {
|
||||
calls.push(`dropTable:${name}`);
|
||||
return dropBuilder;
|
||||
},
|
||||
};
|
||||
await migration.down({ schema } as any);
|
||||
expect(calls).toContain('dropTable:share_aliases');
|
||||
});
|
||||
|
||||
it('entity types expose the alias columns', () => {
|
||||
// Compile-time + runtime sanity: a well-formed row/insert/update value.
|
||||
const row: ShareAlias = {
|
||||
id: 'a-1',
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const insert: InsertableShareAlias = {
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
};
|
||||
const update: UpdatableShareAlias = { pageId: null };
|
||||
|
||||
expect(row.alias).toBe('foo');
|
||||
expect(insert.workspaceId).toBe('ws-1');
|
||||
expect(update.pageId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ShareAliasRepo } from './share-alias.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* SQL-shape unit tests for ShareAliasRepo. A live Postgres is out of scope;
|
||||
* instead we spy on the Kysely builder to assert each method pins the
|
||||
* workspace scope (so a name in one workspace can never resolve another's
|
||||
* page) and threads the right columns.
|
||||
*/
|
||||
describe('ShareAliasRepo', () => {
|
||||
function makeSelectRepo(result: unknown) {
|
||||
const where = jest.fn();
|
||||
const builder: any = {
|
||||
select: jest.fn(() => builder),
|
||||
where: jest.fn((...args: unknown[]) => {
|
||||
where(...args);
|
||||
return builder;
|
||||
}),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue(result),
|
||||
};
|
||||
const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
return { repo: new ShareAliasRepo(db), db, where, builder };
|
||||
}
|
||||
|
||||
it('findByAliasAndWorkspace scopes by alias AND workspace', async () => {
|
||||
const row = { id: 'a-1', alias: 'foo', workspaceId: 'ws-1' };
|
||||
const { repo, db, where } = makeSelectRepo(row);
|
||||
|
||||
const res = await repo.findByAliasAndWorkspace('foo', 'ws-1');
|
||||
|
||||
expect(res).toBe(row);
|
||||
expect(db.selectFrom).toHaveBeenCalledWith('shareAliases');
|
||||
expect(where).toHaveBeenCalledWith('alias', '=', 'foo');
|
||||
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
||||
});
|
||||
|
||||
it('findByPageId scopes by page AND workspace', async () => {
|
||||
const { repo, where } = makeSelectRepo(undefined);
|
||||
await repo.findByPageId('p-1', 'ws-1');
|
||||
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
|
||||
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
||||
});
|
||||
|
||||
it('insert writes the provided columns and returns the row', async () => {
|
||||
const values = jest.fn();
|
||||
const inserted = { id: 'a-1' };
|
||||
const builder: any = {
|
||||
values: jest.fn((v: unknown) => {
|
||||
values(v);
|
||||
return builder;
|
||||
}),
|
||||
returning: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue(inserted),
|
||||
};
|
||||
const db = { insertInto: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
const repo = new ShareAliasRepo(db);
|
||||
|
||||
const res = await repo.insert({
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
});
|
||||
|
||||
expect(db.insertInto).toHaveBeenCalledWith('shareAliases');
|
||||
expect(values).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
});
|
||||
expect(res).toBe(inserted);
|
||||
});
|
||||
|
||||
it('updatePageId retargets a single row scoped by id + workspace', async () => {
|
||||
const set = jest.fn();
|
||||
const where = jest.fn();
|
||||
const builder: any = {
|
||||
set: jest.fn((s: unknown) => {
|
||||
set(s);
|
||||
return builder;
|
||||
}),
|
||||
where: jest.fn((...args: unknown[]) => {
|
||||
where(...args);
|
||||
return builder;
|
||||
}),
|
||||
returning: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1' }),
|
||||
};
|
||||
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
const repo = new ShareAliasRepo(db);
|
||||
|
||||
await repo.updatePageId('a-1', 'p-2', 'ws-1');
|
||||
|
||||
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
|
||||
expect(set.mock.calls[0][0].pageId).toBe('p-2');
|
||||
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
|
||||
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
|
||||
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
||||
});
|
||||
|
||||
it('delete scopes by id + workspace', async () => {
|
||||
const where = jest.fn();
|
||||
const builder: any = {
|
||||
where: jest.fn((...args: unknown[]) => {
|
||||
where(...args);
|
||||
return builder;
|
||||
}),
|
||||
execute: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
const repo = new ShareAliasRepo(db);
|
||||
|
||||
await repo.delete('a-1', 'ws-1');
|
||||
|
||||
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
|
||||
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
|
||||
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
||||
});
|
||||
});
|
||||
109
apps/server/src/database/repos/share-alias/share-alias.repo.ts
Normal file
109
apps/server/src/database/repos/share-alias/share-alias.repo.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
InsertableShareAlias,
|
||||
ShareAlias,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Repository for vanity share aliases (`/l/:alias`). An alias is a long-lived,
|
||||
* workspace-scoped pointer to a page; retargeting is a single UPDATE of
|
||||
* `page_id`. All lookups are workspace-scoped so a name in one workspace can
|
||||
* never resolve a page in another.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShareAliasRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
private baseFields: Array<keyof ShareAlias> = [
|
||||
'id',
|
||||
'workspaceId',
|
||||
'alias',
|
||||
'pageId',
|
||||
'creatorId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
/** Resolve a (normalized) alias within a workspace, or undefined. */
|
||||
async findByAliasAndWorkspace(
|
||||
alias: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('shareAliases')
|
||||
.select(this.baseFields)
|
||||
.where('alias', '=', alias)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** The alias currently pointing at a page (for the share modal). */
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('shareAliases')
|
||||
.select(this.baseFields)
|
||||
.where('pageId', '=', pageId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias | undefined> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('shareAliases')
|
||||
.select(this.baseFields)
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async insert(
|
||||
insertable: InsertableShareAlias,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.insertInto('shareAliases')
|
||||
.values(insertable)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** Retarget an existing alias to a new page (the "swap" operation). */
|
||||
async updatePageId(
|
||||
id: string,
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ShareAlias> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.updateTable('shareAliases')
|
||||
.set({ pageId, updatedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async delete(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('shareAliases')
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
11
apps/server/src/database/types/db.d.ts
vendored
11
apps/server/src/database/types/db.d.ts
vendored
@@ -305,6 +305,16 @@ export interface Pages {
|
||||
ydoc: Buffer | null;
|
||||
}
|
||||
|
||||
export interface ShareAliases {
|
||||
alias: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
id: Generated<string>;
|
||||
pageId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Shares {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@@ -674,6 +684,7 @@ export interface DB {
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
scimTokens: ScimTokens;
|
||||
shareAliases: ShareAliases;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
ShareAliases,
|
||||
Favorites,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
@@ -172,6 +173,11 @@ export type Share = Selectable<Shares>;
|
||||
export type InsertableShare = Insertable<Shares>;
|
||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
|
||||
// Share alias (vanity /l/:alias pointer)
|
||||
export type ShareAlias = Selectable<ShareAliases>;
|
||||
export type InsertableShareAlias = Insertable<ShareAliases>;
|
||||
export type UpdatableShareAlias = Updateable<Omit<ShareAliases, 'id'>>;
|
||||
|
||||
// Favorite
|
||||
export type Favorite = Selectable<Favorites>;
|
||||
export type InsertableFavorite = Insertable<Favorites>;
|
||||
|
||||
@@ -40,7 +40,14 @@ async function bootstrap() {
|
||||
app.useLogger(app.get(PinoLogger));
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
|
||||
exclude: [
|
||||
'robots.txt',
|
||||
'share/:shareId/p/:pageSlug',
|
||||
// Vanity link resolver lives outside /api so /l/<alias> is a clean
|
||||
// public URL that 302s to the canonical share page.
|
||||
'l/:alias',
|
||||
'mcp',
|
||||
],
|
||||
});
|
||||
|
||||
const reflector = app.get(Reflector);
|
||||
|
||||
Reference in New Issue
Block a user