[feature][share] Кастомные адреса для шаринга: /l/:alias (отдельная таблица share_aliases, перенацеливаемый адрес) #205

Closed
opened 2026-06-26 01:03:41 +03:00 by Ghost · 0 comments

Описание

Кастомные («красивые») адреса для публично расшаренных страниц, вида:

https://docs.vvzvlad.xyz/l/любое-имя

Сейчас публичная ссылка выглядит как https://docs.vvzvlad.xyz/share/<random-key>/p/<title>-<slugId> — длинная и нечитаемая. Нужен короткий человекочитаемый алиас на отдельном префиксе /l/, который не пересекается с текущим /share/... и существует параллельно с ним.

Зафиксированные решения

  1. /share/... не трогаем — продолжает работать как есть (на него много кто завязан: внутренние API, закладки, embed).
  2. Префикс /l/ для красивых ссылок (изолированный неймспейс; из коротких заняты только /s — space и /p — page).
  3. Отдельная таблица share_aliases (не колонка в shares) — алиас как самостоятельный долгоживущий указатель.
  4. Слаги только ASCII ([a-z0-9-]), без кириллицы.
  5. Истории алиасов нет. Алиас можно перенацелить на другую страницу — стабильный адрес, за которым подменяется контент.

Почему отдельная таблица, а не колонка в shares

Требование «подменять страницу за стабильным адресом» плохо ложится на колонку в shares: строка shares привязана к page_id с ON DELETE CASCADE, поэтому при удалении/пересоздании страницы её шара (а с ней и алиас) исчезли бы, а «переезд» адреса превратился бы в перенос значения между строками shares. Отдельная таблица делает алиас самостоятельным указателем, не зависящим от жизненного цикла конкретной шары; подмена — это один UPDATE, таблица shares не затрагивается.

Модель данных

TABLE share_aliases
  id            uuid PK default gen_uuid_v7()
  workspace_id  uuid NOT NULL  FK workspaces.id  ON DELETE CASCADE
  alias         varchar NOT NULL          -- normalized ascii, lowercase
  page_id       uuid NULL      FK pages.id ON DELETE SET NULL
  creator_id    uuid NULL      FK users.id ON DELETE SET NULL
  created_at    timestamptz NOT NULL default now()
  updated_at    timestamptz NOT NULL default now()

  UNIQUE (workspace_id, alias)
  INDEX  (page_id)            -- "какой алиас у этой страницы" в модалке
  • Алиас принадлежит воркспейсу, а не странице → стабильный адрес.
  • page_id nullable + ON DELETE SET NULL: при удалении целевой страницы адрес сохраняется (отдаёт 404 до перенацеливания), имя остаётся занятым. Точно отражает требование №5.
    • Альтернатива на этап реализации: ON DELETE CASCADE — проще, но адрес исчезает вместе со страницей.
  • page_id указывает на страницу, а не на шару — публичная доступность проверяется в момент захода через существующий share-граф (getShareForPage).
  • UNIQUE (workspace_id, alias) — имя уникально в воркспейсе (как и текущий key).

Резолв /l/:alias (сервер)

Новый контроллер @Controller('l') (по аналогии с share-seo.controller.ts), исключённый из глобального префикса /api в apps/server/src/main.ts (добавить 'l/:alias' в exclude).

// GET /l/:alias  ->  302 redirect to the canonical share page
@Get(':alias')
async resolve(@Param('alias') raw: string, @Req() req, @Res() res: FastifyReply) {
  const workspace = await this.resolveWorkspace(req);          // same dup-DomainMiddleware as SEO ctrl
  const alias = normalizeShareAlias(decodeURIComponent(raw));
  const aliasRow = workspace
    ? await this.shareAliasRepo.findByAliasAndWorkspace(alias, workspace.id)
    : null;
  if (!aliasRow?.pageId) return this.sendIndex(indexFilePath, res);  // unknown/dangling -> SPA 404, no leak

  // Authoritative re-check through the SINGLE share boundary (handles
  // unshared-later, restricted ancestors, includeSubPages inheritance).
  const resolved = await this.shareService.resolveReadableSharePage(
    undefined, aliasRow.pageId, workspace.id,
  );
  if (!resolved) return this.sendIndex(indexFilePath, res);
  if (!(await this.shareService.isSharingAllowed(workspace.id, resolved.share.spaceId)))
    return this.sendIndex(indexFilePath, res);

  const slug = buildPageSlug(resolved.page.slugId, resolved.page.title);
  // 302 (NOT 301): the alias target is mutable. A cached 301 would pin clients
  // to the old page after a swap. Temporary redirect = always re-resolved.
  return res.redirect(302, `/share/${resolved.share.key}/p/${slug}`);
}

Почему так:

  • Переиспользуем весь существующий рендер и SSR-мету канонической страницы /share/:key/p/:slug — краулеры (Google/Telegram/Slack) идут по 302 и получают корректное превью.
  • Строго 302, не 301 — критично из-за подмены: 301 кэшируется навсегда и после перенацеливания вёл бы на старую страницу.
  • Несуществующий/перенацеленный/закрытый алиас отдаёт ту же SPA-страницу, что и любой неизвестный путь — без утечки факта существования.
  • v2 (опционально): если какой-то мессенджер плохо ходит по редиректам — контроллер /l сам вызывает buildShareMetaHtml и ставит <link rel="canonical"> на /l/<alias>, чтобы индексировался именно красивый адрес.

Валидация слага (ASCII-only)

Общий util (используется сервером и клиентом):

// Normalize a user-provided vanity alias into canonical ASCII storage form.
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 alias.length >= 2 && alias.length <= 60 && ALIAS_RE.test(alias);
}
  • Charset [a-z0-9-], дефис только как разделитель; никакой кириллицы (slugify не нужен).
  • Длина 2–60.
  • Уникальность — через DB-constraint (workspace_id, alias); нарушение → 409.
  • Зарезервированные слова не нужны — /l/ изолированный неймспейс, коллизий с /share, /s, /p, /home нет by design.

Подмена страницы (ключевой сценарий)

Операция setAlias(pageId, alias, confirmReassign?):

  1. Нормализуем + валидируем alias; проверяем право редактировать pageId и что страница публично-читаема (тот же share-boundary).
  2. Ищем share_aliases по (workspace_id, alias):
    • нет записи → INSERT (новый адрес);
    • есть и page_id === pageId → no-op;
    • есть и указывает на другую страницу → это и есть подмена. Без confirmReassign возвращаем 409 с { currentPageId, currentPageTitle }; клиент показывает «Адрес занят страницей "X". Перенести сюда?» и повторяет с confirmReassign: trueUPDATE … SET page_id = :pageId.
  3. removeAlias(aliasId)DELETE строки (адрес освобождается; истории не ведём).

Перенацеливание — UPDATE одной строки; благодаря 302 все существующие ссылки /l/foo сразу ведут на новую страницу.

UI (Share-модалка)

В apps/client/src/features/share/components/share-modal.tsx под текущим блоком ссылки — блок «Custom address»:

  • Префикс-лейбл docs.vvzvlad.xyz/l/ + TextInput для слага (live-нормализация, isValidShareAlias, debounce-проверка доступности через API).
  • Кнопки Save / Remove; при 409 — диалог «Перенести адрес сюда?» (подмена).
  • Когда алиас задан — показываем красивую ссылку …/l/<alias> с CopyTextButton рядом со старой случайной …/share/:key/p/... (она остаётся).

Точки изменений

Backend

  • …/migrations/<ts>-share-aliases.ts (новый) — таблица + UNIQUE(workspace_id, alias) + индекс по page_id.
  • …/database/types/entity.types.ts + db.ts — типы ShareAliases/ShareAlias/Insertable/Updatable.
  • …/database/repos/share-alias/share-alias.repo.ts (новый) — findByAliasAndWorkspace, findByPageId, insert, updatePageId, delete.
  • …/core/share/dto/share-alias.dto.ts (новый) — SetShareAliasDto { pageId, alias, confirmReassign? }, RemoveShareAliasDto, ResolveAliasDto.
  • …/core/share/share-alias.util.ts (новый) — normalizeShareAlias, isValidShareAlias.
  • ShareService или новый ShareAliasService — set/remove/resolve + проверки доступа и доступности + обработка 409.
  • …/core/share/share-alias.controller.ts (новый) — @Controller('share-aliases') под /api: set/remove/availability.
  • …/core/share/share-alias-redirect.controller.ts (новый) — @Controller('l'): GET :alias → 302.
  • apps/server/src/main.ts'l/:alias' в exclude глобального префикса.
  • …/core/share/share.module.ts — регистрация репо/сервиса/контроллеров.

Frontend

  • …/features/share/services/share-service.tssetShareAlias, removeShareAlias, checkShareAliasAvailability.
  • …/features/share/queries/share-query.ts — мутации/хуки + инвалидация.
  • …/features/share/types/share.types.ts — поля алиаса.
  • …/features/share/components/share-modal.tsx — блок «Custom address».
  • (опц.) apps/client/src/App.tsx + новый ShareAliasRedirect — клиентский /l/:alias для in-app навигации (прямые заходы и так обрабатывает сервер 302).
  • (опц.) …/pages/settings/shares/shares.tsx — колонка с алиасом в списке шар.

Граничные случаи

  • Подмена: setAlias на занятый алиас → 409 {currentPage} → подтверждённый reassign = UPDATE page_id; 302 даёт мгновенный эффект без устаревшего кэша.
  • Удаление целевой страницы: page_id → NULL (адрес сохраняется, /l/foo → 404 до перенацеливания).
  • Страница расшарена-отменена позже: запись алиаса цела, но resolve-time getShareForPage вернёт пусто → 404; повторный шаринг восстанавливает.
  • Подстраница под includeSubPages-предком: алиас можно навести на неё; редирект уходит на /share/:ancestorKey/p/:subSlug.
  • Restricted-потомок / sharing disabled: отсекается существующим boundary → 404 (без утечки title).
  • /l/FOO в любом регистре → нормализуется к foo → резолвится.
  • Гонка на уникальность — ловится DB-constraint → 409.
  • 301 vs 302 — только 302 (мутабельная цель).

Этапы внедрения

  1. БД + бэкенд-резолв: миграция, типы, ShareAliasRepo, контроллер /l, exclude в main.ts.
  2. Запись алиаса: util-валидатор/нормализатор, ShareAliasService/DTO, setAlias/removeAlias, обработка 409, эндпоинт проверки доступности.
  3. UI: блок «Custom address» в share-модалке с live-валидацией, copy и диалогом переноса.
  4. (Опц.) SEO-усиление: прямая инъекция меты на /l/:alias с self-canonical, если редиректа не хватит для каких-то краулеров.

Открытый вопрос (не блокирующий)

ON DELETE SET NULL (рекомендую — адрес переживает удаление страницы) vs CASCADE (проще, но адрес исчезает вместе со страницей) для share_aliases.page_id.


Связанные части кода (текущее состояние): apps/server/src/core/share/share.service.ts, apps/server/src/database/repos/share/share.repo.ts, apps/server/src/core/share/share-seo.controller.ts, apps/server/src/main.ts, apps/client/src/App.tsx, apps/client/src/features/share/components/share-modal.tsx, apps/client/src/pages/share/share-redirect.tsx.

## Описание Кастомные («красивые») адреса для публично расшаренных страниц, вида: ``` https://docs.vvzvlad.xyz/l/любое-имя ``` Сейчас публичная ссылка выглядит как `https://docs.vvzvlad.xyz/share/<random-key>/p/<title>-<slugId>` — длинная и нечитаемая. Нужен короткий человекочитаемый алиас на отдельном префиксе `/l/`, который **не пересекается** с текущим `/share/...` и существует параллельно с ним. ## Зафиксированные решения 1. **`/share/...` не трогаем** — продолжает работать как есть (на него много кто завязан: внутренние API, закладки, embed). 2. **Префикс `/l/`** для красивых ссылок (изолированный неймспейс; из коротких заняты только `/s` — space и `/p` — page). 3. **Отдельная таблица `share_aliases`** (не колонка в `shares`) — алиас как самостоятельный долгоживущий указатель. 4. **Слаги только ASCII** (`[a-z0-9-]`), без кириллицы. 5. **Истории алиасов нет.** Алиас можно **перенацелить** на другую страницу — стабильный адрес, за которым подменяется контент. ## Почему отдельная таблица, а не колонка в `shares` Требование «подменять страницу за стабильным адресом» плохо ложится на колонку в `shares`: строка `shares` привязана к `page_id` с `ON DELETE CASCADE`, поэтому при удалении/пересоздании страницы её шара (а с ней и алиас) исчезли бы, а «переезд» адреса превратился бы в перенос значения между строками `shares`. Отдельная таблица делает алиас самостоятельным указателем, не зависящим от жизненного цикла конкретной шары; подмена — это один `UPDATE`, таблица `shares` не затрагивается. ## Модель данных ```sql TABLE share_aliases id uuid PK default gen_uuid_v7() workspace_id uuid NOT NULL FK workspaces.id ON DELETE CASCADE alias varchar NOT NULL -- normalized ascii, lowercase page_id uuid NULL FK pages.id ON DELETE SET NULL creator_id uuid NULL FK users.id ON DELETE SET NULL created_at timestamptz NOT NULL default now() updated_at timestamptz NOT NULL default now() UNIQUE (workspace_id, alias) INDEX (page_id) -- "какой алиас у этой страницы" в модалке ``` - Алиас принадлежит **воркспейсу**, а не странице → стабильный адрес. - `page_id` **nullable + `ON DELETE SET NULL`**: при удалении целевой страницы адрес сохраняется (отдаёт 404 до перенацеливания), имя остаётся занятым. Точно отражает требование №5. - Альтернатива на этап реализации: `ON DELETE CASCADE` — проще, но адрес исчезает вместе со страницей. - `page_id` указывает на **страницу**, а не на шару — публичная доступность проверяется в момент захода через существующий share-граф (`getShareForPage`). - `UNIQUE (workspace_id, alias)` — имя уникально в воркспейсе (как и текущий `key`). ## Резолв `/l/:alias` (сервер) Новый контроллер `@Controller('l')` (по аналогии с `share-seo.controller.ts`), **исключённый из глобального префикса `/api`** в `apps/server/src/main.ts` (добавить `'l/:alias'` в `exclude`). ```ts // GET /l/:alias -> 302 redirect to the canonical share page @Get(':alias') async resolve(@Param('alias') raw: string, @Req() req, @Res() res: FastifyReply) { const workspace = await this.resolveWorkspace(req); // same dup-DomainMiddleware as SEO ctrl const alias = normalizeShareAlias(decodeURIComponent(raw)); const aliasRow = workspace ? await this.shareAliasRepo.findByAliasAndWorkspace(alias, workspace.id) : null; if (!aliasRow?.pageId) return this.sendIndex(indexFilePath, res); // unknown/dangling -> SPA 404, no leak // Authoritative re-check through the SINGLE share boundary (handles // unshared-later, restricted ancestors, includeSubPages inheritance). const resolved = await this.shareService.resolveReadableSharePage( undefined, aliasRow.pageId, workspace.id, ); if (!resolved) return this.sendIndex(indexFilePath, res); if (!(await this.shareService.isSharingAllowed(workspace.id, resolved.share.spaceId))) return this.sendIndex(indexFilePath, res); const slug = buildPageSlug(resolved.page.slugId, resolved.page.title); // 302 (NOT 301): the alias target is mutable. A cached 301 would pin clients // to the old page after a swap. Temporary redirect = always re-resolved. return res.redirect(302, `/share/${resolved.share.key}/p/${slug}`); } ``` Почему так: - Переиспользуем **весь существующий рендер и SSR-мету** канонической страницы `/share/:key/p/:slug` — краулеры (Google/Telegram/Slack) идут по 302 и получают корректное превью. - **Строго 302, не 301** — критично из-за подмены: 301 кэшируется навсегда и после перенацеливания вёл бы на старую страницу. - Несуществующий/перенацеленный/закрытый алиас отдаёт ту же SPA-страницу, что и любой неизвестный путь — без утечки факта существования. - **v2 (опционально):** если какой-то мессенджер плохо ходит по редиректам — контроллер `/l` сам вызывает `buildShareMetaHtml` и ставит `<link rel="canonical">` на `/l/<alias>`, чтобы индексировался именно красивый адрес. ## Валидация слага (ASCII-only) Общий util (используется сервером и клиентом): ```ts // Normalize a user-provided vanity alias into canonical ASCII storage form. 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 alias.length >= 2 && alias.length <= 60 && ALIAS_RE.test(alias); } ``` - Charset `[a-z0-9-]`, дефис только как разделитель; никакой кириллицы (`slugify` не нужен). - Длина 2–60. - Уникальность — через DB-constraint `(workspace_id, alias)`; нарушение → `409`. - Зарезервированные слова не нужны — `/l/` изолированный неймспейс, коллизий с `/share`, `/s`, `/p`, `/home` нет by design. ## Подмена страницы (ключевой сценарий) Операция `setAlias(pageId, alias, confirmReassign?)`: 1. Нормализуем + валидируем `alias`; проверяем право редактировать `pageId` и что страница публично-читаема (тот же share-boundary). 2. Ищем `share_aliases` по `(workspace_id, alias)`: - нет записи → `INSERT` (новый адрес); - есть и `page_id === pageId` → no-op; - есть и указывает на **другую** страницу → это и есть подмена. Без `confirmReassign` возвращаем `409` с `{ currentPageId, currentPageTitle }`; клиент показывает «Адрес занят страницей "X". Перенести сюда?» и повторяет с `confirmReassign: true` → `UPDATE … SET page_id = :pageId`. 3. `removeAlias(aliasId)` — `DELETE` строки (адрес освобождается; истории не ведём). Перенацеливание — `UPDATE` одной строки; благодаря 302 все существующие ссылки `/l/foo` сразу ведут на новую страницу. ## UI (Share-модалка) В `apps/client/src/features/share/components/share-modal.tsx` под текущим блоком ссылки — блок «Custom address»: - Префикс-лейбл `docs.vvzvlad.xyz/l/` + `TextInput` для слага (live-нормализация, `isValidShareAlias`, debounce-проверка доступности через API). - Кнопки **Save** / **Remove**; при `409` — диалог «Перенести адрес сюда?» (подмена). - Когда алиас задан — показываем красивую ссылку `…/l/<alias>` с `CopyTextButton` рядом со старой случайной `…/share/:key/p/...` (она остаётся). ## Точки изменений ### Backend - `…/migrations/<ts>-share-aliases.ts` (новый) — таблица + `UNIQUE(workspace_id, alias)` + индекс по `page_id`. - `…/database/types/entity.types.ts` + `db.ts` — типы `ShareAliases`/`ShareAlias`/`Insertable`/`Updatable`. - `…/database/repos/share-alias/share-alias.repo.ts` (новый) — `findByAliasAndWorkspace`, `findByPageId`, `insert`, `updatePageId`, `delete`. - `…/core/share/dto/share-alias.dto.ts` (новый) — `SetShareAliasDto { pageId, alias, confirmReassign? }`, `RemoveShareAliasDto`, `ResolveAliasDto`. - `…/core/share/share-alias.util.ts` (новый) — `normalizeShareAlias`, `isValidShareAlias`. - `ShareService` или новый `ShareAliasService` — set/remove/resolve + проверки доступа и доступности + обработка `409`. - `…/core/share/share-alias.controller.ts` (новый) — `@Controller('share-aliases')` под `/api`: set/remove/availability. - `…/core/share/share-alias-redirect.controller.ts` (новый) — `@Controller('l')`: `GET :alias` → 302. - `apps/server/src/main.ts` — `'l/:alias'` в `exclude` глобального префикса. - `…/core/share/share.module.ts` — регистрация репо/сервиса/контроллеров. ### Frontend - `…/features/share/services/share-service.ts` — `setShareAlias`, `removeShareAlias`, `checkShareAliasAvailability`. - `…/features/share/queries/share-query.ts` — мутации/хуки + инвалидация. - `…/features/share/types/share.types.ts` — поля алиаса. - `…/features/share/components/share-modal.tsx` — блок «Custom address». - (опц.) `apps/client/src/App.tsx` + новый `ShareAliasRedirect` — клиентский `/l/:alias` для in-app навигации (прямые заходы и так обрабатывает сервер 302). - (опц.) `…/pages/settings/shares/shares.tsx` — колонка с алиасом в списке шар. ## Граничные случаи - **Подмена:** `setAlias` на занятый алиас → `409 {currentPage}` → подтверждённый reassign = `UPDATE page_id`; 302 даёт мгновенный эффект без устаревшего кэша. - **Удаление целевой страницы:** `page_id → NULL` (адрес сохраняется, `/l/foo` → 404 до перенацеливания). - **Страница расшарена-отменена позже:** запись алиаса цела, но resolve-time `getShareForPage` вернёт пусто → 404; повторный шаринг восстанавливает. - **Подстраница под `includeSubPages`-предком:** алиас можно навести на неё; редирект уходит на `/share/:ancestorKey/p/:subSlug`. - **Restricted-потомок / sharing disabled:** отсекается существующим boundary → 404 (без утечки title). - **`/l/FOO` в любом регистре** → нормализуется к `foo` → резолвится. - **Гонка на уникальность** — ловится DB-constraint → `409`. - **301 vs 302** — только 302 (мутабельная цель). ## Этапы внедрения 1. БД + бэкенд-резолв: миграция, типы, `ShareAliasRepo`, контроллер `/l`, `exclude` в `main.ts`. 2. Запись алиаса: util-валидатор/нормализатор, `ShareAliasService`/DTO, `setAlias`/`removeAlias`, обработка `409`, эндпоинт проверки доступности. 3. UI: блок «Custom address» в share-модалке с live-валидацией, copy и диалогом переноса. 4. (Опц.) SEO-усиление: прямая инъекция меты на `/l/:alias` с self-canonical, если редиректа не хватит для каких-то краулеров. ## Открытый вопрос (не блокирующий) `ON DELETE SET NULL` (рекомендую — адрес переживает удаление страницы) vs `CASCADE` (проще, но адрес исчезает вместе со страницей) для `share_aliases.page_id`. --- _Связанные части кода (текущее состояние): `apps/server/src/core/share/share.service.ts`, `apps/server/src/database/repos/share/share.repo.ts`, `apps/server/src/core/share/share-seo.controller.ts`, `apps/server/src/main.ts`, `apps/client/src/App.tsx`, `apps/client/src/features/share/components/share-modal.tsx`, `apps/client/src/pages/share/share-redirect.tsx`._
Ghost added the feature label 2026-06-26 01:03:41 +03:00
Ghost changed title from [feature][share] Кастомные адреса для шаринга: /l/<alias> (отдельная таблица share_aliases, перенацеливаемый адрес) to [feature][share] Кастомные адреса для шаринга: /l/:alias (отдельная таблица share_aliases, перенацеливаемый адрес) 2026-06-26 01:04:03 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#205