Add a retargetable, human-readable vanity link namespace /l/<alias> that sits alongside the untouched /share/... routes. - New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias), page_id nullable ON DELETE SET NULL so the address outlives its target). - ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard / availability / request-time readable-target resolution through the single existing share boundary). - Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301, the target is mutable) to the canonical /share/:key/p/:slug page; unknown / dangling / no-longer-readable aliases serve the SPA index with no leak. 'l/:alias' excluded from the global /api prefix. - Authenticated ShareAliasController (set/remove/availability/for-page). - Shared ASCII-only normalize/validate util (server + client copies). - Client: Custom address block in the share modal (live normalize + debounced availability + copy + reassign confirmation dialog). - Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity (server jest) + client alias util (vitest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
31 lines
1.2 KiB
TypeScript
31 lines
1.2 KiB
TypeScript
/**
|
|
* 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)
|
|
);
|
|
}
|