From fdeede003bfa35451d3dc3b3db6c39f6c70e0554 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 26 Jun 2026 06:28:26 +0300 Subject: [PATCH] feat(share): custom /l/:alias pretty links (share_aliases table) (#205) Add a retargetable, human-readable vanity link namespace /l/ 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) --- .../share/components/share-alias-section.tsx | 237 ++++++++++++++++ .../features/share/components/share-modal.tsx | 4 + .../src/features/share/queries/share-query.ts | 71 +++++ .../features/share/services/share-service.ts | 33 +++ .../features/share/share-alias.util.test.ts | 32 +++ .../src/features/share/share-alias.util.ts | 26 ++ .../src/features/share/types/share.types.ts | 24 ++ .../src/core/share/dto/share-alias.dto.ts | 44 +++ .../share/share-alias-redirect.controller.ts | 97 +++++++ .../src/core/share/share-alias.controller.ts | 141 ++++++++++ .../core/share/share-alias.service.spec.ts | 252 ++++++++++++++++++ .../src/core/share/share-alias.service.ts | 187 +++++++++++++ .../src/core/share/share-alias.util.spec.ts | 60 +++++ .../server/src/core/share/share-alias.util.ts | 30 +++ apps/server/src/core/share/share.module.ts | 15 +- apps/server/src/database/database.module.ts | 3 + .../20260626T130000-share-aliases.ts | 54 ++++ .../share-aliases.migration.spec.ts | 99 +++++++ .../share-alias/share-alias.repo.spec.ts | 120 +++++++++ .../repos/share-alias/share-alias.repo.ts | 109 ++++++++ apps/server/src/database/types/db.d.ts | 11 + .../server/src/database/types/entity.types.ts | 6 + apps/server/src/main.ts | 9 +- 23 files changed, 1660 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/features/share/components/share-alias-section.tsx create mode 100644 apps/client/src/features/share/share-alias.util.test.ts create mode 100644 apps/client/src/features/share/share-alias.util.ts create mode 100644 apps/server/src/core/share/dto/share-alias.dto.ts create mode 100644 apps/server/src/core/share/share-alias-redirect.controller.ts create mode 100644 apps/server/src/core/share/share-alias.controller.ts create mode 100644 apps/server/src/core/share/share-alias.service.spec.ts create mode 100644 apps/server/src/core/share/share-alias.service.ts create mode 100644 apps/server/src/core/share/share-alias.util.spec.ts create mode 100644 apps/server/src/core/share/share-alias.util.ts create mode 100644 apps/server/src/database/migrations/20260626T130000-share-aliases.ts create mode 100644 apps/server/src/database/migrations/share-aliases.migration.spec.ts create mode 100644 apps/server/src/database/repos/share-alias/share-alias.repo.spec.ts create mode 100644 apps/server/src/database/repos/share-alias/share-alias.repo.ts diff --git a/apps/client/src/features/share/components/share-alias-section.tsx b/apps/client/src/features/share/components/share-alias-section.tsx new file mode 100644 index 00000000..870c360b --- /dev/null +++ b/apps/client/src/features/share/components/share-alias-section.tsx @@ -0,0 +1,237 @@ +import { + ActionIcon, + Button, + Group, + Modal, + Text, + TextInput, +} from "@mantine/core"; +import { IconExternalLink } from "@tabler/icons-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import CopyTextButton from "@/components/common/copy.tsx"; +import { getAppUrl } from "@/lib/config.ts"; +import { + useRemoveShareAliasMutation, + useSetShareAliasMutation, + useShareAliasForPageQuery, +} from "@/features/share/queries/share-query.ts"; +import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts"; +import { + isValidShareAlias, + normalizeShareAlias, +} from "@/features/share/share-alias.util.ts"; + +interface ShareAliasSectionProps { + pageId: string; + readOnly: boolean; +} + +// The prefix label shown next to the slug input, e.g. "docs.example.com/l/". +function aliasPrefixLabel(): string { + const url = getAppUrl(); + const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + return `${host}/l/`; +} + +export default function ShareAliasSection({ + pageId, + readOnly, +}: ShareAliasSectionProps) { + const { t } = useTranslation(); + const { data: currentAlias } = useShareAliasForPageQuery(pageId); + const setAliasMutation = useSetShareAliasMutation(); + const removeAliasMutation = useRemoveShareAliasMutation(); + + const [value, setValue] = useState(""); + const [availability, setAvailability] = useState<{ + valid: boolean; + available: boolean; + currentPageId: string | null; + } | null>(null); + const [reassign, setReassign] = useState<{ + alias: string; + currentPageTitle: string | null; + } | null>(null); + + // Seed the input from the page's current alias (if any). + useEffect(() => { + setValue(currentAlias?.alias ?? ""); + }, [currentAlias?.alias, pageId]); + + const normalized = useMemo(() => normalizeShareAlias(value), [value]); + const isValid = isValidShareAlias(normalized); + const unchanged = currentAlias?.alias === normalized; + + // Debounced availability probe (skips when invalid or unchanged). + const debounceRef = useRef>(); + useEffect(() => { + setAvailability(null); + if (!isValid || unchanged) return; + debounceRef.current && clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + try { + const res = await checkShareAliasAvailability(normalized); + setAvailability({ + valid: res.valid, + available: res.available, + currentPageId: res.currentPageId, + }); + } catch { + setAvailability(null); + } + }, 400); + return () => { + debounceRef.current && clearTimeout(debounceRef.current); + }; + }, [normalized, isValid, unchanged]); + + const prettyLink = currentAlias?.alias + ? `${getAppUrl()}/l/${currentAlias.alias}` + : null; + + const handleSave = async (confirmReassign = false) => { + try { + await setAliasMutation.mutateAsync({ + pageId, + alias: normalized, + confirmReassign, + }); + setReassign(null); + } catch (error: any) { + // The address already points at another page: prompt to move it here. + if (error?.status === 409 || error?.response?.status === 409) { + const data = error?.response?.data; + if (data?.code === "ALIAS_REASSIGN_REQUIRED") { + setReassign({ + alias: normalized, + currentPageTitle: data?.currentPageTitle ?? null, + }); + } + } + } + }; + + const handleRemove = async () => { + if (!currentAlias?.id) return; + await removeAliasMutation.mutateAsync(currentAlias.id); + setValue(""); + }; + + const showInvalid = normalized.length > 0 && !isValid; + const showTaken = + isValid && !unchanged && availability && !availability.available; + + return ( + <> + + {t("Custom address")} + + + {t("A short, memorable link you can point at any shared page.")} + + + {prettyLink && ( + + } + style={{ width: "100%" }} + /> + + + + + )} + + setValue(e.currentTarget.value)} + // Show the canonical form once the user pauses so what they type maps + // visibly to what gets stored. + onBlur={() => setValue(normalized)} + leftSection={ + + {aliasPrefixLabel()} + + } + leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)} + placeholder={t("my-page")} + disabled={readOnly} + error={ + showInvalid + ? t("Use 2-60 lowercase letters, digits and hyphens") + : showTaken + ? t("This address is already in use") + : undefined + } + /> + + + + {currentAlias?.id && ( + + )} + + + setReassign(null)} + title={t("Move custom address?")} + centered + size="sm" + > + + {reassign?.currentPageTitle + ? t( + 'The address "{{alias}}" currently points to "{{title}}". Move it to this page?', + { + alias: reassign?.alias, + title: reassign?.currentPageTitle, + }, + ) + : t( + 'The address "{{alias}}" is already in use. Move it to this page?', + { alias: reassign?.alias }, + )} + + + + + + + + ); +} diff --git a/apps/client/src/features/share/components/share-modal.tsx b/apps/client/src/features/share/components/share-modal.tsx index 5a7d92e6..7cb4a8ab 100644 --- a/apps/client/src/features/share/components/share-modal.tsx +++ b/apps/client/src/features/share/components/share-modal.tsx @@ -25,6 +25,7 @@ import CopyTextButton from "@/components/common/copy.tsx"; import { getAppUrl } from "@/lib/config.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import classes from "@/features/share/components/share.module.css"; +import ShareAliasSection from "@/features/share/components/share-alias-section.tsx"; import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; @@ -253,6 +254,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) { disabled={readOnly} /> + {pageId && ( + + )} )} diff --git a/apps/client/src/features/share/queries/share-query.ts b/apps/client/src/features/share/queries/share-query.ts index 30fcabc2..56f659e0 100644 --- a/apps/client/src/features/share/queries/share-query.ts +++ b/apps/client/src/features/share/queries/share-query.ts @@ -10,6 +10,8 @@ import { useTranslation } from "react-i18next"; import { ICreateShare, IShare, + IShareAlias, + ISetShareAlias, ISharedItem, ISharedPage, ISharedPageTree, @@ -20,11 +22,14 @@ import { import { createShare, deleteShare, + getShareAliasForPage, getSharedPageTree, getShareForPage, getShareInfo, getSharePageInfo, getShares, + removeShareAlias, + setShareAlias, updateShare, } from "@/features/share/services/share-service.ts"; import { IPagination, QueryParams } from "@/lib/types.ts"; @@ -170,6 +175,72 @@ export function useDeleteShareMutation() { }); } +export function useShareAliasForPageQuery( + pageId: string, +): UseQueryResult { + return useQuery({ + // The endpoint resolves to null when the page has no alias; normalize the + // absence so React Query never sees `undefined`. + queryKey: ["share-alias-for-page", pageId], + queryFn: async () => (await getShareAliasForPage(pageId)) ?? null, + enabled: !!pageId, + staleTime: 60 * 1000, + retry: false, + }); +} + +export function useSetShareAliasMutation() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => setShareAlias(data), + onSuccess: () => { + queryClient.invalidateQueries({ + predicate: (item) => + ["share-alias-for-page", "share-list"].includes( + item.queryKey[0] as string, + ), + }); + }, + onError: (error) => { + // A 409 reassign-required is handled inline by the modal (it shows the + // "move address here?" confirmation), so don't surface a generic toast. + if (error?.["status"] === 409) return; + notifications.show({ + message: + error?.["response"]?.data?.message || t("Failed to set custom address"), + color: "red", + }); + }, + }); +} + +export function useRemoveShareAliasMutation() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (aliasId) => removeShareAlias(aliasId), + onSuccess: () => { + queryClient.invalidateQueries({ + predicate: (item) => + ["share-alias-for-page", "share-list"].includes( + item.queryKey[0] as string, + ), + }); + }, + onError: (error) => { + notifications.show({ + message: + error?.["response"]?.data?.message || + t("Failed to remove custom address"), + color: "red", + }); + }, + }); +} + export function useGetSharedPageTreeQuery( shareId: string, ): UseQueryResult { diff --git a/apps/client/src/features/share/services/share-service.ts b/apps/client/src/features/share/services/share-service.ts index 2f43ba20..bd1bc81b 100644 --- a/apps/client/src/features/share/services/share-service.ts +++ b/apps/client/src/features/share/services/share-service.ts @@ -4,6 +4,9 @@ import { IPage } from "@/features/page/types/page.types"; import { ICreateShare, IShare, + IShareAlias, + IShareAliasAvailability, + ISetShareAlias, ISharedItem, ISharedPage, ISharedPageTree, @@ -57,3 +60,33 @@ export async function getSharedPageTree( const req = await api.post("/shares/tree", { shareId }); return req.data; } + +export async function getShareAliasForPage( + pageId: string, +): Promise { + const req = await api.post("/share-aliases/for-page", { + pageId, + }); + return req.data; +} + +export async function setShareAlias( + data: ISetShareAlias, +): Promise { + const req = await api.post("/share-aliases/set", data); + return req.data; +} + +export async function removeShareAlias(aliasId: string): Promise { + await api.post("/share-aliases/remove", { aliasId }); +} + +export async function checkShareAliasAvailability( + alias: string, +): Promise { + const req = await api.post( + "/share-aliases/availability", + { alias }, + ); + return req.data; +} diff --git a/apps/client/src/features/share/share-alias.util.test.ts b/apps/client/src/features/share/share-alias.util.test.ts new file mode 100644 index 00000000..3600ce59 --- /dev/null +++ b/apps/client/src/features/share/share-alias.util.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { + isValidShareAlias, + normalizeShareAlias, +} from "@/features/share/share-alias.util.ts"; + +// Mirrors the server-side util so the modal's live feedback matches what the +// server will accept/store. +describe("normalizeShareAlias", () => { + it("lowercases, trims and maps separators to single hyphens", () => { + expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page"); + }); + + it("collapses repeated hyphens and trims edges", () => { + expect(normalizeShareAlias("--a---b--")).toBe("a-b"); + }); +}); + +describe("isValidShareAlias", () => { + it("accepts ascii hyphen-separated slugs of length 2..60", () => { + expect(isValidShareAlias("hello-world")).toBe(true); + expect(isValidShareAlias("a".repeat(60))).toBe(true); + }); + + it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => { + expect(isValidShareAlias("a")).toBe(false); + expect(isValidShareAlias("-a")).toBe(false); + expect(isValidShareAlias("a--b")).toBe(false); + expect(isValidShareAlias("Hello")).toBe(false); + expect(isValidShareAlias("привет")).toBe(false); + }); +}); diff --git a/apps/client/src/features/share/share-alias.util.ts b/apps/client/src/features/share/share-alias.util.ts new file mode 100644 index 00000000..3a91dcb2 --- /dev/null +++ b/apps/client/src/features/share/share-alias.util.ts @@ -0,0 +1,26 @@ +/** + * Client copy of the vanity share-alias helpers. Kept in sync with the server + * (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback + * matches what the server will store/accept. ASCII-only, lowercase, hyphen + * separated, length 2..60. + */ + +// Normalize a user-provided vanity alias into canonical ASCII storage form. +export function normalizeShareAlias(raw: string): string { + return (raw ?? "") + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, ""); +} + +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) + ); +} diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts index ad92acf5..1104196d 100644 --- a/apps/client/src/features/share/types/share.types.ts +++ b/apps/client/src/features/share/types/share.types.ts @@ -75,6 +75,30 @@ export interface IShareInfoInput { pageId: string; } +// Vanity /l/:alias pointer. +export interface IShareAlias { + id: string; + workspaceId: string; + alias: string; + pageId: string | null; + creatorId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ISetShareAlias { + pageId: string; + alias: string; + confirmReassign?: boolean; +} + +export interface IShareAliasAvailability { + alias: string; + valid: boolean; + available: boolean; + currentPageId: string | null; +} + export interface ISharedPageTree { share: IShare; pageTree: Partial; diff --git a/apps/server/src/core/share/dto/share-alias.dto.ts b/apps/server/src/core/share/dto/share-alias.dto.ts new file mode 100644 index 00000000..bdaf7fd0 --- /dev/null +++ b/apps/server/src/core/share/dto/share-alias.dto.ts @@ -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; +} diff --git a/apps/server/src/core/share/share-alias-redirect.controller.ts b/apps/server/src/core/share/share-alias-redirect.controller.ts new file mode 100644 index 00000000..3685273b --- /dev/null +++ b/apps/server/src/core/share/share-alias-redirect.controller.ts @@ -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: `-` (mirrors the client). */ +function buildPageSlug(slugId: string, title?: string): string { + const titleSlug = slugify(title?.substring(0, 70) || 'untitled'); + return `${titleSlug}-${slugId}`; +} diff --git a/apps/server/src/core/share/share-alias.controller.ts b/apps/server/src/core/share/share-alias.controller.ts new file mode 100644 index 00000000..18fb288e --- /dev/null +++ b/apps/server/src/core/share/share-alias.controller.ts @@ -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 + ); + } +} diff --git a/apps/server/src/core/share/share-alias.service.spec.ts b/apps/server/src/core/share/share-alias.service.spec.ts new file mode 100644 index 00000000..349142e4 --- /dev/null +++ b/apps/server/src/core/share/share-alias.service.spec.ts @@ -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', + ); + }); + }); +}); diff --git a/apps/server/src/core/share/share-alias.service.ts b/apps/server/src/core/share/share-alias.service.ts new file mode 100644 index 00000000..b70d6d3e --- /dev/null +++ b/apps/server/src/core/share/share-alias.service.ts @@ -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> + >['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/ 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 { + 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 { + 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 { + return this.shareAliasRepo.findById(aliasId, workspaceId); + } + + /** The alias currently targeting a page (modal display), or undefined. */ + getAliasForPage( + pageId: string, + workspaceId: string, + ): Promise { + 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 { + 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; + } +} diff --git a/apps/server/src/core/share/share-alias.util.spec.ts b/apps/server/src/core/share/share-alias.util.spec.ts new file mode 100644 index 00000000..4e3adf16 --- /dev/null +++ b/apps/server/src/core/share/share-alias.util.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/core/share/share-alias.util.ts b/apps/server/src/core/share/share-alias.util.ts new file mode 100644 index 00000000..414bf12b --- /dev/null +++ b/apps/server/src/core/share/share-alias.util.ts @@ -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) + ); +} diff --git a/apps/server/src/core/share/share.module.ts b/apps/server/src/core/share/share.module.ts index 59eeb2ac..a61769cc 100644 --- a/apps/server/src/core/share/share.module.ts +++ b/apps/server/src/core/share/share.module.ts @@ -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 {} diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index d2083566..da90ef35 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -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, diff --git a/apps/server/src/database/migrations/20260626T130000-share-aliases.ts b/apps/server/src/database/migrations/20260626T130000-share-aliases.ts new file mode 100644 index 00000000..39e65259 --- /dev/null +++ b/apps/server/src/database/migrations/20260626T130000-share-aliases.ts @@ -0,0 +1,54 @@ +import { type Kysely, sql } from 'kysely'; + +/** + * Vanity share aliases: a retargetable, human-readable pointer (`/l/`) + * 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): Promise { + 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): Promise { + await db.schema.dropTable('share_aliases').execute(); +} diff --git a/apps/server/src/database/migrations/share-aliases.migration.spec.ts b/apps/server/src/database/migrations/share-aliases.migration.spec.ts new file mode 100644 index 00000000..43722087 --- /dev/null +++ b/apps/server/src/database/migrations/share-aliases.migration.spec.ts @@ -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(); + }); +}); diff --git a/apps/server/src/database/repos/share-alias/share-alias.repo.spec.ts b/apps/server/src/database/repos/share-alias/share-alias.repo.spec.ts new file mode 100644 index 00000000..89ac1e52 --- /dev/null +++ b/apps/server/src/database/repos/share-alias/share-alias.repo.spec.ts @@ -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'); + }); +}); diff --git a/apps/server/src/database/repos/share-alias/share-alias.repo.ts b/apps/server/src/database/repos/share-alias/share-alias.repo.ts new file mode 100644 index 00000000..4c24f33c --- /dev/null +++ b/apps/server/src/database/repos/share-alias/share-alias.repo.ts @@ -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 = [ + '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 { + 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 { + 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 { + return dbOrTx(this.db, trx) + .selectFrom('shareAliases') + .select(this.baseFields) + .where('id', '=', id) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async insert( + insertable: InsertableShareAlias, + trx?: KyselyTransaction, + ): Promise { + 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 { + 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 { + await dbOrTx(this.db, trx) + .deleteFrom('shareAliases') + .where('id', '=', id) + .where('workspaceId', '=', workspaceId) + .execute(); + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 169d8e60..d3135273 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -305,6 +305,16 @@ export interface Pages { ydoc: Buffer | null; } +export interface ShareAliases { + alias: string; + createdAt: Generated; + creatorId: string | null; + id: Generated; + pageId: string | null; + updatedAt: Generated; + workspaceId: string; +} + export interface Shares { createdAt: Generated; creatorId: string | null; @@ -674,6 +684,7 @@ export interface DB { pageVerifiers: PageVerifiers; pages: Pages; scimTokens: ScimTokens; + shareAliases: ShareAliases; shares: Shares; spaceMembers: SpaceMembers; spaces: Spaces; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 65a6c4da..22a7c914 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -30,6 +30,7 @@ import { AuthProviders, AuthAccounts, Shares, + ShareAliases, Favorites, FileTasks, UserMfa as _UserMFA, @@ -172,6 +173,11 @@ export type Share = Selectable; export type InsertableShare = Insertable; export type UpdatableShare = Updateable>; +// Share alias (vanity /l/:alias pointer) +export type ShareAlias = Selectable; +export type InsertableShareAlias = Insertable; +export type UpdatableShareAlias = Updateable>; + // Favorite export type Favorite = Selectable; export type InsertableFavorite = Insertable; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 05968d09..1fb140c1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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/ is a clean + // public URL that 302s to the canonical share page. + 'l/:alias', + 'mcp', + ], }); const reflector = app.get(Reflector);