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>
110 lines
2.9 KiB
TypeScript
110 lines
2.9 KiB
TypeScript
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<keyof ShareAlias> = [
|
|
'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<ShareAlias | undefined> {
|
|
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<ShareAlias | undefined> {
|
|
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<ShareAlias | undefined> {
|
|
return dbOrTx(this.db, trx)
|
|
.selectFrom('shareAliases')
|
|
.select(this.baseFields)
|
|
.where('id', '=', id)
|
|
.where('workspaceId', '=', workspaceId)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
async insert(
|
|
insertable: InsertableShareAlias,
|
|
trx?: KyselyTransaction,
|
|
): Promise<ShareAlias> {
|
|
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<ShareAlias> {
|
|
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<void> {
|
|
await dbOrTx(this.db, trx)
|
|
.deleteFrom('shareAliases')
|
|
.where('id', '=', id)
|
|
.where('workspaceId', '=', workspaceId)
|
|
.execute();
|
|
}
|
|
}
|