diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2aa9c9..b15ac01a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give + any publicly shared page a short, memorable, workspace-scoped vanity address + backed by a new `share_aliases` table. Hitting `/l/` issues a `302` + (never `301`, since the target is retargetable) to the canonical + `/share//p/` page; an unknown, dangling, or no-longer-readable alias + serves the plain SPA index so that the existence of a name never leaks. An + alias can be moved to another page (with a confirm-reassign guard) and the + foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling + alias any workspace member can reclaim. (#205) + - **Persistent AI-chat history as the source of truth + server-side export.** An assistant turn is now persisted to the database step by step: the row is inserted upfront as `streaming` and updated as each agent step finishes, then diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bd8c4ed3..df8d66b6 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1315,5 +1315,15 @@ "Protocol": "Protocol", "How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced", "OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)", - "OpenAI (official)": "OpenAI (official)" + "OpenAI (official)": "OpenAI (official)", + "Custom address": "Custom address", + "A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.", + "Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens", + "This address is already in use": "This address is already in use", + "Move custom address?": "Move custom address?", + "Move here": "Move here", + "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?", + "The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?", + "Failed to set custom address": "Failed to set custom address", + "Failed to remove custom address": "Failed to remove custom address" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index f8c59436..7c4f7e38 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1169,5 +1169,15 @@ "Protocol": "Протокол", "How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning", "OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)", - "OpenAI (official)": "OpenAI (официальный)" + "OpenAI (official)": "OpenAI (официальный)", + "Custom address": "Пользовательский адрес", + "A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.", + "Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов", + "This address is already in use": "Этот адрес уже занят", + "Move custom address?": "Переместить пользовательский адрес?", + "Move here": "Переместить сюда", + "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?", + "The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?", + "Failed to set custom address": "Не удалось задать пользовательский адрес", + "Failed to remove custom address": "Не удалось удалить пользовательский адрес" } 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.spec.ts b/apps/server/src/core/share/share-alias-redirect.controller.spec.ts new file mode 100644 index 00000000..44616433 --- /dev/null +++ b/apps/server/src/core/share/share-alias-redirect.controller.spec.ts @@ -0,0 +1,252 @@ +import * as fs from 'node:fs'; + +// `@sindresorhus/slugify` is ESM-only and not in jest's transformIgnorePatterns, +// so the real module fails to parse under ts-jest. Stub it with a minimal, +// deterministic slugifier — this spec asserts the controller's slug *assembly* +// (`-`, 70-char clamp, `untitled` fallback), not the upstream +// slug algorithm. The factory keeps the real ESM module from ever being loaded. +jest.mock('@sindresorhus/slugify', () => ({ + __esModule: true, + default: (input: string) => + String(input) + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''), +})); + +import { ShareAliasRedirectController } from './share-alias-redirect.controller'; + +/** + * Routing/leak guard for the PUBLIC `GET /l/:alias` resolver. + * + * This is the most security-sensitive surface of the alias feature: an + * unauthenticated route that MUST serve the plain SPA index (exactly like any + * unknown path) for an unknown / dangling / no-longer-readable alias so that the + * existence of a name never leaks. Only a resolvable, still-readable alias may + * 302 to the canonical `/share//p/-` page (302 — never + * 301 — because the target is retargetable). These tests pin that routing and + * the defensive percent-decoding, mirroring `share-seo.controller.routing.spec`. + */ + +const STREAM_SENTINEL = { __isStream: true } as unknown as fs.ReadStream; + +// Stub fs at CALL time (jest.spyOn), NOT module load (jest.mock): the controller +// transitively pulls bcrypt, whose native module is located by node-gyp-build +// reading the filesystem at import time — a module-level fs mock breaks that. +beforeEach(() => { + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'createReadStream').mockReturnValue(STREAM_SENTINEL); +}); +afterEach(() => jest.restoreAllMocks()); + +function makeRes() { + const res: any = { + sent: undefined as unknown, + statusCode: undefined as number | undefined, + redirectUrl: undefined as string | undefined, + type: jest.fn(() => res), + status: jest.fn((code: number) => { + res.statusCode = code; + return res; + }), + send: jest.fn((v: unknown) => { + res.sent = v; + return res; + }), + redirect: jest.fn((url: string, code: number) => { + res.redirectUrl = url; + res.statusCode = code; + return res; + }), + }; + return res; +} + +function makeController(opts: { + resolved?: { share: any; page: any } | null; + selfHosted?: boolean; +}) { + const shareAliasService = { + resolveReadableTarget: jest.fn(async () => opts.resolved ?? null), + }; + const workspaceRepo = { + findFirst: jest.fn(async () => ({ id: 'ws-self' })), + findByHostname: jest.fn(async (sub: string) => + sub === 'acme' ? { id: 'ws-acme' } : null, + ), + }; + const environmentService = { + isSelfHosted: jest.fn(() => opts.selfHosted ?? true), + }; + const controller = new ShareAliasRedirectController( + shareAliasService as any, + workspaceRepo as any, + environmentService as any, + ); + return { controller, shareAliasService, workspaceRepo, environmentService }; +} + +const selfReq: any = { raw: { headers: { host: 'self' } } }; + +describe('ShareAliasRedirectController.resolve', () => { + it('302-redirects a resolvable alias to the canonical share page', async () => { + const { controller, shareAliasService } = makeController({ + resolved: { + share: { key: 'SHAREKEY' }, + page: { slugId: 'abc123', title: 'Quarterly Report' }, + }, + }); + const res = makeRes(); + + await controller.resolve('promo', selfReq, res); + + expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith( + 'promo', + 'ws-self', + ); + expect(res.redirect).toHaveBeenCalledWith( + '/share/SHAREKEY/p/quarterly-report-abc123', + 302, + ); + // No index stream was served on a hit. + expect(res.sent).toBeUndefined(); + }); + + it('falls back to "untitled" in the slug when the target has no title', async () => { + const { controller } = makeController({ + resolved: { share: { key: 'K' }, page: { slugId: 'sid', title: '' } }, + }); + const res = makeRes(); + + await controller.resolve('promo', selfReq, res); + + expect(res.redirect).toHaveBeenCalledWith('/share/K/p/untitled-sid', 302); + }); + + it('clamps the title-slug to the first 70 characters of the page title', async () => { + // 119-char title; only the first 70 chars must reach the slug. The 70-char + // boundary deliberately falls mid-word ("Entire" -> "entir") so the clamp is + // unambiguous: anything past char 70 ("...e Fiscal Year...") must be dropped. + const longTitle = + 'The Comprehensive Quarterly Financial Performance Report For The Entire Fiscal Year Two Thousand Twenty Five And Beyond'; + const { controller } = makeController({ + resolved: { + share: { key: 'K' }, + page: { slugId: 'sid', title: longTitle }, + }, + }); + const res = makeRes(); + + await controller.resolve('promo', selfReq, res); + + expect(res.redirect).toHaveBeenCalledWith( + '/share/K/p/the-comprehensive-quarterly-financial-performance-report-for-the-entir-sid', + 302, + ); + }); + + it('streams the SPA index WITHOUT a 302 for an unknown/dangling/unreadable alias (no leak)', async () => { + const { controller, shareAliasService } = makeController({ resolved: null }); + const res = makeRes(); + + await controller.resolve('does-not-exist', selfReq, res); + + expect(shareAliasService.resolveReadableTarget).toHaveBeenCalled(); + // The plain index stream was served and no redirect leaked alias existence. + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.sent).toBe(STREAM_SENTINEL); + expect(res.type).toHaveBeenCalledWith('text/html'); + }); + + it('streams the SPA index without even resolving when the workspace is null', async () => { + // Subdomain host that maps to no workspace => workspace === null. + const { controller, shareAliasService, workspaceRepo } = makeController({ + selfHosted: false, + }); + const res = makeRes(); + const req: any = { raw: { headers: { host: 'unknown.example.com' } } }; + + await controller.resolve('promo', req, res); + + expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('unknown'); + // Never even attempts to resolve (alias existence cannot leak per-host). + expect(shareAliasService.resolveReadableTarget).not.toHaveBeenCalled(); + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.sent).toBe(STREAM_SENTINEL); + }); + + it('defensively decodes broken percent-encoding and treats it as unknown', async () => { + const { controller, shareAliasService } = makeController({ resolved: null }); + const res = makeRes(); + + // '%E0%A4%A' is invalid -> decodeURIComponent throws -> raw value is used, + // and the alias resolves to nothing (no crash, served as index). + await controller.resolve('%E0%A4%A', selfReq, res); + + expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith( + '%E0%A4%A', + 'ws-self', + ); + expect(res.redirect).not.toHaveBeenCalled(); + expect(res.sent).toBe(STREAM_SENTINEL); + }); + + it('decodes a valid percent-encoded alias before resolving', async () => { + const { controller, shareAliasService } = makeController({ resolved: null }); + const res = makeRes(); + + await controller.resolve('my%2Dlink', selfReq, res); + + expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith( + 'my-link', + 'ws-self', + ); + }); + + it('resolves the workspace via findFirst on the self-hosted path', async () => { + const { controller, workspaceRepo, shareAliasService } = makeController({ + selfHosted: true, + resolved: null, + }); + const res = makeRes(); + + await controller.resolve('promo', selfReq, res); + + expect(workspaceRepo.findFirst).toHaveBeenCalled(); + expect(workspaceRepo.findByHostname).not.toHaveBeenCalled(); + expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith( + 'promo', + 'ws-self', + ); + }); + + it('resolves the workspace via findByHostname (subdomain) on the cloud path', async () => { + const { controller, workspaceRepo, shareAliasService } = makeController({ + selfHosted: false, + resolved: null, + }); + const res = makeRes(); + const req: any = { raw: { headers: { host: 'acme.example.com' } } }; + + await controller.resolve('promo', req, res); + + expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('acme'); + expect(workspaceRepo.findFirst).not.toHaveBeenCalled(); + expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith( + 'promo', + 'ws-acme', + ); + }); + + it('serves a 404 when no built client index exists', async () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const { controller } = makeController({ resolved: null }); + const res = makeRes(); + + await controller.resolve('promo', selfReq, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.redirect).not.toHaveBeenCalled(); + }); +}); 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..81d0af0e --- /dev/null +++ b/apps/server/src/core/share/share-alias-redirect.controller.ts @@ -0,0 +1,95 @@ +import { Controller, Get, 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 { + 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.spec.ts b/apps/server/src/core/share/share-alias.controller.spec.ts new file mode 100644 index 00000000..0ff1d2fc --- /dev/null +++ b/apps/server/src/core/share/share-alias.controller.spec.ts @@ -0,0 +1,260 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { ShareAliasController } from './share-alias.controller'; + +/** + * Authz-gate tests for the authenticated alias management controller. The access + * decisions for creating/retargeting/removing an alias live in THIS controller + * (the service spec delegates authorization to the caller), so each gate is + * pinned here against mocked PageRepo / ShareService / ShareAliasService / + * PageAccessService. A regression that drops any gate must fail here. + */ +describe('ShareAliasController authz gates', () => { + function makeController() { + const shareAliasService = { + setAlias: jest.fn(async () => ({ id: 'alias-1' })), + removeAlias: jest.fn(async () => undefined), + getAliasById: jest.fn(), + getAliasForPage: jest.fn(), + checkAvailability: jest.fn(), + }; + const shareService = { + resolveReadableSharePage: jest.fn(), + isSharingAllowed: jest.fn(), + }; + const pageRepo = { findById: jest.fn() }; + const pageAccessService = { + validateCanEdit: jest.fn(async () => undefined), + validateCanView: jest.fn(async () => undefined), + }; + const controller = new ShareAliasController( + shareAliasService as any, + shareService as any, + pageRepo as any, + pageAccessService as any, + ); + return { + controller, + shareAliasService, + shareService, + pageRepo, + pageAccessService, + }; + } + + const user: any = { id: 'u-1' }; + const workspace: any = { id: 'ws-1' }; + + describe('set', () => { + it('throws NotFoundException for a nonexistent page', async () => { + const { controller, pageRepo, pageAccessService } = makeController(); + pageRepo.findById.mockResolvedValue(null); + + await expect( + controller.set({ pageId: 'p-x', alias: 'promo' } as any, user, workspace), + ).rejects.toBeInstanceOf(NotFoundException); + expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException for a page in another workspace', async () => { + const { controller, pageRepo } = makeController(); + pageRepo.findById.mockResolvedValue({ + id: 'p-1', + workspaceId: 'ws-OTHER', + }); + + await expect( + controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('enforces validateCanEdit before setting the alias', async () => { + const { controller, pageRepo, pageAccessService, shareService } = + makeController(); + pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' }); + pageAccessService.validateCanEdit.mockRejectedValue( + new ForbiddenException('no edit'), + ); + + await expect( + controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace), + ).rejects.toBeInstanceOf(ForbiddenException); + // Gate short-circuits before any share resolution. + expect(shareService.resolveReadableSharePage).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when the page is not publicly shared', async () => { + const { controller, pageRepo, shareService } = makeController(); + pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' }); + shareService.resolveReadableSharePage.mockResolvedValue(null); + + await expect( + controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace), + ).rejects.toThrow('Page is not publicly shared'); + await expect( + controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('throws ForbiddenException when public sharing is disabled', async () => { + const { controller, pageRepo, shareService } = makeController(); + pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' }); + shareService.resolveReadableSharePage.mockResolvedValue({ + share: { spaceId: 'sp-1' }, + }); + shareService.isSharingAllowed.mockResolvedValue(false); + + await expect( + controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('delegates to setAlias on the happy path with all gates passed', async () => { + const { controller, pageRepo, shareService, shareAliasService } = + makeController(); + pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' }); + shareService.resolveReadableSharePage.mockResolvedValue({ + share: { spaceId: 'sp-1' }, + }); + shareService.isSharingAllowed.mockResolvedValue(true); + + const result = await controller.set( + { pageId: 'p-1', alias: 'promo', confirmReassign: true } as any, + user, + workspace, + ); + + expect(shareAliasService.setAlias).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + pageId: 'p-1', + creatorId: 'u-1', + alias: 'promo', + confirmReassign: true, + }); + expect(result).toEqual({ id: 'alias-1' }); + }); + }); + + describe('remove', () => { + it('throws NotFoundException for an unknown alias', async () => { + const { controller, shareAliasService } = makeController(); + shareAliasService.getAliasById.mockResolvedValue(null); + + await expect( + controller.remove({ aliasId: 'a-x' } as any, user, workspace), + ).rejects.toBeInstanceOf(NotFoundException); + expect(shareAliasService.removeAlias).not.toHaveBeenCalled(); + }); + + it('requires validateCanEdit on the current target before removing', async () => { + const { controller, shareAliasService, pageRepo, pageAccessService } = + makeController(); + shareAliasService.getAliasById.mockResolvedValue({ + id: 'a-1', + pageId: 'p-1', + }); + pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' }); + pageAccessService.validateCanEdit.mockRejectedValue( + new ForbiddenException('no edit'), + ); + + await expect( + controller.remove({ aliasId: 'a-1' } as any, user, workspace), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(shareAliasService.removeAlias).not.toHaveBeenCalled(); + }); + + it('removes a dangling alias (pageId null) WITHOUT an edit check', async () => { + const { controller, shareAliasService, pageRepo, pageAccessService } = + makeController(); + shareAliasService.getAliasById.mockResolvedValue({ + id: 'a-1', + pageId: null, + }); + + await controller.remove({ aliasId: 'a-1' } as any, user, workspace); + + expect(pageRepo.findById).not.toHaveBeenCalled(); + expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled(); + expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1'); + }); + + it('removes when the editor can edit the current target', async () => { + const { controller, shareAliasService, pageRepo, pageAccessService } = + makeController(); + shareAliasService.getAliasById.mockResolvedValue({ + id: 'a-1', + pageId: 'p-1', + }); + pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' }); + + await controller.remove({ aliasId: 'a-1' } as any, user, workspace); + + expect(pageAccessService.validateCanEdit).toHaveBeenCalled(); + expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1'); + }); + + it('removes even if the recorded target page no longer exists', async () => { + const { controller, shareAliasService, pageRepo, pageAccessService } = + makeController(); + shareAliasService.getAliasById.mockResolvedValue({ + id: 'a-1', + pageId: 'p-gone', + }); + pageRepo.findById.mockResolvedValue(null); + + await controller.remove({ aliasId: 'a-1' } as any, user, workspace); + + expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled(); + expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1'); + }); + }); + + describe('forPage', () => { + it('throws NotFoundException for a cross-workspace/nonexistent page', async () => { + const { controller, pageRepo, pageAccessService } = makeController(); + pageRepo.findById.mockResolvedValue({ + id: 'p-1', + workspaceId: 'ws-OTHER', + }); + + await expect( + controller.forPage({ pageId: 'p-1' } as any, user, workspace), + ).rejects.toBeInstanceOf(NotFoundException); + expect(pageAccessService.validateCanView).not.toHaveBeenCalled(); + }); + + it('requires validateCanView and returns the alias (or null)', async () => { + const { controller, pageRepo, pageAccessService, shareAliasService } = + makeController(); + pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' }); + shareAliasService.getAliasForPage.mockResolvedValue({ id: 'a-1' }); + + const result = await controller.forPage( + { pageId: 'p-1' } as any, + user, + workspace, + ); + + expect(pageAccessService.validateCanView).toHaveBeenCalled(); + expect(result).toEqual({ id: 'a-1' }); + }); + + it('returns null when the page has no alias', async () => { + const { controller, pageRepo, shareAliasService } = makeController(); + pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' }); + shareAliasService.getAliasForPage.mockResolvedValue(undefined); + + const result = await controller.forPage( + { pageId: 'p-1' } as any, + user, + workspace, + ); + + expect(result).toBeNull(); + }); + }); +}); 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..5c50f8b7 --- /dev/null +++ b/apps/server/src/core/share/share-alias.controller.ts @@ -0,0 +1,139 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PageAccessService } from '../page/page-access/page-access.service'; +import { ShareService } from './share.service'; +import { ShareAliasService } from './share-alias.service'; +import { + RemoveShareAliasDto, + SetShareAliasDto, + ShareAliasAvailabilityDto, + ShareAliasForPageDto, +} from './dto/share-alias.dto'; + +/** + * Authenticated management of vanity `/l/:alias` links. The PUBLIC resolve path + * lives in `ShareAliasRedirectController` (`/l/:alias`); this controller only + * creates/retargets/removes/looks-up aliases for editors. + */ +@UseGuards(JwtAuthGuard) +@Controller('share-aliases') +export class ShareAliasController { + constructor( + private readonly shareAliasService: ShareAliasService, + private readonly shareService: ShareService, + private readonly pageRepo: PageRepo, + private readonly pageAccessService: PageAccessService, + ) {} + + @HttpCode(HttpStatus.OK) + @Post('set') + async set( + @Body() dto: SetShareAliasDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page || page.workspaceId !== workspace.id) { + throw new NotFoundException('Page not found'); + } + + // Editing the page is required to point an address at it. + await this.pageAccessService.validateCanEdit(page, user); + + // The page must currently be publicly readable through the share graph; an + // alias to a non-shared page would only ever 404. + const resolved = await this.shareService.resolveReadableSharePage( + undefined, + page.id, + workspace.id, + ); + if (!resolved) { + throw new BadRequestException('Page is not publicly shared'); + } + + const sharingAllowed = await this.shareService.isSharingAllowed( + workspace.id, + resolved.share.spaceId, + ); + if (!sharingAllowed) { + throw new ForbiddenException('Public sharing is disabled'); + } + + return this.shareAliasService.setAlias({ + workspaceId: workspace.id, + pageId: page.id, + creatorId: user.id, + alias: dto.alias, + confirmReassign: dto.confirmReassign, + }); + } + + @HttpCode(HttpStatus.OK) + @Post('remove') + async remove( + @Body() dto: RemoveShareAliasDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const alias = await this.shareAliasService.getAliasById( + dto.aliasId, + workspace.id, + ); + if (!alias) { + throw new NotFoundException('Alias not found'); + } + + // Only someone who can edit the (current) target page may free the address. + // A dangling alias (page deleted) can be removed by any workspace member. + if (alias.pageId) { + const page = await this.pageRepo.findById(alias.pageId); + if (page) { + await this.pageAccessService.validateCanEdit(page, user); + } + } + + await this.shareAliasService.removeAlias(alias.id, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('availability') + async availability( + @Body() dto: ShareAliasAvailabilityDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.shareAliasService.checkAvailability(dto.alias, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('for-page') + async forPage( + @Body() dto: ShareAliasForPageDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page || page.workspaceId !== workspace.id) { + throw new NotFoundException('Page not found'); + } + await this.pageAccessService.validateCanView(page, user); + + return ( + (await this.shareAliasService.getAliasForPage(page.id, workspace.id)) ?? + null + ); + } +} 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..d891c799 --- /dev/null +++ b/apps/server/src/database/migrations/share-aliases.migration.spec.ts @@ -0,0 +1,94 @@ +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('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 only: these typed declarations fail `tsc` if the entity types + // drift (missing/renamed columns, wrong nullability). The runtime assertions + // would be tautological, so the value is purely in the type-check. + 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, insert, update]).toHaveLength(3); + }); +}); 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);