Compare commits

..
Author SHA1 Message Date
Philipinho 29869478e6 fix: license display 2026-08-26 20:02:01 +01:00
30 changed files with 379 additions and 2292 deletions
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.",
"MCP Server URL": "MCP-Server-URL",
"Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
"Enforce OAuth": "OAuth erzwingen",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-Assistenten müssen sich über OAuth mit einem Docmost-Konto verbinden. API-Schlüssel können nicht mit dem MCP-Server verwendet werden.",
"Toggle enforce OAuth for MCP": "OAuth-Erzwingung für MCP umschalten",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Unterstützte Tools",
"MCP server URL:": "MCP-Server-URL:",
"Learn more": "Mehr erfahren",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.",
"MCP Server URL": "URL del servidor MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.",
"Enforce OAuth": "Exigir OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Los asistentes de IA deben conectarse con una cuenta de Docmost mediante OAuth. No se pueden usar claves API con el servidor MCP.",
"Toggle enforce OAuth for MCP": "Activar o desactivar la exigencia de OAuth para MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Herramientas compatibles",
"MCP server URL:": "URL del servidor MCP:",
"Learn more": "Más información",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.",
"MCP Server URL": "URL du serveur MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via OAuth.",
"Enforce OAuth": "Imposer OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Les assistants IA doivent se connecter avec un compte Docmost via OAuth. Les clés API ne peuvent pas être utilisées avec le serveur MCP.",
"Toggle enforce OAuth for MCP": "Activer ou désactiver limposition dOAuth pour MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Outils pris en charge",
"MCP server URL:": "URL du serveur MCP :",
"Learn more": "En savoir plus",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
"MCP Server URL": "URL del server MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite OAuth.",
"Enforce OAuth": "Rendi obbligatorio OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Gli assistenti AI devono connettersi con un account Docmost tramite OAuth. Le chiavi API non possono essere utilizzate con il server MCP.",
"Toggle enforce OAuth for MCP": "Attiva/disattiva l'obbligo di OAuth per MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Strumenti supportati",
"MCP server URL:": "URL del server MCP:",
"Learn more": "Scopri di più",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"MCP Server URL": "MCP サーバーの URL",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
"Enforce OAuth": "OAuth を必須化",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI アシスタントは OAuth を使用して Docmost アカウントに接続する必要があります。MCP サーバーでは API キーは使用できません。",
"Toggle enforce OAuth for MCP": "MCP の OAuth 必須化を切り替え",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "サポートされているツール",
"MCP server URL:": "MCP サーバーの URL:",
"Learn more": "詳細を見る",
@@ -1191,7 +1191,7 @@
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "1行を削除しますか?",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順",
"Discard": "破棄",
@@ -1291,9 +1291,9 @@
"Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1行を削除しました",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1件を選択中",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected",
"Compare": "比較",
"Compare versions": "バージョンを比較",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.",
"MCP Server URL": "MCP 서버 URL",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.",
"Enforce OAuth": "OAuth 강제",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 도우미는 OAuth를 통해 Docmost 계정에 연결해야 합니다. MCP 서버에서는 API 키를 사용할 수 없습니다.",
"Toggle enforce OAuth for MCP": "MCP에 대해 OAuth 강제 전환",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "지원되는 도구",
"MCP server URL:": "MCP 서버 URL:",
"Learn more": "자세히 알아보기",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.",
"MCP Server URL": "MCP-server-URL",
"Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via OAuth.",
"Enforce OAuth": "OAuth afdwingen",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-assistenten moeten verbinding maken met een Docmost-account via OAuth. API-sleutels kunnen niet worden gebruikt met de MCP-server.",
"Toggle enforce OAuth for MCP": "OAuth afdwingen voor MCP in- of uitschakelen",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Ondersteunde tools",
"MCP server URL:": "MCP-server-URL:",
"Learn more": "Meer informatie",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "O MCP está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
"MCP Server URL": "URL do servidor MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via OAuth.",
"Enforce OAuth": "Exigir OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Os assistentes de IA devem se conectar com uma conta do Docmost via OAuth. Chaves de API não podem ser usadas com o servidor MCP.",
"Toggle enforce OAuth for MCP": "Ativar/desativar exigência de OAuth para MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Ferramentas compatíveis",
"MCP server URL:": "URL do servidor MCP:",
"Learn more": "Saiba mais",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.",
"Enforce OAuth": "Сделать OAuth обязательным",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-помощники должны подключаться к учетной записи Docmost через OAuth. Ключи API нельзя использовать с MCP-сервером.",
"Toggle enforce OAuth for MCP": "Переключить обязательное использование OAuth для MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Поддерживаемые инструменты",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Подробнее",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.",
"Enforce OAuth": "Зробити OAuth обов’язковим",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-асистенти повинні підключатися до облікового запису Docmost через OAuth. Ключі API не можна використовувати з MCP-сервером.",
"Toggle enforce OAuth for MCP": "Увімкнути обов’язковий OAuth для MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Підтримувані інструменти",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Дізнатися більше",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"MCP Server URL": "MCP 服务器 URL",
"Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
"Enforce OAuth": "强制使用 OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 助手必须通过 OAuth 使用 Docmost 账户连接。MCP 服务器不能使用 API 密钥。",
"Toggle enforce OAuth for MCP": "切换 MCP 的强制使用 OAuth 设置",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "支持的工具",
"MCP server URL:": "MCP 服务器 URL",
"Learn more": "了解更多",
@@ -34,6 +34,7 @@ export default function LicenseDetails() {
<Table.Td>
{license.licenseType === "business" ? "Business" : "Enterprise"}{" "}
{license.trial && <Badge color="green">Trial</Badge>}
{license.lifetime && <Badge color="blue">Lifetime</Badge>}
</Table.Td>
</Table.Tr>
@@ -59,7 +60,14 @@ export default function LicenseDetails() {
<Table.Tr>
<Table.Th>Expires at</Table.Th>
<Table.Td>
{formatLocalized(license.expiresAt, "dd MMMM, yyyy", "PPP", locale)}
{license.lifetime
? "Never"
: formatLocalized(
license.expiresAt,
"dd MMMM, yyyy",
"PPP",
locale,
)}
</Table.Td>
</Table.Tr>
<Table.Tr>
@@ -4,10 +4,12 @@ import { differenceInDays, isAfter } from "date-fns";
export const GRACE_PERIOD_DAYS = 10;
export function isLicenseExpired(license: ILicenseInfo): boolean {
if (license.lifetime) return false;
return isAfter(new Date(), license.expiresAt);
}
export function daysToExpire(license: ILicenseInfo): number {
if (license.lifetime) return Infinity;
const days = differenceInDays(license.expiresAt, new Date());
return days > 0 ? days : 0;
}
@@ -6,6 +6,7 @@ export interface ILicenseInfo {
seatCount: number;
licenseType: LicenseType;
issuedAt: Date;
expiresAt: Date;
expiresAt: Date | null;
trial: boolean;
lifetime: boolean;
}
@@ -12,19 +12,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import NoTableResults from "@/components/common/no-table-results.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function GroupMembersList() {
const { t } = useTranslation();
const { groupId } = useParams();
const { search, cursor, goNext, goPrev, handleSearch } =
usePaginateAndSearch();
const { data, isLoading } = useGroupMembersQuery(groupId, {
cursor,
query: search,
});
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole();
@@ -54,7 +48,6 @@ export default function GroupMembersList() {
return (
<>
<SearchInput onSearch={handleSearch} />
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
@@ -66,62 +59,55 @@ export default function GroupMembersList() {
</Table.Thead>
<Table.Tbody>
{data?.items.length > 0 ? (
data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions for {{name}}", {
name: user.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={3} />
)}
{data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions for {{name}}", {
name: user.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
-1
View File
@@ -24,7 +24,6 @@
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:integration": "jest --config test/jest-integration.json --runInBand",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
@@ -55,10 +55,6 @@ import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
import { sql } from 'kysely';
import { TransclusionService } from '../transclusion/transclusion.service';
import {
assertAcyclicPageTraversal,
stripPageTraversalMetadata,
} from '../../../database/helpers/page-hierarchy-cycle';
@Injectable()
export class PageService {
@@ -133,27 +129,24 @@ export class PageService {
ydoc = createYdocFromJson(prosemirrorJson);
}
const page = await this.pageRepo.insertPage(
{
slugId: generateSlugId(),
title: createPageDto.title,
position: await this.nextPagePosition(
createPageDto.spaceId,
parentPageId,
),
icon: createPageDto.icon,
parentPageId: parentPageId,
spaceId: createPageDto.spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
isBase,
content,
textContent,
ydoc,
},
trx,
);
const page = await this.pageRepo.insertPage({
slugId: generateSlugId(),
title: createPageDto.title,
position: await this.nextPagePosition(
createPageDto.spaceId,
parentPageId,
),
icon: createPageDto.icon,
parentPageId: parentPageId,
spaceId: createPageDto.spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
isBase,
content,
textContent,
ydoc,
}, trx);
if (trx) {
// Add the watcher inside the caller's transaction so the async worker
@@ -874,9 +867,6 @@ export class PageService {
'parentPageId',
'spaceId',
'deletedAt',
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
sql<number>`0`.as('traversalDepth'),
])
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
@@ -893,28 +883,13 @@ export class PageService {
'p.parentPageId',
'p.spaceId',
'p.deletedAt',
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
sql<number>`pa.traversal_depth + 1`.as('traversalDepth'),
])
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null)
.where('pa.isCycle', '=', false),
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_ancestors')
.select([
'id',
'slugId',
'title',
'icon',
'isBase',
'position',
'parentPageId',
'spaceId',
'deletedAt',
'isCycle',
])
.selectAll('page_ancestors')
.select((eb) =>
eb
.exists(
@@ -926,12 +901,9 @@ export class PageService {
)
.as('hasChildren'),
)
.orderBy('traversalDepth', 'desc')
.execute();
assertAcyclicPageTraversal(ancestors, childPageId);
return ancestors.map(stripPageTraversalMetadata);
return ancestors.reverse();
}
async getRecentSpacePages(
@@ -1037,29 +1009,19 @@ export class PageService {
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.isCycle', '=', false),
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.select(['id', 'isCycle'])
.selectAll()
.execute();
assertAcyclicPageTraversal(descendants, pageId);
const pageIds = descendants.map((d) => d.id);
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
@@ -5,11 +5,6 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import {
assertAcyclicPageTraversal,
PageHierarchyCycleError,
} from '../../../database/helpers/page-hierarchy-cycle';
import { sql } from 'kysely';
const DEFAULT_RETENTION_DAYS = 30;
@@ -47,35 +42,17 @@ export class TrashCleanupService {
.select(['id'])
.where('workspaceId', '=', workspace.id)
.where('deletedAt', '<', retentionDate)
.orderBy('id')
.execute();
for (const page of oldDeletedPages) {
let pageIds: string[];
try {
const ancestors = await this.getPageAncestors(page.id);
assertAcyclicPageTraversal(ancestors, page.id);
const descendants = await this.getPageDescendants(page.id);
assertAcyclicPageTraversal(descendants, page.id);
pageIds = descendants.map((descendant) => descendant.id);
await this.cleanupPage(page.id);
totalCleaned++;
} catch (error) {
if (error instanceof PageHierarchyCycleError) {
this.logCleanupError(page.id, error);
continue;
}
throw error;
}
if (pageIds.length === 0) {
continue;
}
try {
totalCleaned += await this.cleanupPage(page.id, pageIds);
} catch (error) {
this.logCleanupError(page.id, error);
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
}
}
}
@@ -93,66 +70,27 @@ export class TrashCleanupService {
}
}
private async getPageAncestors(pageId: string) {
return this.db
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select([
'id',
'parentPageId',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.parentPageId',
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
])
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('pa.isCycle', '=', false),
),
)
.selectFrom('page_ancestors')
.select(['id', 'isCycle'])
.execute();
}
private async getPageDescendants(pageId: string) {
private async cleanupPage(pageId: string) {
// Get all descendants using recursive CTE (including the page itself)
return this.db
const descendants = await this.db
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.isCycle', '=', false),
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.select(['id', 'isCycle'])
.selectAll()
.execute();
}
private async cleanupPage(pageId: string, pageIds: string[]) {
const pageIds = descendants.map((d) => d.id);
this.logger.debug(
`Cleaning up page ${pageId} with ${pageIds.length - 1} descendants`,
);
@@ -176,24 +114,14 @@ export class TrashCleanupService {
}
try {
const result = await this.db
.deleteFrom('pages')
.where('id', 'in', pageIds)
.executeTakeFirst();
return Number(result.numDeletedRows);
if (pageIds.length > 0) {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
}
} catch (error) {
// Log but don't throw - pages might have been deleted by another node
this.logger.warn(
`Error deleting pages, they may have been already deleted: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return 0;
}
}
private logCleanupError(pageId: string, error: unknown) {
this.logger.error(
`Failed to cleanup page ${pageId}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
}
}
+91 -57
View File
@@ -26,7 +26,6 @@ import { validate as isValidUUID } from 'uuid';
import { sql } from 'kysely';
import { TransclusionService } from '../page/transclusion/transclusion.service';
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
import { stripPageTraversalMetadata } from '../../database/helpers/page-hierarchy-cycle';
@Injectable()
export class ShareService {
@@ -145,7 +144,7 @@ export class ShareService {
async getShareForPage(pageId: string, workspaceId: string) {
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
const traversal = await this.db
const share = await this.db
.withRecursive('page_hierarchy', (cte) =>
cte
.selectFrom('pages')
@@ -165,67 +164,41 @@ export class ShareService {
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.unionAll((union) =>
union
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.leftJoin('shares as s', 's.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
sql`ph.level + 1`.as('level'),
's.id as shareId',
's.key as shareKey',
's.includeSubPages',
's.searchIndexing',
's.creatorId',
's.spaceId',
's.workspaceId',
's.createdAt',
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
])
.where('p.deletedAt', 'is', null)
.where(sql`ph.share_id`, 'is', null) // stop if share found
.where('ph.isCycle', '=', false),
.unionAll(
(union) =>
union
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.leftJoin('shares as s', 's.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
sql`ph.level + 1`.as('level'),
's.id as shareId',
's.key as shareKey',
's.includeSubPages',
's.searchIndexing',
's.creatorId',
's.spaceId',
's.workspaceId',
's.createdAt',
])
.where('p.deletedAt', 'is', null)
.where(sql`ph.share_id`, 'is', null) // stop if share found
.where(sql`ph.level`, '<', sql`25`), // prevent loop
),
)
.selectFrom('page_hierarchy')
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'level',
'shareId',
'shareKey',
'includeSubPages',
'searchIndexing',
'creatorId',
'spaceId',
'workspaceId',
'createdAt',
'isCycle',
])
.execute();
if (traversal.some((row) => row.isCycle)) {
return undefined;
}
const matchedShare = traversal.find((row) => row.shareId !== null);
const share = matchedShare
? stripPageTraversalMetadata(matchedShare)
: undefined;
.selectAll()
.where('shareId', 'is not', null)
.limit(1)
.executeTakeFirst();
if (!share || share.workspaceId !== workspaceId) {
return undefined;
@@ -255,6 +228,67 @@ export class ShareService {
};
}
async getShareAncestorPage(
ancestorPageId: string,
childPageId: string,
): Promise<any> {
let ancestor = null;
try {
ancestor = await this.db
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'parentPageId',
'spaceId',
(eb) =>
eb
.case()
.when(eb.ref('id'), '=', ancestorPageId)
.then(true)
.else(false)
.end()
.as('found'),
])
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.parentPageId',
'p.spaceId',
(eb) =>
eb
.case()
.when(eb.ref('p.id'), '=', ancestorPageId)
.then(true)
.else(false)
.end()
.as('found'),
])
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
// Continue recursing only when the target ancestor hasn't been found on that branch.
.where('pa.found', '=', false),
),
)
.selectFrom('page_ancestors')
.selectAll()
.where('found', '=', true)
.limit(1)
.executeTakeFirst();
} catch (err) {
// empty
}
return ancestor;
}
/**
* Resolve transclusion content for a public share viewer. Each requested
* source page must itself be reachable via the share graph (its own share
@@ -1,53 +0,0 @@
import {
assertAcyclicPageTraversal,
PageHierarchyCycleError,
stripPageTraversalMetadata,
} from './page-hierarchy-cycle';
describe('page hierarchy cycle contract', () => {
it('does nothing when no row is marked as a cycle', () => {
expect(() =>
assertAcyclicPageTraversal(
[
{ id: 'page-1', title: 'Root', isCycle: false },
{ id: 'page-2', title: 'Child', isCycle: false },
],
'page-1',
),
).not.toThrow();
});
it('throws PageHierarchyCycleError when a row is marked as a cycle', () => {
expect(() =>
assertAcyclicPageTraversal(
[{ id: 'page-1', title: 'Root', isCycle: true }],
'page-1',
),
).toThrow(PageHierarchyCycleError);
});
it('keeps the root page id on the error for safe logging', () => {
try {
assertAcyclicPageTraversal(
[{ id: 'page-1', title: 'Root', isCycle: true }],
'root-page',
);
throw new Error('expected a cycle error');
} catch (error) {
expect(error).toBeInstanceOf(PageHierarchyCycleError);
expect((error as PageHierarchyCycleError).rootPageId).toBe('root-page');
expect((error as PageHierarchyCycleError).code).toBe(
'PAGE_HIERARCHY_CYCLE',
);
}
});
it('removes traversal metadata without changing the public row fields', () => {
const row = { id: 'page-1', title: 'Root', isCycle: false };
expect(stripPageTraversalMetadata(row)).toEqual({
id: 'page-1',
title: 'Root',
});
});
});
@@ -1,28 +0,0 @@
export type CycleTrackedRow = {
isCycle: boolean;
};
export class PageHierarchyCycleError extends Error {
readonly code = 'PAGE_HIERARCHY_CYCLE';
constructor(readonly rootPageId: string) {
super('Cyclic page hierarchy detected');
this.name = 'PageHierarchyCycleError';
}
}
export function assertAcyclicPageTraversal<T extends CycleTrackedRow>(
rows: readonly T[],
rootPageId: string,
): void {
if (rows.some((row) => row.isCycle)) {
throw new PageHierarchyCycleError(rootPageId);
}
}
export function stripPageTraversalMetadata<T extends CycleTrackedRow>(
row: T,
): Omit<T, 'isCycle'> {
const { isCycle: _isCycle, ...publicRow } = row;
return publicRow;
}
@@ -60,10 +60,6 @@ export class GroupUserRepo {
sql`f_unaccent(users.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`users.email`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
@@ -24,7 +24,6 @@ import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
import { assertAcyclicPageTraversal } from '../../helpers/page-hierarchy-cycle';
export { PagePermissionMember } from './types/page-permission.types';
@@ -333,7 +332,7 @@ export class PagePermissionRepo {
}
| undefined
> {
const ancestors = await this.db
return this.db
.withRecursive('ancestors', (qb) =>
qb
.selectFrom('pages')
@@ -341,8 +340,6 @@ export class PagePermissionRepo {
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`0`.as('depth'),
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
@@ -353,41 +350,19 @@ export class PagePermissionRepo {
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`ancestors.depth + 1`.as('depth'),
sql<string[]>`ancestors.traversal_path || pages.id`.as(
'traversalPath',
),
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
'isCycle',
),
])
.where('ancestors.isCycle', '=', false),
]),
),
)
.selectFrom('ancestors')
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.select([
'pageAccess.id as pageAccessId',
'pageAccess.pageId',
'pageAccess.accessLevel',
'ancestors.depth',
'ancestors.isCycle',
])
.orderBy('ancestors.depth', 'asc')
.execute();
assertAcyclicPageTraversal(ancestors, pageId);
const restrictedAncestor = ancestors.find(
(ancestor) => ancestor.pageAccessId !== null,
);
if (!restrictedAncestor) return undefined;
return {
pageAccessId: restrictedAncestor.pageAccessId,
pageId: restrictedAncestor.pageId,
accessLevel: restrictedAncestor.accessLevel,
depth: restrictedAncestor.depth,
};
.executeTakeFirst();
}
/**
@@ -421,30 +396,17 @@ export class PagePermissionRepo {
const result = await sql<{
canAccess: boolean | null;
canEdit: boolean | null;
hasHierarchyCycle: boolean | null;
}>`
WITH RECURSIVE ancestors AS (
SELECT
id AS ancestor_id,
parent_page_id,
0 AS depth,
ARRAY[id]::uuid[] AS traversal_path,
false AS is_cycle
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
FROM pages
WHERE id = ${pageId}::uuid
UNION ALL
SELECT
p.id,
p.parent_page_id,
a.depth + 1,
a.traversal_path || p.id,
p.id = ANY(a.traversal_path) AS is_cycle
SELECT p.id, p.parent_page_id, a.depth + 1
FROM pages p
JOIN ancestors a ON a.parent_page_id = p.id
WHERE NOT a.is_cycle
)
SELECT
(SELECT bool_or(is_cycle) FROM ancestors) AS "hasHierarchyCycle",
bool_and(pp.id IS NOT NULL) AS "canAccess",
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
@@ -460,13 +422,6 @@ export class PagePermissionRepo {
`.execute(this.db);
const row = result.rows[0];
if (row?.hasHierarchyCycle) {
return {
hasAnyRestriction: true,
canAccess: false,
canEdit: false,
};
}
if (!row || row.canAccess === null) {
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
}
@@ -506,8 +461,6 @@ export class PagePermissionRepo {
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`0`.as('depth'),
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
@@ -518,14 +471,7 @@ export class PagePermissionRepo {
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`ancestors.depth + 1`.as('depth'),
sql<string[]>`ancestors.traversal_path || pages.id`.as(
'traversalPath',
),
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
'isCycle',
),
])
.where('ancestors.isCycle', '=', false),
]),
),
)
.selectFrom('pages')
@@ -565,14 +511,6 @@ export class PagePermissionRepo {
.else(false)
.end()
.as('hasInheritedRestriction'),
eb
.exists(
eb
.selectFrom('ancestors')
.select('ancestors.ancestorId')
.where('ancestors.isCycle', '=', true),
)
.as('hasHierarchyCycle'),
// canAccess: no restricted ancestor without ANY permission
eb
.case()
@@ -700,15 +638,13 @@ export class PagePermissionRepo {
const hasDirectRestriction = Boolean(result?.hasDirectRestriction);
const hasInheritedRestriction = Boolean(result?.hasInheritedRestriction);
const hasHierarchyCycle = Boolean(result?.hasHierarchyCycle);
return {
hasDirectRestriction,
hasInheritedRestriction,
hasAnyRestriction:
hasDirectRestriction || hasInheritedRestriction || hasHierarchyCycle,
canAccess: !hasHierarchyCycle && Boolean(result?.canAccess),
canEdit: !hasHierarchyCycle && Boolean(result?.canEdit),
hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction,
canAccess: Boolean(result?.canAccess),
canEdit: Boolean(result?.canEdit),
};
}
@@ -728,48 +664,7 @@ export class PagePermissionRepo {
if (spaceId) {
const hasRestrictions = await this.hasRestrictedPagesInSpace(spaceId);
if (!hasRestrictions) {
const cyclicPages = await this.db
.withRecursive('allAncestors', (qb) =>
qb
.selectFrom('pages')
.select([
'pages.id as pageId',
'pages.id as ancestorId',
'pages.parentPageId',
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin(
'allAncestors',
'allAncestors.parentPageId',
'pages.id',
)
.select([
'allAncestors.pageId',
'pages.id as ancestorId',
'pages.parentPageId',
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
'traversalPath',
),
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
'isCycle',
),
])
.where('allAncestors.isCycle', '=', false),
),
)
.selectFrom('allAncestors')
.select('allAncestors.pageId')
.distinct()
.where('allAncestors.isCycle', '=', true)
.execute();
const cyclicPageIds = new Set(cyclicPages.map((page) => page.pageId));
return pageIds.filter((pageId) => !cyclicPageIds.has(pageId));
return pageIds;
}
}
@@ -781,8 +676,6 @@ export class PagePermissionRepo {
'pages.id as pageId',
'pages.id as ancestorId',
'pages.parentPageId',
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
.unionAll((eb) =>
@@ -797,29 +690,12 @@ export class PagePermissionRepo {
'allAncestors.pageId',
'pages.id as ancestorId',
'pages.parentPageId',
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
'traversalPath',
),
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
'isCycle',
),
])
.where('allAncestors.isCycle', '=', false),
]),
),
)
.selectFrom('pages')
.select('pages.id')
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('allAncestors')
.select('allAncestors.ancestorId')
.whereRef('allAncestors.pageId', '=', 'pages.id')
.where('allAncestors.isCycle', '=', true),
),
),
)
.where(({ not, exists, selectFrom }) =>
not(
exists(
@@ -869,8 +745,6 @@ export class PagePermissionRepo {
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`0`.as('depth'),
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
.unionAll((eb) =>
@@ -886,14 +760,7 @@ export class PagePermissionRepo {
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`all_ancestors.depth + 1`.as('depth'),
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
'traversalPath',
),
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
'isCycle',
),
])
.where('allAncestors.isCycle', '=', false),
]),
),
)
.selectFrom('pages')
@@ -954,16 +821,6 @@ export class PagePermissionRepo {
.as('canEdit'),
)
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('allAncestors')
.select('allAncestors.ancestorId')
.whereRef('allAncestors.pageId', '=', 'pages.id')
.where('allAncestors.isCycle', '=', true),
),
),
)
// view filter: no restricted ancestor without any permission
.where(({ not, exists, selectFrom }) =>
not(
@@ -1008,39 +865,21 @@ export class PagePermissionRepo {
.withRecursive('ancestors', (qb) =>
qb
.selectFrom('pages')
.select([
'pages.id as ancestorId',
'pages.parentPageId',
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.select(['pages.id as ancestorId', 'pages.parentPageId'])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
.select([
'pages.id as ancestorId',
'pages.parentPageId',
sql<string[]>`ancestors.traversal_path || pages.id`.as(
'traversalPath',
),
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
'isCycle',
),
])
.where('ancestors.isCycle', '=', false),
.select(['pages.id as ancestorId', 'pages.parentPageId']),
),
)
.selectFrom('ancestors')
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.select([
sql<boolean>`bool_or(ancestors.is_cycle)`.as('hasHierarchyCycle'),
sql<boolean>`bool_or(page_access.id IS NOT NULL)`.as('hasPageAccess'),
])
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.select('pageAccess.id')
.executeTakeFirst();
return Boolean(result?.hasHierarchyCycle || result?.hasPageAccess);
return !!result;
}
/**
@@ -1082,8 +921,6 @@ export class PagePermissionRepo {
'child.id as childId',
'child.id as ancestorId',
'child.parentPageId as ancestorParentId',
sql<string[]>`ARRAY[child.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('child.parentPageId', 'in', parentIds)
.where('child.deletedAt', 'is', null)
@@ -1099,14 +936,7 @@ export class PagePermissionRepo {
'childAncestors.childId',
'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId',
sql<string[]>`child_ancestors.traversal_path || pages.id`.as(
'traversalPath',
),
sql<boolean>`pages.id = ANY(child_ancestors.traversal_path)`.as(
'isCycle',
),
])
.where('childAncestors.isCycle', '=', false),
]),
),
)
.selectFrom('pages as child')
@@ -1114,16 +944,6 @@ export class PagePermissionRepo {
.distinct()
.where('child.parentPageId', 'in', parentIds)
.where('child.deletedAt', 'is', null)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('childAncestors')
.select('childAncestors.ancestorId')
.whereRef('childAncestors.childId', '=', 'child.id')
.where('childAncestors.isCycle', '=', true),
),
),
)
.where(({ not, exists, selectFrom }) =>
not(
exists(
@@ -1158,6 +978,67 @@ export class PagePermissionRepo {
return results.map((r) => r.parentPageId);
}
/**
* Get all page IDs within a subtree that are restricted OR are descendants of restricted pages.
* Used to filter pages from public shares - if a page is restricted, it and all its
* children should be hidden.
*/
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
const results = await this.db
.withRecursive('descendants', (qb) =>
qb
.selectFrom('pages')
.select(['pages.id as descendantId', 'pages.parentPageId'])
.where('pages.id', '=', rootPageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin(
'descendants',
'descendants.descendantId',
'pages.parentPageId',
)
.select(['pages.id as descendantId', 'pages.parentPageId'])
.where('pages.deletedAt', 'is', null),
),
)
.withRecursive('descendantAncestors', (qb) =>
qb
.selectFrom('descendants')
.innerJoin('pages', 'pages.id', 'descendants.descendantId')
.select([
'descendants.descendantId',
'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId',
])
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin(
'descendantAncestors',
'descendantAncestors.ancestorParentId',
'pages.id',
)
.select([
'descendantAncestors.descendantId',
'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId',
]),
),
)
.selectFrom('descendantAncestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'descendantAncestors.ancestorId',
)
.select('descendantAncestors.descendantId')
.distinct()
.execute();
return results.map((r) => r.descendantId);
}
/**
* Given a pageId and a set of candidate userIds, return the subset who can
* access the page (have permission on ALL restricted ancestors).
@@ -1171,27 +1052,17 @@ export class PagePermissionRepo {
const results = await sql<{ userId: string }>`
WITH RECURSIVE ancestors AS (
SELECT
id AS ancestor_id,
parent_page_id,
ARRAY[id]::uuid[] AS traversal_path,
false AS is_cycle
SELECT id AS ancestor_id, parent_page_id
FROM pages
WHERE id = ${pageId}::uuid
UNION ALL
SELECT
p.id,
p.parent_page_id,
a.traversal_path || p.id,
p.id = ANY(a.traversal_path) AS is_cycle
SELECT p.id, p.parent_page_id
FROM pages p
JOIN ancestors a ON a.parent_page_id = p.id
WHERE NOT a.is_cycle
)
SELECT cu.user_id AS "userId"
FROM unnest(${userIds}::uuid[]) AS cu(user_id)
WHERE NOT EXISTS (SELECT 1 FROM ancestors WHERE is_cycle)
AND NOT EXISTS (
WHERE NOT EXISTS (
SELECT 1
FROM ancestors a
JOIN page_access pa ON pa.page_id = a.ancestor_id
+72 -139
View File
@@ -16,10 +16,6 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
import {
assertAcyclicPageTraversal,
stripPageTraversalMetadata,
} from '../../helpers/page-hierarchy-cycle';
@Injectable()
export class PageRepo {
@@ -207,31 +203,21 @@ export class PageRepo {
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.select(['id'])
.where('id', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('p.deletedAt', 'is', null)
.where('pd.isCycle', '=', false),
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_descendants')
.select(['id', 'isCycle'])
.selectAll()
.execute();
assertAcyclicPageTraversal(descendants, pageId);
const pageIds = descendants.map((d) => d.id);
if (pageIds.length > 0) {
@@ -286,29 +272,19 @@ export class PageRepo {
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.isCycle', '=', false),
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.select(['id', 'isCycle'])
.selectAll()
.execute();
assertAcyclicPageTraversal(pages, pageId);
const pageIds = pages.map((p) => p.id);
// Restore all pages, but only detach the root page if its parent is deleted
@@ -378,12 +354,7 @@ export class PageRepo {
});
}
async getCreatedByPages(
creatorId: string,
requestingUserId: string,
pagination: PaginationOptions,
spaceId?: string,
) {
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
let query = this.db
.selectFrom('pages')
.select(this.baseFields)
@@ -394,11 +365,7 @@ export class PageRepo {
if (spaceId) {
query = query.where('spaceId', '=', spaceId);
} else {
query = query.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId),
);
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
}
return executeWithCursorPagination(query, {
@@ -524,7 +491,7 @@ export class PageRepo {
parentPageId: string,
opts: { includeContent: boolean },
) {
const pages = await this.db
return this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
@@ -539,8 +506,6 @@ export class PageRepo {
'workspaceId',
'createdAt',
'updatedAt',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('id', '=', parentPageId)
@@ -559,34 +524,15 @@ export class PageRepo {
'p.workspaceId',
'p.createdAt',
'p.updatedAt',
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.where('p.deletedAt', 'is', null)
.where('ph.isCycle', '=', false),
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_hierarchy')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
'isCycle',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.selectAll()
.execute();
assertAcyclicPageTraversal(pages, parentPageId);
return pages.map((page) => stripPageTraversalMetadata(page));
}
/**
@@ -594,82 +540,69 @@ export class PageRepo {
* More efficient than getPageAndDescendants + filtering because:
* 1. Single DB query (no separate restricted IDs query)
* 2. Stops traversing at restricted pages (doesn't fetch data to discard)
* 3. Filters the bounded traversal only after hierarchy validation
* 3. No in-memory filtering needed
*/
async getPageAndDescendantsExcludingRestricted(
parentPageId: string,
opts: { includeContent: boolean },
) {
const pages = await this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.position',
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
.where('pages.id', '=', parentPageId)
.where('pages.deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.where('p.deletedAt', 'is', null)
// Only recurse into children of non-restricted pages
.where('ph.isRestricted', '=', false)
.where('ph.isCycle', '=', false),
),
)
.selectFrom('page_hierarchy')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
'isRestricted',
'isCycle',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.execute();
assertAcyclicPageTraversal(pages, parentPageId);
return pages
.filter((page) => !page.isRestricted)
.map((page) => {
const withoutCycleMetadata = stripPageTraversalMetadata(page);
const { isRestricted: _isRestricted, ...publicPage } =
withoutCycleMetadata;
return publicPage;
});
return (
this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.position',
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
.where('pages.id', '=', parentPageId)
.where('pages.deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.where('p.deletedAt', 'is', null)
// Only recurse into children of non-restricted pages
.where('ph.isRestricted', '=', false),
),
)
.selectFrom('page_hierarchy')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
// Filter out restricted pages from the result
.where('isRestricted', '=', false)
.execute()
);
}
}
@@ -1,18 +0,0 @@
name: docmost-cycle-test
services:
postgres:
image: postgres:18
environment:
POSTGRES_DB: docmost_cycle_test
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost
ports:
- '127.0.0.1:55432:5432'
tmpfs:
- /var/lib/postgresql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U docmost -d docmost_cycle_test']
interval: 2s
timeout: 2s
retries: 15
-26
View File
@@ -1,26 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".*\\.integration-spec\\.ts$",
"transform": {
"happy-dom.+\\.js$": [
"babel-jest",
{
"presets": [["@babel/preset-env", { "targets": { "node": "current" } }]]
}
],
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
],
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/../src/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/../src/ee/$1",
"^src/(.*)$": "<rootDir>/../src/$1",
"^@docmost/base-formula/server$": "<rootDir>/../../../packages/base-formula/src/index.server.ts",
"^@docmost/base-formula/client$": "<rootDir>/../../../packages/base-formula/src/index.client.ts"
}
}
File diff suppressed because it is too large Load Diff
-90
View File
@@ -1,90 +0,0 @@
import { CamelCasePlugin, Kysely, sql } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { DbInterface } from '../../src/database/types/db.interface';
const disposableDatabaseName = 'docmost_cycle_test';
const databaseUrl = process.env.TEST_DATABASE_URL;
if (!databaseUrl) {
throw new Error('TEST_DATABASE_URL must be set for integration tests');
}
export function assertDisposableTestDatabaseUrl(value: string): void {
let parsedUrl: URL;
try {
parsedUrl = new URL(value);
} catch {
throw new Error('TEST_DATABASE_URL must be a valid PostgreSQL URL');
}
const isPostgresUrl = ['postgres:', 'postgresql:'].includes(
parsedUrl.protocol,
);
const isLoopback = ['127.0.0.1', 'localhost', '[::1]'].includes(
parsedUrl.hostname,
);
const databaseName = decodeURIComponent(parsedUrl.pathname.slice(1));
if (!isPostgresUrl || !isLoopback || databaseName !== disposableDatabaseName) {
throw new Error(
`Integration tests require the loopback database ${disposableDatabaseName}`,
);
}
}
assertDisposableTestDatabaseUrl(databaseUrl);
const postgresPool = postgres(databaseUrl, { max: 1, onnotice: () => {} });
export const db = new Kysely<DbInterface>({
dialect: new PostgresJSDialect({ postgres: postgresPool }),
plugins: [new CamelCasePlugin()],
});
let databaseSafetyVerified = false;
beforeAll(async () => {
const result = await sql<{ databaseName: string }>`
SELECT current_database() AS "databaseName"
`.execute(db);
if (result.rows[0]?.databaseName !== disposableDatabaseName) {
throw new Error(
`Connected database must be the disposable ${disposableDatabaseName} database`,
);
}
databaseSafetyVerified = true;
});
export async function withStatementTimeout<T>(
callback: (connection: Kysely<DbInterface>) => Promise<T>,
): Promise<T> {
return db.connection().execute(async (connection) => {
await sql`SET statement_timeout = '500ms'`.execute(connection);
try {
return await callback(connection);
} finally {
await sql`SET statement_timeout = DEFAULT`.execute(connection);
}
});
}
export async function truncateFixtureTables(): Promise<void> {
if (!databaseSafetyVerified) {
throw new Error('Refusing to truncate an unverified integration database');
}
await sql`TRUNCATE TABLE pages, spaces, workspaces CASCADE`.execute(db);
}
afterEach(async () => {
await truncateFixtureTables();
});
afterAll(async () => {
await postgresPool.end();
});
@@ -1,114 +0,0 @@
import { randomUUID } from 'node:crypto';
import { sql } from 'kysely';
import { db } from './database';
export type PageFixture = {
id: string;
parentPageId: string | null;
title: string;
};
type HierarchyContext = {
workspaceId: string;
spaceId: string;
};
async function seedWorkspaceAndSpace(): Promise<HierarchyContext> {
const workspace = await db
.insertInto('workspaces')
.values({
hostname: `cycle-test-${randomUUID()}`,
name: 'Page hierarchy integration workspace',
})
.returning('id')
.executeTakeFirstOrThrow();
const space = await db
.insertInto('spaces')
.values({
name: 'Page hierarchy integration space',
slug: `cycle-test-${randomUUID()}`,
workspaceId: workspace.id,
})
.returning('id')
.executeTakeFirstOrThrow();
return { spaceId: space.id, workspaceId: workspace.id };
}
async function insertPage(
context: HierarchyContext,
title: string,
parentPageId: string | null = null,
): Promise<PageFixture> {
const page = await db
.insertInto('pages')
.values({
parentPageId,
slugId: randomUUID(),
spaceId: context.spaceId,
title,
workspaceId: context.workspaceId,
})
.returning(['id', 'parentPageId', 'title'])
.executeTakeFirstOrThrow();
return { id: page.id, parentPageId: page.parentPageId, title };
}
export async function seedAcyclicPageChain(): Promise<{
root: PageFixture;
child: PageFixture;
grandchild: PageFixture;
}> {
const context = await seedWorkspaceAndSpace();
const root = await insertPage(context, 'Root');
const child = await insertPage(context, 'Child', root.id);
const grandchild = await insertPage(context, 'Grandchild', child.id);
return { root, child, grandchild };
}
export async function seedSelfCycle(): Promise<{ self: PageFixture }> {
const context = await seedWorkspaceAndSpace();
const self = await insertPage(context, 'Self');
await sql`UPDATE pages SET parent_page_id = ${self.id} WHERE id = ${self.id}`.execute(
db,
);
return { self: { ...self, parentPageId: self.id } };
}
export async function seedTwoPageCycle(): Promise<{
a: PageFixture;
b: PageFixture;
}> {
const context = await seedWorkspaceAndSpace();
const a = await insertPage(context, 'A');
const b = await insertPage(context, 'B', a.id);
await sql`UPDATE pages SET parent_page_id = ${b.id} WHERE id = ${a.id}`.execute(
db,
);
return {
a: { ...a, parentPageId: b.id },
b,
};
}
export async function seedBranchingDescendantTree(): Promise<{
root: PageFixture;
firstChild: PageFixture;
secondChild: PageFixture;
grandchild: PageFixture;
}> {
const context = await seedWorkspaceAndSpace();
const root = await insertPage(context, 'Root');
const firstChild = await insertPage(context, 'First child', root.id);
const secondChild = await insertPage(context, 'Second child', root.id);
const grandchild = await insertPage(context, 'Grandchild', firstChild.id);
return { root, firstChild, secondChild, grandchild };
}