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 { PageAccessService } from '../page/page-access/page-access.service'; import { Page, ShareAlias, User } from '@docmost/db/types/entity.types'; import { isValidShareAlias, normalizeShareAlias } from './share-alias.util'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { executeTx, isUniqueViolation, violatedConstraint, } from '@docmost/db/utils'; import { NoResultError } from 'kysely'; /** * Unique index name from the share_aliases migrations whose violation we map to * a DISTINCT, non-misleading outcome: * - PAGE_ID: partial `(workspace_id, page_id) WHERE page_id IS NOT NULL` * -> a concurrent writer already gave THIS page an alias. * The `(workspace_id, alias)` index (the vanity NAME being taken) needs no * constant: it is the default "Alias already taken" mapping. */ const UNIQUE_PAGE_ID_INDEX = 'share_aliases_workspace_id_page_id_unique'; 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, private readonly pageAccessService: PageAccessService, @InjectKysely() private readonly db: KyselyDB, ) {} /** * Create, RENAME or retarget a page's vanity alias. INVARIANT: a page has * EXACTLY ONE custom address. The alias name is workspace-scoped: * - name free, page has no alias yet -> INSERT a new pointer * - name free, page already has one -> RENAME that row in place (the slug * edit, e.g. `te` -> `ted`); we never spawn a second row, so no orphan * `/l/` link survives * - name already points at pageId -> no-op (idempotent) * - name points at ANOTHER page -> the "swap". Without confirmReassign * we throw 409 so the client can confirm. SECURITY (#495): the 409 reveals * the current target's title ONLY when `user` may VIEW that page, and never * its id — otherwise any member with one editable+shared page could iterate * alias names with confirmReassign=false and map them to (id, title) of * pages they cannot see. With confirmReassign we UPDATE the single row's * page_id (every /l/ link follows the 302 to the new page instantly * — no stale cache). * * To keep the invariant self-healing we DELETE every other alias row still * pointing at this page (a legacy duplicate, or the target page's own former * alias during a swap). The whole thing runs in one transaction. Because the * `(workspace_id, page_id)` unique index is NON-deferrable (checked at the end * of each statement), the swap branch DELETEs the target page's existing row * BEFORE retargeting, so the page is never transiently carried by two rows; * the other branches self-heal AFTER their write. Either way the page never * ends a statement with duplicate rows. * * 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; // The requesting user — used ONLY to gate whether the reassign 409 may reveal // the current target page's title (view-permission check). Not an authz gate // for the write itself (the controller already validated edit on `pageId`). user: User; }): Promise { const { workspaceId, pageId, creatorId, confirmReassign, user } = opts; const alias = normalizeShareAlias(opts.alias); if (!isValidShareAlias(alias)) { throw new BadRequestException( 'Invalid alias. Use 2-60 lowercase letters, digits and hyphens.', ); } try { return await executeTx(this.db, async (trx) => { const byName = await this.shareAliasRepo.findByAliasAndWorkspace( alias, workspaceId, trx, ); // The name is occupied by a DIFFERENT (or dangling) target page. if (byName && byName.pageId !== pageId) { if (!confirmReassign) { // SECURITY (#495): only disclose the current target's TITLE, and only // when the requester may VIEW that page. Never disclose its id (the // client's confirm-reassign UX doesn't use it, and it is an enumerable // identity). A member with one editable+shared page must NOT be able to // iterate alias names and map them to (id, title) of pages they cannot // see. When view is denied (or the alias is dangling) the 409 is the // bare "occupied" fact — the client still shows a generic confirm modal. const currentPage = byName.pageId ? await this.pageRepo.findById(byName.pageId) : null; let currentPageTitle: string | null = null; if (currentPage) { try { await this.pageAccessService.validateCanView(currentPage, user); currentPageTitle = currentPage.title ?? null; } catch { // No view permission on the target -> do not reveal its title. currentPageTitle = null; } } throw new ConflictException({ message: 'Alias already in use', code: 'ALIAS_REASSIGN_REQUIRED', currentPageTitle, }); } // Confirmed swap. ORDER MATTERS: the partial unique index on // `(workspace_id, page_id)` is NON-deferrable, so it is checked at the // end of EVERY statement. If we retargeted `byName` onto `pageId` // first while `pageId` still had its OWN alias row, there would // momentarily be two rows with this page_id -> immediate 23505 and a // rolled-back tx (a misleading "Alias already taken"). So we FIRST drop // the target page's existing alias row(s), THEN retarget. `byName.id` // still points at its old page here, so excluding it via `keepId` is // harmless; after the retarget it is the page's only row, so no // trailing self-heal is needed. await this.shareAliasRepo.deleteOthersForPage( pageId, byName.id, workspaceId, trx, ); return await this.shareAliasRepo.updatePageId( byName.id, pageId, workspaceId, trx, ); } // The name is FREE, or already points at THIS page. Ensure the page has // a single row carrying this name: rename its current one, or insert. const current = byName ?? (await this.shareAliasRepo.findByPageId(pageId, workspaceId, trx)); let row: ShareAlias; if (current) { row = current.alias === alias ? current // same-name no-op : await this.shareAliasRepo.updateAlias( current.id, alias, workspaceId, trx, ); } else { row = await this.shareAliasRepo.insert( { workspaceId, alias, pageId, creatorId }, trx, ); } // Self-heal: a page keeps EXACTLY ONE custom address. await this.shareAliasRepo.deleteOthersForPage( pageId, row.id, workspaceId, trx, ); return row; }); } catch (err: any) { if ( err instanceof ConflictException || err instanceof BadRequestException ) { throw err; } // The row we read was deleted (concurrent `removeAlias`) before our UPDATE // matched it, so `executeTakeFirstOrThrow` found no row. Surface a // retryable conflict instead of a 200-without-alias (swap branch) or a // generic 400 from dereferencing `undefined.id` (rename branch). if (err instanceof NoResultError) { this.logger.warn( 'share alias update matched no row (concurrent-delete race)', ); throw new ConflictException({ message: 'The address changed concurrently, please retry', code: 'ALIAS_PAGE_RACE', }); } // A unique index fired. Which one decides the message — always log the // constraint so the race is diagnosable. if (isUniqueViolation(err)) { const constraint = violatedConstraint(err); this.logger.warn( `share alias unique violation on ${constraint ?? ''}`, ); // `(workspace_id, page_id)`: a concurrent request already gave this page // an alias. The page still has exactly one custom address (the racing // writer's), so this is not a user-facing name clash — surface a // distinct, non-misleading message instead of "Alias already taken". if (constraint === UNIQUE_PAGE_ID_INDEX) { throw new ConflictException({ message: 'This page is being given an address by another request', code: 'ALIAS_PAGE_RACE', }); } // `(workspace_id, alias)` or any other/unknown unique index: treat as // the vanity name being claimed first. throw new ConflictException({ message: 'Alias already taken' }); } this.logger.error(err); throw new BadRequestException('Failed to set alias'); } } /** 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; }> { const alias = normalizeShareAlias(rawAlias); if (!isValidShareAlias(alias)) { return { alias, valid: false, available: false }; } const existing = await this.shareAliasRepo.findByAliasAndWorkspace( alias, workspaceId, ); // SECURITY (#495): return ONLY the boolean availability. The previous shape // leaked `currentPageId` — the id of whatever page the alias already targets — // to ANY authenticated workspace member, with no view-permission check on that // page. An attacker could enumerate alias names and map them to page ids they // have no access to. The taken/free bit is all the "is this address free" // probe needs. The reassign flow (setAlias 409) may surface the target's // TITLE, but only behind a `validateCanView` on that page (see setAlias); it // never returns the page id. return { alias, valid: true, available: !existing, }; } /** 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; } }