Compare commits

..

20 Commits

Author SHA1 Message Date
Philipinho 5eb3416b5c namespace 2026-01-17 14:51:56 +00:00
Philipinho c1cfe158cd smart scene sync 2026-01-17 14:39:27 +00:00
Philipinho ab81903299 ignore ts error 2026-01-17 13:44:04 +00:00
Philipinho 14698ebb05 fix room exit 2026-01-17 13:42:58 +00:00
Philipinho efa52ea4c8 Live cursor 2026-01-17 13:36:35 +00:00
Philipinho a4750bff56 WIP - POC 2026-01-17 13:24:54 +00:00
Philipinho 5c9eed53c0 websocket - WIP 2026-01-17 03:15:58 +00:00
Philipinho bcb004af21 update lockfile 2026-01-16 13:22:41 +00:00
Philipinho ac675e7d74 update dockerfile 2026-01-16 13:21:42 +00:00
Philipinho bf89eff5e7 sync 2026-01-16 13:20:31 +00:00
Philip Okugbe 183787fa0c fix: update dependencies (#1843) 2026-01-14 16:36:47 +00:00
Philipinho 15aa04a5f7 sync 2026-01-14 11:49:39 +00:00
Philipinho 79343a5d52 fix: prevent text overflow in group and space list tables 2026-01-13 16:25:42 +00:00
Philipinho 61e252918e fix length 2026-01-13 16:13:52 +00:00
Philipinho e98fa7f69a sync
* fix form length
2026-01-13 16:13:04 +00:00
Philip Okugbe 6d148a35eb New Crowdin updates (#1830)
* New translations translation.json (Japanese)

* New translations translation.json (Japanese)
2026-01-13 16:01:08 +00:00
Philip Okugbe 0bbc1c35de fix: public sharing performance improvements (#1841) 2026-01-13 16:00:22 +00:00
Philip Okugbe 47097969a0 fix: use subquery (#1833)
- enhance file tasks list endpoint
2026-01-13 15:58:26 +00:00
Philip Okugbe 13f529e064 fix anchor scroll in same page (#1834) 2026-01-13 15:35:53 +00:00
Philip Okugbe 8fc8422fbc fix: increase max length for groups and spaces (#1840) 2026-01-13 15:31:03 +00:00
59 changed files with 3186 additions and 3260 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
node_modules
.git
.gitignore
dist
data
/data
.env*
.nx
+3 -3
View File
@@ -1,13 +1,14 @@
FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
FROM base AS builder
WORKDIR /app
COPY . .
RUN npm install -g pnpm@10.4.0
RUN pnpm install --frozen-lockfile
RUN pnpm build
@@ -31,12 +32,11 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches
COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm@10.4.0
RUN chown -R node:node /app
USER node
+21 -22
View File
@@ -10,51 +10,50 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
"@mantine/notifications": "^8.1.3",
"@mantine/spotlight": "^8.1.3",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"@excalidraw/excalidraw": "0.18.0-c158187",
"@mantine/core": "^8.3.12",
"@mantine/dates": "^8.3.12",
"@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.3.12",
"@mantine/notifications": "^8.3.12",
"@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17",
"@tiptap/extension-character-count": "^2.27.1",
"alfaaz": "^1.1.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.14.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.12.5",
"i18next": "^23.16.8",
"i18next-http-backend": "^2.7.3",
"jotai": "^2.16.2",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.22",
"katex": "0.16.27",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.11.0",
"mermaid": "^11.12.2",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15",
"react-clear-modal": "^2.0.17",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.1",
"react-drawio": "^1.0.7",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-router-dom": "^7.0.1",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"react-router-dom": "^7.12.0",
"semver": "^7.7.3",
"socket.io-client": "^4.8.3",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76"
+144 -144
View File
@@ -13,21 +13,21 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになることができます",
"Can create and edit pages in space.": "スペース内のページを作成および編集できます",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになます",
"Can create and edit pages in space.": "スペース内のページを作成編集できます",
"Can edit": "編集可能",
"Can manage workspace": "ワークスペースを管理できます",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can view": "閲覧可能",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Cancel": "キャンセル",
"Change email": "メールアドレスの変更",
"Change password": "パスワードの変更",
"Change photo": "画像の変更",
"Choose a role": "ロールを選んでください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みのインターフェース言語を選択してください",
"Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認",
"Copy link": "リンクをコピー",
"Create": "新規作成",
@@ -40,24 +40,24 @@
"Date": "日付",
"Delete": "削除",
"Delete group": "グループを削除",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?この操作により、子ページおよびページ履歴削除されます。この操作は元に戻せません。",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページページ履歴削除されます。この操作は取り消せません。",
"Description": "説明",
"Details": "詳細",
"e.g ACME": "例: 山田太郎",
"e.g ACME Inc": "例: 株式会社サンプル",
"e.g Developers": "例: エンジニア",
"e.g Group for developers": "例: エンジニアグループ",
"e.g Group for developers": "例: 開発チーム",
"e.g product": "例: product",
"e.g Product Team": "例: 製品チーム",
"e.g Sales": "例: 営業",
"e.g Space for product team": "例: 製品チームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"e.g Product Team": "例: プロダクトチーム",
"e.g Sales": "例: 営業",
"e.g Space for product team": "例: プロダクトチームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム用スペース",
"Edit": "編集",
"Read": "読む",
"Read": "閲覧",
"Edit group": "グループを編集",
"Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください",
"Enter valid email addresses separated by comma or space max_50": "有効なメールアドレスをカンマまたはスペース区切って入力してください(最大 50",
"Enter valid email addresses separated by comma or space max_50": "メールアドレスをカンマまたはスペース区切りで入力(最大50",
"enter valid emails addresses": "有効なメールアドレスを入力してください",
"Enter your current password": "現在のパスワードを入力してください",
"enter your full name": "氏名を入力してください",
@@ -81,18 +81,18 @@
"Group description": "グループ説明",
"Group name": "グループ名",
"Groups": "グループ",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Home": "ホーム",
"Import pages": "ページをインポート",
"Import pages & space settings": "ページとスペース設定をインポート",
"Importing pages": "ページをインポートしています",
"invalid invitation link": "招待リンクが間違っています",
"invalid invitation link": "無効な招待リンクす",
"Invitation signup": "招待登録",
"Invite by email": "メールアドレスで招待する",
"Invite members": "メンバーを招待する",
"Invite new members": "新しいメンバーを招待する",
"Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセス権が付与されます",
"Invited members who are yet to accept their invitation will appear here.": "招待を承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセスできます",
"Join the workspace": "ワークスペースに参加",
"Language": "言語",
"Light": "ライト",
@@ -113,20 +113,20 @@
"New page": "新規ページ",
"New password": "新しいパスワード",
"No group found": "グループが見つかりません",
"No page history saved yet.": "まだページ履歴が保存されていません",
"No page history saved yet.": "ページ履歴がありません",
"No pages yet": "ページがありません",
"No results found...": "結果が見つかりませんでした...",
"No user found": "ユーザがいません",
"No results found...": "結果が見つかりません",
"No user found": "ユーザーが見つかりません",
"Overview": "概要",
"Owner": "所有者",
"page": "ページ",
"Page deleted successfully": "ページが正常に削除されました",
"Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ページインポートが進行中です。このタブを閉じないでください",
"Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ページインポート中です。このタブを閉じないでください",
"Pages": "ページ",
"pages": "ページ",
"Password": "パスワード",
"Password changed successfully": "パスワードが正常に変更されました",
"Password changed successfully": "パスワードを変更しました",
"Pending": "保留中",
"Please confirm your action": "アクションを確認してください",
"Preferences": "設定",
@@ -143,95 +143,95 @@
"Search for groups": "グループを検索",
"Search for users": "ユーザーを検索",
"Search for users and groups": "ユーザーとグループを検索",
"Search...": "検索する語句を入力",
"Search...": "検索",
"Select language": "言語を選択",
"Select role": "ロールを選択",
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
"Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択",
"Select theme": "テーマを選択",
"Send invitation": "招待を送る",
"Invitation sent": "招待送信されました",
"Invitation sent": "招待送信ました",
"Settings": "設定",
"Setup workspace": "ワークスペースを設定する",
"Sign In": "サインイン",
"Sign Up": "アカウント登録",
"Slug": "Slug (URL用文字列)",
"Sign Up": "新規登録",
"Slug": "スラッグ(URL識別子)",
"Space": "スペース",
"Space description": "スペース説明",
"Space menu": "スペースメニュー",
"Space name": "スペース名",
"Space settings": "スペース設定",
"Space slug": "スペースのSlug (URL用文字列)",
"Space slug": "スペースのスラッグ(URL識別子)",
"Spaces": "スペース",
"Spaces you belong to": "所属しているスペース",
"No space found": "スペースが見つかりません",
"Search for spaces": "スペースを検索",
"Start typing to search...": "検索を開始するには入力してください...",
"Start typing to search...": "入力して検索",
"Status": "ステータス",
"Successfully imported": "インポートに成功しました",
"Successfully restored": "正常に復元されました",
"Successfully imported": "インポートしました",
"Successfully restored": "復元しました",
"System settings": "システム設定",
"Theme": "テーマ",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。",
"Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください",
"Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください",
"untitled": "無題",
"Untitled": "無題",
"Updated successfully": "正常に更新されました",
"Updated successfully": "更新しました",
"User": "ユーザー",
"Workspace": "ワークスペース",
"Workspace Name": "ワークスペース名",
"Workspace settings": "ワークスペース設定",
"You can change your password here.": "パスワードを変更できます",
"You can change your password here.": "パスワードを変更できます",
"Your Email": "メールアドレス",
"Your import is complete.": "インポートが完了しました",
"Your import is complete.": "インポートが完了しました",
"Your name": "名前",
"Your Name": "名前",
"Your password": "パスワード",
"Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。",
"Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください",
"Sidebar toggle": "サイドバー切り替え",
"Comments": "コメント",
"404 page not found": "404 ページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Take me back to homepage": "ホームに戻る",
"Forgot password": "パスワードを忘れた",
"Forgot your password?": "パスワードを忘れましたか?",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信を確認してください",
"Send reset link": "リセットリンクを送",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信ました。受信トレイを確認してください",
"Send reset link": "リセットリンクを送",
"Password reset": "パスワードリセット",
"Your new password": "新しいパスワード",
"Set password": "パスワードを設定",
"Write a comment": "コメントを書く",
"Reply...": "返信...",
"Error loading comments.": "コメントの読み込み中にエラーが発生しました",
"No comments yet.": "コメントがありません",
"Error loading comments.": "コメントの読み込みに失敗しました",
"No comments yet.": "コメントがありません",
"Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Comment created successfully": "コメント作成されました",
"Error creating comment": "コメントの作成中にエラーが発生しました",
"Comment updated successfully": "コメント更新されました",
"Comment created successfully": "コメント作成ました",
"Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメント更新ました",
"Failed to update comment": "コメントの更新に失敗しました",
"Comment deleted successfully": "コメント削除されました",
"Comment deleted successfully": "コメント削除ました",
"Failed to delete comment": "コメントの削除に失敗しました",
"Comment resolved successfully": "コメント解決されました",
"Comment re-opened successfully": "コメント再開されました",
"Comment unresolved successfully": "コメントが再解決されました",
"Comment resolved successfully": "コメント解決ました",
"Comment re-opened successfully": "コメント再開ました",
"Comment unresolved successfully": "コメントを未解決に戻しました",
"Failed to resolve comment": "コメントの解決に失敗しました",
"Resolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決に戻す",
"Resolve Comment Thread": "コメントスレッドを解決",
"Unresolve Comment Thread": "コメントスレッドを解決",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決しますか?",
"Unresolve Comment Thread": "コメントスレッドを解決に戻す",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決に戻しますか?",
"Resolved": "解決済",
"No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決されたコメントはありません",
"No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決済みのコメントはありません",
"Revoke invitation": "招待を取り消す",
"Revoke": "取り消す",
"Don't": "取り消さない",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか? ユーザはワークスペースに参加できなくなります",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですかユーザはワークスペースに参加できなくなります",
"Resend invitation": "招待を再度送る",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもこのワークスペースに参加できます",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもワークスペースに参加できます",
"Invite link": "招待リンク",
"Copy": "コピー",
"Copy to space": "スペースにコピー",
@@ -239,13 +239,13 @@
"Duplicate": "複製",
"Select a user": "ユーザを選択",
"Select a group": "グループを選択",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Delete space": "スペースを削除",
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
"Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限完全に削除されます",
"Delete this space with all its pages and data.": "このスペースすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限完全に削除されます",
"Confirm space name": "スペース名を確認する",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "アクションを確認するためスペース名 <b>{{spaceName}}</b> を入力してください",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "確認のためスペース名 <b>{{spaceName}}</b> を入力してください",
"Format": "フォーマット",
"Include subpages": "サブページを含める",
"Include attachments": "添付ファイルを含める",
@@ -273,12 +273,12 @@
"Success": "成功",
"Warning": "警告",
"Danger": "危険",
"Mermaid diagram error:": "Mermaid コードエラー",
"Invalid Mermaid diagram": "無効な Mermaid コードです",
"Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.io図を編集",
"Mermaid diagram error:": "Mermaid ダイアグラムエラー:",
"Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです",
"Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集",
"Exit": "終了",
"Save & Exit": "保存して終了",
"Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集",
"Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集",
"Paste link": "リンクを貼り付け",
"Edit link": "リンクを編集",
"Remove link": "リンクを削除",
@@ -315,22 +315,22 @@
"Bullet List": "箇条書きリスト",
"Numbered List": "番号付きリスト",
"Blockquote": "引用",
"Just start typing with plain text.": "すぐに文章を書き始められます",
"Track tasks with a to-do list.": "Todoリストでタスクを追跡します",
"Big section heading.": "大きいフォントのセクション見出しです。",
"Medium section heading.": "中くらいのフォントのセクション見出しです。",
"Small section heading.": "小さいフォントのセクション見出しです。",
"Create a simple bullet list.": "シンプルな箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用を作成します",
"Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "水平線を挿入します",
"Upload any image from your device.": "画像をアップロードします",
"Upload any video from your device.": "動画をアップロードします",
"Upload any file from your device.": "ファイルをアップロードします",
"Just start typing with plain text.": "プレーンテキストを入力します",
"Track tasks with a to-do list.": "Todo リストでタスクを管理します",
"Big section heading.": "大見出し",
"Medium section heading.": "中見出し",
"Small section heading.": "小見出し",
"Create a simple bullet list.": "箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用ブロックを作成します",
"Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "区切り線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any file from your device.": "デバイスからファイルをアップロードします",
"Table": "テーブル",
"Insert a table.": "を挿入します",
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します",
"Insert a table.": "テーブルを挿入します",
"Insert collapsible block.": "折りたたみブロックを挿入します",
"Video": "動画",
"Divider": "区切り線",
"Quote": "引用",
@@ -338,16 +338,16 @@
"File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える",
"Callout": "コールアウト",
"Insert callout notice.": "コールアウトブロックを挿入します",
"Insert callout notice.": "コールアウトを挿入します",
"Math inline": "インライン数式",
"Insert inline math equation.": "インライン数式を挿入します",
"Insert inline math equation.": "インライン数式を挿入します",
"Math block": "数式ブロック",
"Insert math equation": "数式を挿入します",
"Mermaid diagram": "Mermaidコード",
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
"Insert and design Drawio diagrams": "Drawio図を挿入してデザインします",
"Insert current date": "今日の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw図を埋め込みます",
"Mermaid diagram": "Mermaid ダイアグラム",
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入します",
"Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します",
"Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"Multiple": "複数",
"Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える",
@@ -357,29 +357,29 @@
"Yesterday, {{time}}": "昨日、{{time}}",
"Space created successfully": "スペースを作成しました",
"Space updated successfully": "スペースを更新しました",
"Space deleted successfully": "スペース削除されました",
"Space deleted successfully": "スペース削除ました",
"Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバー削除されました",
"Member removed successfully": "メンバー削除ました",
"Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "作成しました:{{time}}",
"Created at: {{time}}": "作成日: {{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
"Word count: {{wordCount}}": "単語数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ます",
"Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します",
"Reading": "読み取り",
"Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバー削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません",
"Member deleted successfully": "メンバー削除ました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません",
"Move": "移動",
"Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...",
"Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次生成ます",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加すると目次生成されます",
"Share": "共有",
"Public sharing": "公開共有",
"Shared by": "共有者",
@@ -398,13 +398,13 @@
"Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有が正常に削除されました",
"Share deleted successfully": "共有を削除しました",
"Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました",
"Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーに成功しました",
"Page duplicated successfully": "ページが正常に複製されました",
"Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーしました",
"Page duplicated successfully": "ページを複製しました",
"Find": "検索",
"Not found": "見つかりません",
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
@@ -419,26 +419,26 @@
"Error": "エラー",
"Failed to disable MFA": "MFAの無効化に失敗しました",
"Disable two-factor authentication": "二要素認証を無効化",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください:",
"Two-factor authentication has been enabled": "二要素認証有効になりました",
"Two-factor authentication has been disabled": "二要素認証無効になりました",
"2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください",
"Two-factor authentication has been enabled": "二要素認証有効にました",
"Two-factor authentication has been disabled": "二要素認証無効にました",
"2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証が有効です",
"Add 2FA method": "2FAメソッドを追加",
"Backup codes": "バックアップコード",
"Disable": "無効にする",
"Invalid verification code": "無効な認証コード",
"New backup codes have been generated": "新しいバックアップコード生成されました",
"New backup codes have been generated": "新しいバックアップコード生成ました",
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"About backup codes": "バックアップコードについて",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコード再生成できます。これにより、既存のすべてのコードが無効になります",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "新しいバックアップコードはいつでも再生成できます。既存のコードはすべて無効になります",
"Confirm password": "パスワードを確認",
"Generate new backup codes": "新しいバックアップコードを生成",
"Save your new backup codes": "新しいバックアップコードを保存",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効になりました",
"Your new backup codes": "新しいバックアップコード",
"I've saved my backup codes": "バックアップコードを保存しました",
"Failed to setup MFA": "MFAの設定に失敗しました",
@@ -449,51 +449,51 @@
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
"Verify and enable": "確認と有効化",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください",
"Backup": "バックアップ",
"Save codes": "コードを保存",
"Save your backup codes": "バックアップコードを保存",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、これらのコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"Print": "印刷",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定されました。再度ログインしてください",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定ました。再度ログインしてください",
"Two-Factor authentication required": "二要素認証が必要です",
"Your workspace requires two-factor authentication for all users": "ワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます",
"Your workspace requires two-factor authentication for all users": "このワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースアクセスるには二要素認証を設定してください。アカウントのセキュリティが強化されます",
"Set up two-factor authentication": "二要素認証を設定",
"Cancel and logout": "キャンセルしてログアウト",
"Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます",
"Your workspace requires two-factor authentication. Please set it up to continue.": "このワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "認証アプリからの確認コードアカウントのセキュリティが強化されます",
"Password is required": "パスワードが必要です",
"Password must be at least 8 characters": "パスワードは8文字以上必要です",
"Please enter a 6-digit code": "6桁のコードを入力してください",
"Code must be exactly 6 digits": "コードは正確に6桁である必要があります",
"Code must be exactly 6 digits": "コードは6桁で入力してください",
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
"Need help authenticating?": "認証に関するヘルプが必要ですか?",
"MFA QR Code": "MFA QRコード",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットが成功しました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットが成功しました。新しいパスワードでログインしてください",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントを作成しました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインして二要素認証を設定してください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットしました。新しいパスワードでログインしてください",
"Two-factor authentication": "二要素認証",
"Use authenticator app instead": "代わりに認証アプリを使用",
"Verify backup code": "バックアップコードを確認",
"Use backup code": "バックアップコードを使用",
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
"Backup code": "バックアップコード",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
"Verify": "確認",
"Trash": "ごみ箱",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Deleted": "削除",
"No pages in trash": "ごみ箱にページがありません",
"Permanently delete page?": "ページを完全に削除しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますか? この操作は元に戻せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますかこの操作は取り消せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Move to trash": "ごみ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元",
"Page moved to trash": "ページごみ箱に移動されました",
"Page restored successfully": "ページが正常に復元されました",
"Page moved to trash": "ページごみ箱に移動ました",
"Page restored successfully": "ページを復元しました",
"Deleted by": "削除者",
"Deleted at": "削除日時",
"Preview": "プレビュー",
@@ -511,10 +511,10 @@
"Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみSSOで登録できます",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみSSO経由で登録できます",
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "有効にすると、すべてのメンバーが二要素認証を設定しないとワークスペースにアクセスできなくなります",
"Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名",
"Allow signup": "登録を許可する",
@@ -532,10 +532,10 @@
"Upload image": "画像をアップロード",
"Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像が正常に削除されました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像を削除しました",
"API key": "APIキー",
"API key created successfully": "APIキーが正常に作成されました",
"API key created successfully": "APIキーを作成しました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
@@ -550,9 +550,9 @@
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "正常に無効化されました",
"Revoked successfully": "無効にしました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定",
@@ -562,7 +562,7 @@
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペースコンテンツ全体にわたって意味検索機能を提供します",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え",
"Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
import { ENCRYPTION_KEY_BITS } from "@excalidraw/common";
type LibraryItems = any;
type LibraryPersistedData = {
@@ -8,8 +10,8 @@ export interface LibraryPersistenceAdapter {
load(metadata: { source: "load" | "save" }):
| Promise<{ libraryItems: LibraryItems } | null>
| {
libraryItems: LibraryItems;
}
libraryItems: LibraryItems;
}
| null;
save(libraryData: LibraryPersistedData): Promise<void> | void;
@@ -25,7 +27,10 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
return JSON.parse(data);
}
} catch (e) {
console.error("Error downloading Excalidraw library from localStorage", e);
console.error(
"Error downloading Excalidraw library from localStorage",
e,
);
}
return null;
},
@@ -40,3 +45,124 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
}
},
};
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};
export const IV_LENGTH_BYTES = 12;
// Pre-transform error: No known conditions for "./data/encryption" specifier in "@excalidraw/excalidraw" package
// Plugin: vite:import-analysis
// File: /Users/lite/WebstormProjects/docmost-ee/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts:11:7
// 7 | decryptData,
// 8 | encryptData
// 9 | } from "@excalidraw/excalidraw/data/encryption";
//@ts-ignore
export const createIV = (): Uint8Array<ArrayBuffer> => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
export const generateEncryptionKey = async <
T extends "string" | "cryptoKey" = "string",
>(
returnAs?: T,
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
true, // extractable
["encrypt", "decrypt"],
);
return (
returnAs === "cryptoKey"
? key
: (await window.crypto.subtle.exportKey("jwk", key)).k
) as T extends "cryptoKey" ? CryptoKey : string;
};
export const getCryptoKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
false, // extractable
[usage],
);
export const encryptData = async (
key: string | CryptoKey,
//@ts-ignore
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
//@ts-ignore
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
//@ts-ignore
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: data instanceof Blob
? await blobToArrayBuffer(data)
: data;
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encryptedBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
buffer,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
//@ts-ignore
iv: Uint8Array<ArrayBuffer>,
//@ts-ignore
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
@@ -8,13 +8,14 @@ import {
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useState } from "react";
import { useState, useCallback } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import type { ExcalidrawImperativeAPI, Gesture } from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
@@ -22,8 +23,9 @@ import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { useHandleLibrary, LiveCollaborationTrigger } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useExcalidrawCollab } from "./use-excalidraw-collab";
const Excalidraw = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
@@ -46,6 +48,16 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const pageId = editor.storage?.pageId;
const { broadcastScene, broadcastPointer, isCollaborating } = useExcalidrawCollab(excalidrawAPI, pageId, opened);
const handleChange = useCallback(
(elements: readonly ExcalidrawElement[]) => {
broadcastScene(elements);
},
[broadcastScene],
);
const handleOpen = async () => {
if (!editor.isEditable) {
return;
@@ -157,6 +169,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
scrollToContent: true,
}}
theme={computedColorScheme}
onChange={handleChange}
onPointerUpdate={broadcastPointer}
renderTopRightUI={() => (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {}}
/>
)}
/>
</Suspense>
</div>
@@ -0,0 +1,257 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { isSyncableElement } from "../data";
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import type { TCollabClass } from "./Collab";
import type { Socket } from "socket.io-client";
class Portal {
collab: TCollabClass;
socket: Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map();
constructor(collab: TCollabClass) {
this.collab = collab;
}
open(socket: Socket, id: string, key: string) {
this.socket = socket;
this.roomId = id;
this.roomKey = key;
// Initialize socket listeners
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
trackEvent("share", "room joined");
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
WS_SUBTYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: SocketId[]) => {
this.collab.setCollaborators(clients);
});
return socket;
}
close() {
if (!this.socket) {
return;
}
this.queueFileUpload.flush();
this.socket.close();
this.socket = null;
this.roomId = null;
this.roomKey = null;
this.socketInitialized = false;
this.broadcastedElementVersions = new Map();
}
isOpen() {
return !!(
this.socketInitialized &&
this.socket &&
this.roomId &&
this.roomKey
);
}
async _broadcastSocketData(
data: SocketUpdateData,
volatile: boolean = false,
roomId?: string,
) {
if (this.isOpen()) {
const json = JSON.stringify(data);
const encoded = new TextEncoder().encode(json);
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
this.socket?.emit(
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
roomId ?? this.roomId,
encryptedBuffer,
iv,
);
}
}
queueFileUpload = throttle(async () => {
try {
await this.collab.fileManager.saveFiles({
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
files: this.collab.excalidrawAPI.getFiles(),
});
} catch (error: any) {
if (error.name !== "AbortError") {
this.collab.excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
}
}
let isChanged = false;
const newElements = this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
isChanged = true;
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return newElementWith(element, { status: "saved" });
}
return element;
});
if (isChanged) {
this.collab.excalidrawAPI.updateScene({
elements: newElements,
captureUpdate: CaptureUpdateAction.NEVER,
});
}
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
elements: readonly OrderedExcalidrawElement[],
syncAll: boolean,
) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT");
}
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = elements.reduce((acc, element) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version > this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push(element);
}
return acc;
}, [] as SyncableExcalidrawElement[]);
const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType,
payload: {
elements: syncableElements,
},
};
for (const syncableElement of syncableElements) {
this.broadcastedElementVersions.set(
syncableElement.id,
syncableElement.version,
);
}
this.queueFileUpload();
await this._broadcastSocketData(data as SocketUpdateData);
};
broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: WS_SUBTYPES.IDLE_STATUS,
payload: {
socketId: this.socket.id as SocketId,
userState,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
}) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
type: WS_SUBTYPES.MOUSE_LOCATION,
payload: {
socketId: this.socket.id as SocketId,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds:
this.collab.excalidrawAPI.getAppState().selectedElementIds,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastVisibleSceneBounds = (
payload: {
sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
},
roomId: string,
) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
payload: {
socketId: this.socket.id as SocketId,
username: this.collab.state.username,
sceneBounds: payload.sceneBounds,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
roomId,
);
}
};
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
if (this.socket?.id) {
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
}
};
}
export default Portal;
@@ -0,0 +1,266 @@
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
import { useAtom } from "jotai";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import type {
ExcalidrawImperativeAPI,
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw";
import throttle from "lodash.throttle";
// Message types for collaboration
type SceneUpdateMessage = {
type: "SCENE_UPDATE";
payload: { elements: readonly ExcalidrawElement[] };
};
type PointerUpdateMessage = {
type: "POINTER_UPDATE";
payload: {
socketId: string;
pointer: { x: number; y: number };
button: "down" | "up";
username: string;
selectedElementIds: Record<string, boolean>;
};
};
type CollabMessage = SceneUpdateMessage | PointerUpdateMessage;
export function useExcalidrawCollab(
excalidrawAPI: ExcalidrawImperativeAPI | null,
pageId: string | undefined,
isOpen: boolean,
) {
const [socket] = useAtom(socketAtom);
const [currentUser] = useAtom(currentUserAtom);
const lastBroadcastedVersion = useRef(-1);
const isInitialized = useRef(false);
const collaboratorsRef = useRef<Map<string, Collaborator>>(new Map());
const [isCollaborating, setIsCollaborating] = useState(false);
// Track broadcasted element versions for bandwidth optimization
const broadcastedElementVersions = useRef<Map<string, number>>(new Map());
const roomId = pageId ? `excalidraw-${pageId}` : null;
const username = currentUser?.user?.name || "Anonymous";
// Broadcast pointer/cursor updates (volatile - can be dropped)
const broadcastPointer = useMemo(
() =>
throttle(
(payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => {
if (!socket || !roomId || !isInitialized.current) return;
if (payload.pointersMap.size >= 2) return; // Skip multi-touch
const data: PointerUpdateMessage = {
type: "POINTER_UPDATE",
payload: {
socketId: socket.id!,
pointer: payload.pointer,
button: payload.button,
username,
selectedElementIds:
excalidrawAPI?.getAppState().selectedElementIds || {},
},
};
const json = JSON.stringify(data);
socket.emit("ex-server-volatile-broadcast", [roomId, json, null]);
},
50,
),
[socket, roomId, username, excalidrawAPI],
);
// Broadcast scene changes with bandwidth optimization
const broadcastScene = useCallback(
(elements: readonly ExcalidrawElement[], syncAll = false) => {
if (!socket || !roomId || !isInitialized.current) {
return;
}
const sceneVersion = getSceneVersion(elements);
if (sceneVersion <= lastBroadcastedVersion.current) {
return;
}
// Filter to only send elements that changed since last broadcast
const changedElements = elements.filter((element) => {
const lastVersion = broadcastedElementVersions.current.get(element.id);
return syncAll || lastVersion === undefined || element.version > lastVersion;
});
if (changedElements.length === 0) {
return;
}
const data: SceneUpdateMessage = {
type: "SCENE_UPDATE",
payload: { elements: changedElements },
};
// Update tracking map
for (const element of changedElements) {
broadcastedElementVersions.current.set(element.id, element.version);
}
const json = JSON.stringify(data);
socket.emit("ex-server-broadcast", [roomId, json, null]);
lastBroadcastedVersion.current = sceneVersion;
},
[socket, roomId],
);
// Throttled version for onChange handler
const throttledBroadcastScene = useMemo(
() => throttle((elements: readonly ExcalidrawElement[]) => broadcastScene(elements, false), 100),
[broadcastScene],
);
// Handle incoming broadcasts
const handleClientBroadcast = useCallback(
(jsonData: string, _iv: Uint8Array | null) => {
if (!excalidrawAPI || !socket) return;
try {
const data: CollabMessage = JSON.parse(jsonData);
if (data.type === "SCENE_UPDATE" && data.payload?.elements) {
const remoteElements = data.payload.elements;
const localElements =
excalidrawAPI.getSceneElementsIncludingDeleted();
const reconciledElements = reconcileElements(
localElements,
// @ts-ignore
remoteElements,
excalidrawAPI.getAppState(),
);
excalidrawAPI.updateScene({
elements: reconciledElements,
});
lastBroadcastedVersion.current = getSceneVersion(reconciledElements);
} else if (data.type === "POINTER_UPDATE") {
const { socketId, pointer, button, username, selectedElementIds } =
data.payload;
// Don't update our own cursor
if (socketId === socket.id) return;
// Update collaborator with pointer info
const collaborator = collaboratorsRef.current.get(socketId) || {};
collaboratorsRef.current.set(socketId, {
...collaborator,
// @ts-ignore
pointer,
button,
username,
// @ts-ignore
selectedElementIds,
isCurrentUser: false,
});
excalidrawAPI.updateScene({
// @ts-ignore
collaborators: collaboratorsRef.current,
});
}
} catch (err) {
console.error("Failed to process broadcast:", err);
}
},
[excalidrawAPI, socket],
);
// Handle room user changes
const handleRoomUserChange = useCallback(
(socketIds: string[]) => {
if (!excalidrawAPI || !socket) return;
// Update collaborators map, preserving existing data
const newCollaborators = new Map<string, Collaborator>();
for (const id of socketIds) {
const existing = collaboratorsRef.current.get(id);
newCollaborators.set(id, {
...existing,
isCurrentUser: id === socket.id,
username:
existing?.username || (id === socket.id ? username : "User"),
});
}
collaboratorsRef.current = newCollaborators;
// @ts-ignore
excalidrawAPI.updateScene({ collaborators: newCollaborators });
// We're collaborating if there are other users
setIsCollaborating(socketIds.length > 1);
},
[excalidrawAPI, socket, username],
);
// Join/leave room based on modal state
useEffect(() => {
if (!socket || !roomId || !isOpen) {
setIsCollaborating(false);
return;
}
console.log("Joining room:", roomId);
socket.emit("ex-join-room", roomId);
isInitialized.current = true;
// Set up listeners
socket.on("ex-client-broadcast", handleClientBroadcast);
socket.on("ex-room-user-change", handleRoomUserChange);
socket.on("ex-first-in-room", () => {
console.log("First in excalidraw room");
});
socket.on("ex-new-user", (socketId: string) => {
console.log("New user joined:", socketId);
if (excalidrawAPI) {
// Send full scene to new user (syncAll = true)
broadcastScene(excalidrawAPI.getSceneElements(), true);
}
});
return () => {
console.log("Leaving room:", roomId);
socket.emit("ex-leave-room", roomId);
socket.off("ex-client-broadcast", handleClientBroadcast);
socket.off("ex-room-user-change", handleRoomUserChange);
socket.off("ex-first-in-room");
socket.off("ex-new-user");
isInitialized.current = false;
lastBroadcastedVersion.current = -1;
broadcastedElementVersions.current = new Map();
collaboratorsRef.current = new Map();
setIsCollaborating(false);
};
}, [
socket,
roomId,
isOpen,
handleClientBroadcast,
handleRoomUserChange,
broadcastScene,
excalidrawAPI,
]);
return {
broadcastScene: throttledBroadcastScene,
broadcastPointer,
isCollaborating,
};
}
@@ -1,19 +1,21 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useParams } from "react-router-dom";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const { spaceSlug } = useParams();
const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
const {
data: page,
isLoading,
@@ -23,6 +25,20 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
const handleClick = (e: React.MouseEvent) => {
if (isSamePage && anchorId) {
e.preventDefault();
const element = document.querySelector(`[id="${anchorId}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`#${anchorId}`, { replace: true });
}
}
};
const shareSlugUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
@@ -45,6 +61,7 @@ export default function MentionView(props: NodeViewProps) {
to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
@@ -1,5 +1,7 @@
import api from "@/lib/api-client";
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IApiKey } from "@/ee/api-key";
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
const req = await api.post<IFileTask>("/file-tasks/info", {
@@ -8,7 +10,10 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
return req.data;
}
export async function getFileTasks(): Promise<IFileTask[]> {
const req = await api.post<IFileTask[]>("/file-tasks");
export async function getFileTasks(
params?: QueryParams,
): Promise<IPagination<IFileTask>> {
const req = await api.post("/file-tasks", { ...params });
return req.data;
}
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
name: z.string().trim().min(2).max(100),
description: z.string().max(500),
});
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({
name: z.string().min(2).max(50),
name: z.string().min(2).max(100),
description: z.string().max(500),
});
@@ -50,7 +50,7 @@ export default function GroupList() {
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle />
<div>
<div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</Text>
@@ -269,12 +269,15 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => {
timerRef.current = setTimeout(() => {
queryClient.prefetchQuery({
queryKey: ["pages", node.data.slugId],
queryFn: () => getPageById({ pageId: node.data.slugId }),
timerRef.current = setTimeout(async () => {
const page = await queryClient.fetchQuery({
queryKey: ["pages", node.data.id],
queryFn: () => getPageById({ pageId: node.data.id }),
staleTime: 5 * 60 * 1000,
});
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150);
};
@@ -8,7 +8,6 @@ import {
Switch,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react";
@@ -21,12 +20,12 @@ import {
import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps {
readOnly: boolean;
@@ -35,7 +34,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug);
const pageSlugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams();
const { isTrial } = useTrial();
@@ -27,9 +27,7 @@ import {
getShares,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { useEffect } from "react";
export function useGetSharesQuery(
params?: QueryParams,
@@ -72,7 +70,7 @@ export function useShareForPageQuery(
queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId),
enabled: !!pageId,
staleTime: 0,
staleTime: 60 * 1000,
retry: false,
});
@@ -1,6 +1,7 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
@@ -9,12 +10,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
name: z.string().trim().min(2).max(100),
slug: z
.string()
.trim()
.min(2)
.max(50)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
@@ -7,12 +7,12 @@ import { ISpace } from "@/features/space/types/space.types.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(250),
name: z.string().min(2).max(100),
description: z.string().max(500),
slug: z
.string()
.min(2)
.max(50)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
@@ -48,7 +48,7 @@ export default function SpaceList() {
variant="filled"
name={space.name}
/>
<div>
<div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</Text>
+25 -25
View File
@@ -30,48 +30,48 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/azure": "^2.0.47",
"@ai-sdk/google": "^2.0.18",
"@ai-sdk/openai": "^2.0.46",
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0",
"@casl/ability": "^6.7.3",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"@langchain/core": "1.1.13",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.9",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/core": "^11.1.11",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.9",
"@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1",
"@nestjs/platform-fastify": "^11.1.11",
"@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.9",
"@nestjs/websockets": "^11.1.11",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^5.0.65",
"ai-sdk-ollama": "^0.12.0",
"ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3",
"cheerio": "^1.1.0",
"cheerio": "^1.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie": "^1.0.2",
"fs-extra": "^11.3.0",
"happy-dom": "20.0.10",
"cookie": "^1.1.1",
"fs-extra": "^11.3.3",
"happy-dom": "20.1.0",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0",
@@ -79,9 +79,9 @@
"mime-types": "^2.1.35",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.11",
"nodemailer": "^7.0.12",
"openid-client": "^5.7.1",
"otpauth": "^9.4.0",
"otpauth": "^9.4.1",
"p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
@@ -95,11 +95,11 @@
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.1",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
"typesense": "^2.1.0",
"ws": "^8.18.3",
"ws": "^8.19.0",
"yauzl": "^3.2.0"
},
"devDependencies": {
@@ -124,7 +124,7 @@
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^29.7.0",
"kysely-codegen": "^0.17.0",
"kysely-codegen": "^0.19.0",
"prettier": "^3.5.1",
"react-email": "3.0.2",
"source-map-support": "^0.5.21",
@@ -9,7 +9,6 @@ import { TokenService } from '../../core/auth/services/token.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util';
@@ -24,7 +23,6 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -70,31 +68,9 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
// Check page-level permissions
const { hasRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permissions
if (!canAccess) {
this.logger.warn(
`User ${user.id} denied page-level access to page: ${pageId}`,
);
throw new UnauthorizedException();
}
if (!canEdit) {
data.connection.readOnly = true;
this.logger.debug(
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
);
}
} else {
// No restrictions - use space-level permissions
if (userSpaceRole === SpaceRole.READER) {
data.connection.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
if (userSpaceRole === SpaceRole.READER) {
data.connection.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
@@ -14,12 +14,3 @@ export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see
}
export enum PageAccessLevel {
RESTRICTED = 'restricted', // only specific users/groups can view or edit
}
export enum PagePermissionRole {
READER = 'reader', // can only view content and descendants
WRITER = 'writer', // can edit content, descendants, and add new users to permission
}
@@ -53,7 +53,6 @@ import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page-access/page-access.service';
@Controller()
export class AttachmentController {
@@ -68,7 +67,6 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
) {}
@UseGuards(JwtAuthGuard)
@@ -113,8 +111,13 @@ export class AttachmentController {
throw new NotFoundException('Page not found');
}
// Checks both space-level and page-level edit permissions
await this.pageAccessService.validateCanEdit(page, user);
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
@@ -168,13 +171,14 @@ export class AttachmentController {
throw new NotFoundException();
}
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException();
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
// Checks both space-level and page-level view permissions
await this.pageAccessService.validateCanView(page, user);
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
@@ -24,7 +24,6 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { PageAccessService } from '../page-access/page-access.service';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -34,7 +33,6 @@ export class CommentController {
private readonly commentRepo: CommentRepo,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -49,7 +47,10 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.commentService.create(
{
@@ -74,8 +75,10 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.commentService.findByPageId(page.id, pagination);
}
@@ -87,13 +90,13 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return comment;
}
@@ -105,12 +108,17 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
await this.pageAccessService.validateCanEdit(page, user);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException(
'You must have space edit permission to edit comments',
);
}
return this.commentService.update(comment, dto, user);
}
@@ -123,27 +131,41 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
if (isOwner) {
/*
// Check if comment has children from other users
const hasChildrenFromOthers =
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
// Owner can delete if no children from other users
if (!hasChildrenFromOthers) {
await this.commentRepo.deleteComment(comment.id);
return;
}
// If has children from others, only space admin can delete
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'Only space admins can delete comments with replies from other users',
);
}*/
await this.commentRepo.deleteComment(comment.id);
return;
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
-2
View File
@@ -14,7 +14,6 @@ import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { PageAccessModule } from './page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
@@ -30,7 +29,6 @@ import { ShareModule } from './share/share.module';
SpaceModule,
GroupModule,
CaslModule,
PageAccessModule,
ShareModule,
],
})
@@ -11,7 +11,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateGroupDto {
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { PageAccessService } from './page-access.service';
@Global()
@Module({
providers: [PageAccessService],
exports: [PageAccessService],
})
export class PageAccessModule {}
@@ -1,71 +0,0 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Page, User } from '@docmost/db/types/entity.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@Injectable()
export class PageAccessService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
/**
* Validate user can view page, throws ForbiddenException if not.
* If page has restrictions: page-level permission determines access.
* If no restrictions: space-level permission determines access.
*/
async validateCanView(page: Page, user: User): Promise<void> {
// TODO: cache by pageId and userId.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasRestriction, canAccess } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permission
if (!canAccess) {
throw new ForbiddenException();
}
}
// No restriction - space membership (checked above) is sufficient for view
}
/**
* Validate user can edit page, throws ForbiddenException if not.
* If page has restrictions: page-level writer permission determines access.
* If no restrictions: space-level edit permission determines access.
*/
async validateCanEdit(page: Page, user: User): Promise<void> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasRestriction, canEdit } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permission
if (!canEdit) {
throw new ForbiddenException();
}
} else {
// No restrictions - use space-level permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
}
}
@@ -1,67 +0,0 @@
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { PagePermissionRole } from '../../../common/helpers/types/permission';
export class PageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
export class RestrictPageDto extends PageIdDto {}
export class AddPagePermissionDto extends PageIdDto {
@IsEnum(PagePermissionRole)
role: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(25, {
message: 'userIds must be an array with no more than 25 elements',
})
@ArrayMinSize(1)
@IsUUID('all', { each: true })
userIds?: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(25, {
message: 'groupIds must be an array with no more than 25 elements',
})
@ArrayMinSize(1)
@IsUUID('all', { each: true })
groupIds?: string[];
}
export class RemovePagePermissionDto extends PageIdDto {
@IsOptional()
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
groupId?: string;
}
export class UpdatePagePermissionRoleDto extends PageIdDto {
@IsEnum(PagePermissionRole)
role: string;
@IsOptional()
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
groupId?: string;
}
export class RemovePageRestrictionDto extends PageIdDto {}
@@ -1,107 +0,0 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { PagePermissionService } from './services/page-permission.service';
import {
AddPagePermissionDto,
PageIdDto,
RemovePagePermissionDto,
RemovePageRestrictionDto,
RestrictPageDto,
UpdatePagePermissionRoleDto,
} from './dto/page-permission.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
@UseGuards(JwtAuthGuard)
@Controller('pages/permissions')
export class PagePermissionController {
constructor(
private readonly pagePermissionService: PagePermissionService,
) {}
@HttpCode(HttpStatus.OK)
@Post('restrict')
async restrictPage(
@Body() dto: RestrictPageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
await this.pagePermissionService.restrictPage(dto.pageId, user, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('add')
async addPagePermission(
@Body() dto: AddPagePermissionDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (
(!dto.userIds || dto.userIds.length === 0) &&
(!dto.groupIds || dto.groupIds.length === 0)
) {
throw new BadRequestException('userIds or groupIds is required');
}
await this.pagePermissionService.addPagePermissions(dto, user, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('remove')
async removePagePermission(
@Body() dto: RemovePagePermissionDto,
@AuthUser() user: User,
) {
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('userId or groupId is required');
}
await this.pagePermissionService.removePagePermission(dto, user);
}
@HttpCode(HttpStatus.OK)
@Post('update-role')
async updatePagePermissionRole(
@Body() dto: UpdatePagePermissionRoleDto,
@AuthUser() user: User,
) {
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('userId or groupId is required');
}
await this.pagePermissionService.updatePagePermissionRole(dto, user);
}
@HttpCode(HttpStatus.OK)
@Post('unrestrict')
async removePageRestriction(
@Body() dto: RemovePageRestrictionDto,
@AuthUser() user: User,
) {
await this.pagePermissionService.removePageRestriction(dto.pageId, user);
}
@HttpCode(HttpStatus.OK)
@Post('list')
async getPagePermissions(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
return this.pagePermissionService.getPagePermissions(
dto.pageId,
user,
pagination,
);
}
}
+35 -71
View File
@@ -10,7 +10,6 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { PageAccessService } from '../page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
@@ -45,7 +44,6 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -63,7 +61,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return page;
}
@@ -75,24 +76,12 @@ export class PageController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (createPageDto.parentPageId) {
// Creating under a parent page - check edit permission on parent
const parentPage = await this.pageRepo.findById(
createPageDto.parentPageId,
);
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
throw new NotFoundException('Parent page not found');
}
await this.pageAccessService.validateCanEdit(parentPage, user);
} else {
// Creating at root level - require space-level permission
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.create(user.id, workspace.id, createPageDto);
@@ -107,7 +96,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.update(page, updatePageDto, user.id);
}
@@ -136,9 +128,10 @@ export class PageController {
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
} else {
// User with edit permission can delete
await this.pageAccessService.validateCanEdit(page, user);
// Soft delete requires page manage permissions
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageService.removePage(
deletePageDto.pageId,
user.id,
@@ -160,18 +153,11 @@ export class PageController {
throw new NotFoundException('Page not found');
}
//Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
// so page is virtually lost. Fix.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
//TODO: can users with page level edit, but no space level edit restore pages they can edit?
// Check page-level edit permission (if restoring to a restricted ancestor)
await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
return this.pageRepo.findById(pageIdDto.pageId, {
@@ -198,7 +184,6 @@ export class PageController {
return this.pageService.getRecentSpacePages(
recentPageDto.spaceId,
user.id,
pagination,
);
}
@@ -213,7 +198,6 @@ export class PageController {
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
//TODO: should space admin see deleted pages they dont have access to?
if (deletedPageDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
@@ -231,6 +215,7 @@ export class PageController {
}
}
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK)
@Post('/history')
async getPageHistory(
@@ -243,7 +228,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
}
@@ -259,14 +247,13 @@ export class PageController {
throw new NotFoundException('Page history not found');
}
// Get the page to check permissions
const page = await this.pageRepo.findById(history.pageId);
if (!page) {
throw new NotFoundException('Page not found');
const ability = await this.spaceAbility.createForUser(
user,
history.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return history;
}
@@ -298,12 +285,7 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.getSidebarPages(
spaceId,
pagination,
dto.pageId,
user.id,
);
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
}
@HttpCode(HttpStatus.OK)
@@ -333,11 +315,7 @@ export class PageController {
throw new ForbiddenException();
}
// Check page-level edit permission on the source page
await this.pageAccessService.validateCanEdit(movedPage, user);
// Moves only accessible pages; inaccessible child pages become root pages in original space
return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
}
@HttpCode(HttpStatus.OK)
@@ -348,10 +326,6 @@ export class PageController {
throw new NotFoundException('Page to copy not found');
}
// Check page-level view permission on the source page (need to read to copy)
// Inaccessible child branches are automatically skipped during duplication
await this.pageAccessService.validateCanView(copiedPage, user);
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -394,22 +368,10 @@ export class PageController {
user,
movedPage.spaceId,
);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Check page-level edit permission
await this.pageAccessService.validateCanEdit(movedPage, user);
// If moving to a new parent, check permission on the target parent
if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
const targetParent = await this.pageRepo.findById(dto.parentPageId);
if (targetParent) {
await this.pageAccessService.validateCanEdit(targetParent, user);
}
}
return this.pageService.movePage(dto, movedPage);
}
@@ -421,8 +383,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getPageBreadCrumbs(page.id);
}
}
+3 -5
View File
@@ -3,14 +3,12 @@ import { PageService } from './services/page.service';
import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
import { PagePermissionService } from './services/page-permission.service';
import { PagePermissionController } from './page-permission.controller';
import { StorageModule } from '../../integrations/storage/storage.module';
@Module({
controllers: [PageController, PagePermissionController],
providers: [PageService, PageHistoryService, TrashCleanupService, PagePermissionService],
exports: [PageService, PageHistoryService, PagePermissionService],
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService],
imports: [StorageModule],
})
export class PageModule {}
@@ -1,438 +0,0 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import {
AddPagePermissionDto,
RemovePagePermissionDto,
UpdatePagePermissionRoleDto,
} from '../dto/page-permission.dto';
import { Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
PageAccessLevel,
PagePermissionRole,
} from '../../../common/helpers/types/permission';
import { executeTx } from '@docmost/db/utils';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
@Injectable()
export class PagePermissionService {
constructor(
private pagePermissionRepo: PagePermissionRepo,
private pageRepo: PageRepo,
private spaceAbility: SpaceAbilityFactory,
@InjectKysely() private readonly db: KyselyDB,
) {}
async restrictPage(
pageId: string,
authUser: User,
workspaceId: string,
): Promise<void> {
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(authUser, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// TODO: does this check if any of the page's ancestor's is restricted and the user don't have access to it?
// to have access to this page, they must already have access to the page if any of it's ancestor's is restricted
const existingAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
if (existingAccess) {
throw new BadRequestException('Page is already restricted');
}
await executeTx(this.db, async (trx) => {
const pageAccess = await this.pagePermissionRepo.insertPageAccess(
{
pageId: pageId,
workspaceId: workspaceId,
accessLevel: PageAccessLevel.RESTRICTED,
creatorId: authUser.id,
},
trx,
);
await this.pagePermissionRepo.insertPagePermissions(
[
{
pageAccessId: pageAccess.id,
userId: authUser.id,
role: PagePermissionRole.WRITER,
addedById: authUser.id,
},
],
trx,
);
});
}
async addPagePermissions(
dto: AddPagePermissionDto,
authUser: User,
workspaceId: string,
): Promise<void> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.validateWriteAccess(page, authUser);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
dto.pageId,
);
if (!pageAccess) {
throw new BadRequestException(
'Page is not restricted. Restrict the page first.',
);
}
let validUsers = [];
let validGroups = [];
if (dto.userIds && dto.userIds.length > 0) {
validUsers = await this.db
.selectFrom('users')
.select(['id'])
.where('id', 'in', dto.userIds)
.where('workspaceId', '=', workspaceId)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pagePermissions')
.select('id')
.whereRef('pagePermissions.userId', '=', 'users.id')
.where('pagePermissions.pageAccessId', '=', pageAccess.id),
),
),
)
.execute();
}
if (dto.groupIds && dto.groupIds.length > 0) {
validGroups = await this.db
.selectFrom('groups')
.select(['id'])
.where('id', 'in', dto.groupIds)
.where('workspaceId', '=', workspaceId)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pagePermissions')
.select('id')
.whereRef('pagePermissions.groupId', '=', 'groups.id')
.where('pagePermissions.pageAccessId', '=', pageAccess.id),
),
),
)
.execute();
}
const permissionsToAdd = [];
for (const user of validUsers) {
permissionsToAdd.push({
pageAccessId: pageAccess.id,
userId: user.id,
role: dto.role,
addedById: authUser.id,
});
}
for (const group of validGroups) {
permissionsToAdd.push({
pageAccessId: pageAccess.id,
groupId: group.id,
role: dto.role,
addedById: authUser.id,
});
}
if (permissionsToAdd.length > 0) {
await this.pagePermissionRepo.insertPagePermissions(permissionsToAdd);
}
}
async removePagePermission(
dto: RemovePagePermissionDto,
authUser: User,
): Promise<void> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.validateWriteAccess(page, authUser);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
dto.pageId,
);
if (!pageAccess) {
throw new BadRequestException('Page is not restricted');
}
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('Please provide a userId or groupId');
}
if (dto.userId) {
const permission = await this.pagePermissionRepo.findPagePermissionByUserId(
pageAccess.id,
dto.userId,
);
if (!permission) {
throw new NotFoundException('Permission not found');
}
if (permission.role === PagePermissionRole.WRITER) {
await this.validateLastWriter(pageAccess.id);
}
await this.pagePermissionRepo.deletePagePermissionByUserId(
pageAccess.id,
dto.userId,
);
} else if (dto.groupId) {
const permission =
await this.pagePermissionRepo.findPagePermissionByGroupId(
pageAccess.id,
dto.groupId,
);
if (!permission) {
throw new NotFoundException('Permission not found');
}
if (permission.role === PagePermissionRole.WRITER) {
await this.validateLastWriter(pageAccess.id);
}
await this.pagePermissionRepo.deletePagePermissionByGroupId(
pageAccess.id,
dto.groupId,
);
}
}
async updatePagePermissionRole(
dto: UpdatePagePermissionRoleDto,
authUser: User,
): Promise<void> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.validateWriteAccess(page, authUser);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
dto.pageId,
);
if (!pageAccess) {
throw new BadRequestException('Page is not restricted');
}
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('Please provide a userId or groupId');
}
if (dto.userId) {
const permission =
await this.pagePermissionRepo.findPagePermissionByUserId(
pageAccess.id,
dto.userId,
);
if (!permission) {
throw new NotFoundException('Permission not found');
}
if (permission.role === dto.role) {
return;
}
if (permission.role === PagePermissionRole.WRITER) {
await this.validateLastWriter(pageAccess.id);
}
await this.pagePermissionRepo.updatePagePermissionRole(
pageAccess.id,
dto.role,
{ userId: dto.userId },
);
} else if (dto.groupId) {
const permission =
await this.pagePermissionRepo.findPagePermissionByGroupId(
pageAccess.id,
dto.groupId,
);
if (!permission) {
throw new NotFoundException('Permission not found');
}
if (permission.role === dto.role) {
return;
}
if (permission.role === PagePermissionRole.WRITER) {
await this.validateLastWriter(pageAccess.id);
}
await this.pagePermissionRepo.updatePagePermissionRole(
pageAccess.id,
dto.role,
{ groupId: dto.groupId },
);
}
}
async removePageRestriction(pageId: string, authUser: User): Promise<void> {
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.validateWriteAccess(page, authUser);
const pageAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
if (!pageAccess) {
throw new BadRequestException('Page is not restricted');
}
await this.pagePermissionRepo.deletePageAccess(pageId);
}
async getPagePermissions(
pageId: string,
authUser: User,
pagination: PaginationOptions,
) {
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(authUser, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const pageAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
if (!pageAccess) {
return {
items: [],
pagination: {
page: 1,
perPage: pagination.limit,
totalItems: 0,
totalPages: 0,
hasNextPage: false,
hasPrevPage: false,
},
};
}
return this.pagePermissionRepo.getPagePermissionsPaginated(
pageAccess.id,
pagination,
);
}
async validateLastWriter(pageAccessId: string): Promise<void> {
const writerCount =
await this.pagePermissionRepo.countWritersByPageAccessId(pageAccessId);
if (writerCount <= 1) {
throw new BadRequestException(
'There must be at least one user with "Can edit" permission',
);
}
}
/**
* Check if user has writer permission on ALL restricted ancestors of a page.
* Used for permission management operations.
*/
async hasWritePermission(userId: string, pageId: string): Promise<boolean> {
const hasRestriction =
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!hasRestriction) {
return false; // no restrictions, defer to space permissions
}
return this.pagePermissionRepo.canUserEditPage(userId, pageId);
}
async hasPageAccess(pageId: string): Promise<boolean> {
const pageAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
return !!pageAccess;
}
async validateWriteAccess(page: Page, user: User): Promise<void> {
const hasWritePermission = await this.hasWritePermission(user.id, page.id);
if (hasWritePermission) {
return;
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
/**
* Check if user can view a page.
* User must have permission (reader or writer) on EVERY restricted ancestor.
* Returns true if:
* - No ancestors are restricted (defer to space permission)
* - User has permission on all restricted ancestors
*/
async canViewPage(userId: string, pageId: string): Promise<boolean> {
return this.pagePermissionRepo.canUserAccessPage(userId, pageId);
}
/**
* Check if user can edit a page.
* User must have WRITER permission on EVERY restricted ancestor.
* Returns true if:
* - No ancestors are restricted (defer to space permission)
* - User has writer permission on all restricted ancestors
*/
async canEditPage(userId: string, pageId: string): Promise<boolean> {
return this.pagePermissionRepo.canUserEditPage(userId, pageId);
}
/**
* Filter page IDs to only those the user can access.
*/
async filterAccessiblePages(
pageIds: string[],
userId: string,
): Promise<string[]> {
const results =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
return results.map((r) => r.id);
}
}
@@ -7,7 +7,6 @@ import {
import { CreatePageDto } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
@@ -48,7 +47,6 @@ export class PageService {
constructor(
private pageRepo: PageRepo,
private pagePermissionRepo: PagePermissionRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@@ -57,61 +55,6 @@ export class PageService {
private eventEmitter: EventEmitter2,
) {}
/**
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
* A page is included only if:
* 1. The user has access to it
* 2. Its parent is also included (or it's the root page)
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
*/
private async filterAccessibleTreePages<T extends { id: string; parentPageId: string | null }>(
pages: T[],
rootPageId: string,
userId: string,
): Promise<T[]> {
if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
// Build a map for quick lookup
const pageMap = new Map(pages.map((p) => [p.id, p]));
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
// Process pages in a way that ensures parents are processed before children
// We do this by iterating until no more pages can be added
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is already included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
async findById(
pageId: string,
includeContent?: boolean,
@@ -224,7 +167,7 @@ export class PageService {
page.id,
);
return this.pageRepo.findById(page.id, {
return await this.pageRepo.findById(page.id, {
includeSpace: true,
includeContent: true,
includeCreator: true,
@@ -237,7 +180,6 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
userId?: string,
): Promise<any> {
let query = this.db
.selectFrom('pages')
@@ -263,83 +205,16 @@ export class PageService {
query = query.where('parentPageId', 'is', null);
}
const result = await executeWithPagination(query, {
const result = executeWithPagination(query, {
page: pagination.page,
perPage: 250,
});
if (userId && result.items.length > 0) {
const pageIds = result.items.map((p: any) => p.id);
// Single query to get accessible pages with their edit permissions
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const permissionMap = new Map(
accessiblePages.map((p) => [p.id, p.canEdit]),
);
// Filter and add canEdit flag in one pass
result.items = result.items
.filter((p: any) => permissionMap.has(p.id))
.map((p: any) => ({
...p,
canEdit: permissionMap.get(p.id),
}));
// For pages with hasChildren: true, verify they have accessible children
const pagesWithChildren = result.items.filter((p: any) => p.hasChildren);
if (pagesWithChildren.length > 0) {
const parentIds = pagesWithChildren.map((p: any) => p.id);
const parentsWithAccessibleChildren =
await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
parentIds,
userId,
);
const hasAccessibleChildrenSet = new Set(parentsWithAccessibleChildren);
result.items = result.items.map((p: any) => ({
...p,
hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
}));
}
}
return result;
}
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
// Filter to only accessible pages while maintaining tree integrity
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
userId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) => !accessibleIds.has(p.id) && p.parentPageId && accessibleIds.has(p.parentPageId),
);
async movePageToSpace(rootPage: Page, spaceId: string) {
await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition(rootPage.spaceId, null);
await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition },
page.id,
trx,
);
}
// Update root page
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
@@ -347,50 +222,44 @@ export class PageService {
rootPage.id,
trx,
);
const pageIdsToMove = accessiblePages.map((p) => p.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id, { includeContent: false })
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
// Update sub pages
await this.pageRepo.updatePages(
{ spaceId },
pageIdsToMove.filter((id) => id !== rootPage.id),
pageIds.filter((id) => id !== rootPage.id),
trx,
);
}
if (pageIdsToMove.length > 0) {
// Clear page-level permissions - moved pages inherit destination space permissions
// (page_permissions cascade deletes via foreign key)
await trx
.deleteFrom('pageAccess')
.where('pageId', 'in', pageIdsToMove)
.execute();
if (pageIds.length > 0) {
// update spaceId in shares
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIdsToMove)
.where('pageId', 'in', pageIds)
.execute();
// Update comments
await trx
.updateTable('comments')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIdsToMove)
.where('pageId', 'in', pageIds)
.execute();
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
pageIdsToMove,
pageIds,
trx,
);
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIdsToMove,
workspaceId: rootPage.workspaceId,
pageId: pageIds,
workspaceId: rootPage.workspaceId
});
}
});
@@ -415,17 +284,10 @@ export class PageService {
nextPosition = await this.nextPagePosition(spaceId);
}
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true,
});
// Filter to only accessible pages while maintaining tree integrity
const pages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
authUser.id,
);
const pageMap = new Map<string, CopyPageMapEntry>();
pages.forEach((page) => {
pageMap.set(page.id, {
@@ -525,14 +387,9 @@ export class PageService {
workspaceId: page.workspaceId,
creatorId: authUser.id,
lastUpdatedById: authUser.id,
parentPageId:
page.id === rootPage.id
? isDuplicateInSameSpace
? rootPage.parentPageId
: null
: page.parentPageId
? pageMap.get(page.parentPageId)?.newPageId
: null,
parentPageId: page.id === rootPage.id
? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
};
}),
);
@@ -711,43 +568,16 @@ export class PageService {
async getRecentSpacePages(
spaceId: string,
userId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
const result = await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
}
async getRecentPages(
userId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
const result = await this.pageRepo.getRecentPages(userId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
return await this.pageRepo.getRecentPages(userId, pagination);
}
async getDeletedSpacePages(
+9 -38
View File
@@ -7,7 +7,6 @@ import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@@ -19,7 +18,6 @@ export class SearchService {
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
) {}
async searchPage(
@@ -76,16 +74,13 @@ export class SearchService {
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length > 0) {
queryResults = queryResults
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
queryResults = queryResults
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
@@ -120,22 +115,10 @@ export class SearchService {
}
//@ts-ignore
let results: any[] = await queryResults.execute();
// Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
opts.userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
results = results.filter((r: any) => accessibleSet.has(r.id));
}
queryResults = await queryResults.execute();
//@ts-ignore
const searchResults = results.map((result: SearchResponseDto) => {
const searchResults = queryResults.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
@@ -224,18 +207,6 @@ export class SearchService {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute();
}
// Filter by page-level permissions
if (pages.length > 0) {
const pageIds = pages.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
return { users, groups, pages };
+9 -46
View File
@@ -26,8 +26,6 @@ import {
UpdateShareDto,
} from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageAccessService } from '../page-access/page-access.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@@ -43,8 +41,6 @@ export class ShareController {
private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService,
) {}
@@ -100,7 +96,6 @@ export class ShareController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
// TODO: look into permission
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Shared page not found');
@@ -127,21 +122,9 @@ export class ShareController {
throw new NotFoundException('Page not found');
}
// User must be able to edit the page to create a share
await this.pageAccessService.validateCanEdit(page, user);
// Block includeSubPages if user cannot access all descendants
if (createShareDto.includeSubPages) {
const hasInaccessible =
await this.pagePermissionRepo.hasInaccessibleDescendants(
page.id,
user.id,
);
if (hasInaccessible) {
throw new BadRequestException(
'Cannot share subpages: restricted pages found',
);
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
return this.shareService.createShare({
@@ -161,26 +144,9 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const page = await this.pageRepo.findById(share.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// User must be able to edit the page to update its share
await this.pageAccessService.validateCanEdit(page, user);
// Block includeSubPages if user cannot access all descendants
if (updateShareDto.includeSubPages) {
const hasInaccessible =
await this.pagePermissionRepo.hasInaccessibleDescendants(
page.id,
user.id,
);
if (hasInaccessible) {
throw new BadRequestException(
'Cannot share subpages: restricted pages found',
);
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
return this.shareService.updateShare(share.id, updateShareDto);
@@ -195,14 +161,11 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const page = await this.pageRepo.findById(share.pageId);
if (!page) {
throw new NotFoundException('Page not found');
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
// User must be able to edit the page to delete its share
await this.pageAccessService.validateCanEdit(page, user);
await this.shareRepo.deleteShare(share.id);
}
+53 -162
View File
@@ -19,7 +19,6 @@ import {
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid';
@@ -32,7 +31,6 @@ export class ShareService {
constructor(
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
) {}
@@ -44,114 +42,16 @@ export class ShareService {
}
if (share.includeSubPages) {
const allPages = await this.pageRepo.getPageAndDescendants(share.pageId, {
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
includeContent: false,
});
// Filter out restricted pages and maintain tree integrity
const filteredPages = await this.filterPublicPages(allPages, share.pageId);
return { share, pageTree: filteredPages };
return { share, pageTree: pageList };
} else {
return { share, pageTree: [] };
}
}
/**
* Filter pages for public share - exclude restricted pages.
* A page is included only if:
* 1. It has no page_access restriction AND
* 2. Its parent is also included (or it's the root)
*/
private async filterPublicPages<
T extends { id: string; parentPageId: string | null },
>(pages: T[], rootPageId: string): Promise<T[]> {
if (pages.length === 0) return [];
// Get all restricted page IDs
const restrictedIds =
await this.pagePermissionRepo.getRestrictedDescendantIds(rootPageId);
const restrictedSet = new Set(restrictedIds);
// Include pages that are NOT restricted and have valid parent chain
const includedIds = new Set<string>();
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (restrictedSet.has(page.id)) continue;
// Root page: include if not restricted
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
/**
* Check if a specific page is accessible within a public share.
* A page is accessible if no page in its ancestor chain
* (from the page up to and including the share root) has a page_access restriction.
*/
private async isPagePubliclyAccessible(
pageId: string,
shareRootPageId: string,
): Promise<boolean> {
if (pageId === shareRootPageId) {
const hasRestriction = await this.db
.selectFrom('pageAccess')
.select('id')
.where('pageId', '=', pageId)
.executeTakeFirst();
return !hasRestriction;
}
// Get the depth from share root to the requested page
const shareToPage = await this.db
.selectFrom('pageHierarchy')
.select('depth')
.where('ancestorId', '=', shareRootPageId)
.where('descendantId', '=', pageId)
.executeTakeFirst();
if (!shareToPage) {
return false;
}
// Get all ancestor IDs in the chain from pageId to shareRootPageId
const chainPageIds = await this.db
.selectFrom('pageHierarchy')
.select('ancestorId')
.where('descendantId', '=', pageId)
.where('depth', '<=', shareToPage.depth)
.where('depth', '>', 0)
.execute();
const idsToCheck = [pageId, ...chainPageIds.map((c) => c.ancestorId)];
// Check if any page in the chain has a restriction
const hasRestricted = await this.db
.selectFrom('pageAccess')
.select('pageId')
.where('pageId', 'in', idsToCheck)
.executeTakeFirst();
return !hasRestricted;
}
async createShare(opts: {
authUserId: string;
workspaceId: string;
@@ -203,17 +103,6 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
// For descendant pages, verify the ancestor chain has no restrictions
if (share.level > 0) {
const isAccessible = await this.isPagePubliclyAccessible(
dto.pageId,
share.pageId,
);
if (!isAccessible) {
throw new NotFoundException('Shared page not found');
}
}
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
includeCreator: true,
@@ -234,80 +123,82 @@ export class ShareService {
.withRecursive('page_hierarchy', (cte) =>
cte
.selectFrom('pages')
.leftJoin('shares', 'shares.pageId', 'pages.id')
.select([
'id',
'slugId',
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'parentPageId',
'pages.parentPageId',
sql`0`.as('level'),
'shares.id as shareId',
'shares.key as shareKey',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
])
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((union) =>
union
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
// Increase the level by 1 for each ancestor.
sql`ph.level + 1`.as('level'),
])
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
.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',
])
.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')
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
.select([
'page_hierarchy.id as sharedPageId',
'page_hierarchy.slugId as sharedPageSlugId',
'page_hierarchy.title as sharedPageTitle',
'page_hierarchy.icon as sharedPageIcon',
'page_hierarchy.level as level',
'shares.id',
'shares.key',
'shares.pageId',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
'shares.updatedAt',
])
.where('shares.id', 'is not', null)
.orderBy('page_hierarchy.level', 'asc')
.selectAll()
.where('shareId', 'is not', null)
.limit(1)
.executeTakeFirst();
if (!share || share.workspaceId != workspaceId) {
if (!share || share.workspaceId !== workspaceId) {
return undefined;
}
if (share.level === 1 && !share.includeSubPages) {
// we can only show a page if its shared ancestor permits it
if ((share.level as number) > 0 && !share.includeSubPages) {
return undefined;
}
return {
id: share.id,
key: share.key,
id: share.shareId,
key: share.shareKey,
includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing,
pageId: share.pageId,
pageId: share.id,
creatorId: share.creatorId,
spaceId: share.spaceId,
workspaceId: share.workspaceId,
createdAt: share.createdAt,
level: share.level,
sharedPage: {
id: share.sharedPageId,
slugId: share.sharedPageSlugId,
title: share.sharedPageTitle,
icon: share.sharedPageIcon,
id: share.id,
slugId: share.slugId,
title: share.title,
icon: share.icon,
},
};
}
@@ -9,7 +9,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateSpaceDto {
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -19,7 +19,7 @@ export class CreateSpaceDto {
description?: string;
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsAlphanumeric()
slug: string;
}
@@ -16,7 +16,6 @@ import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageRepo } from './repos/page/page.repo';
import { PagePermissionRepo } from './repos/page/page-permission.repo';
import { CommentRepo } from './repos/comment/comment.repo';
import { PageHistoryRepo } from './repos/page/page-history.repo';
import { AttachmentRepo } from './repos/attachment/attachment.repo';
@@ -72,7 +71,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -89,7 +87,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -1,200 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_hierarchy')
.ifNotExists()
.addColumn('ancestor_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('descendant_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('depth', 'integer', (col) => col.notNull().defaultTo(0))
.addPrimaryKeyConstraint('page_hierarchy_pkey', [
'ancestor_id',
'descendant_id',
])
.execute();
// indexes
await db.schema
.createIndex('idx_page_hierarchy_descendant')
.ifNotExists()
.on('page_hierarchy')
.column('descendant_id')
.execute();
await db.schema
.createIndex('idx_page_hierarchy_ancestor_depth')
.ifNotExists()
.on('page_hierarchy')
.columns(['ancestor_id', 'depth'])
.execute();
await db.schema
.createIndex('idx_page_hierarchy_descendant_depth')
.ifNotExists()
.on('page_hierarchy')
.columns(['descendant_id', 'depth'])
.execute();
// rebuild function
await sql`
CREATE OR REPLACE FUNCTION rebuild_page_hierarchy()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
TRUNCATE page_hierarchy;
WITH RECURSIVE page_tree AS (
SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth
FROM pages WHERE deleted_at IS NULL
UNION ALL
SELECT pt.ancestor_id, p.id AS descendant_id, pt.depth + 1
FROM page_tree pt
JOIN pages p ON p.parent_page_id = pt.descendant_id
WHERE p.deleted_at IS NULL
)
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, descendant_id, depth FROM page_tree;
END;
$$;
`.execute(db);
// Create insert trigger function
await sql`
CREATE OR REPLACE FUNCTION page_hierarchy_after_insert()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.deleted_at IS NOT NULL THEN
RETURN NEW;
END IF;
IF NEW.parent_page_id IS NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
VALUES (NEW.id, NEW.id, 0);
ELSE
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, NEW.id, depth + 1
FROM page_hierarchy
WHERE descendant_id = NEW.parent_page_id
UNION ALL
SELECT NEW.id, NEW.id, 0;
END IF;
RETURN NEW;
END;
$$;
`.execute(db);
await sql`
CREATE OR REPLACE TRIGGER page_hierarchy_after_insert_trigger
AFTER INSERT ON pages
FOR EACH ROW
EXECUTE FUNCTION page_hierarchy_after_insert();
`.execute(db);
// Create update trigger function
await sql`
CREATE OR REPLACE FUNCTION page_hierarchy_after_update()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
subtree_ids UUID[];
BEGIN
-- Only process if parent_page_id or deleted_at changed
IF OLD.parent_page_id IS NOT DISTINCT FROM NEW.parent_page_id
AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN
RETURN NEW;
END IF;
-- Handle soft-delete: remove from closure when deleted_at is set
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
SELECT array_agg(descendant_id) INTO subtree_ids
FROM page_hierarchy
WHERE ancestor_id = NEW.id;
DELETE FROM page_hierarchy
WHERE descendant_id = ANY(subtree_ids);
RETURN NEW;
END IF;
-- Handle restore: rebuild closure when deleted_at is cleared
IF OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL THEN
IF NEW.parent_page_id IS NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
VALUES (NEW.id, NEW.id, 0);
ELSE
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, NEW.id, depth + 1
FROM page_hierarchy
WHERE descendant_id = NEW.parent_page_id
UNION ALL
SELECT NEW.id, NEW.id, 0;
END IF;
RETURN NEW;
END IF;
-- Skip if page is soft-deleted
IF NEW.deleted_at IS NOT NULL THEN
RETURN NEW;
END IF;
-- Move operation: parent changed
-- Get all descendants of the moved page (including itself)
SELECT array_agg(descendant_id) INTO subtree_ids
FROM page_hierarchy
WHERE ancestor_id = NEW.id;
-- Delete old ancestor relationships (keep internal subtree links)
DELETE FROM page_hierarchy
WHERE descendant_id = ANY(subtree_ids)
AND NOT (ancestor_id = ANY(subtree_ids));
-- Insert new ancestor relationships (if new parent exists)
IF NEW.parent_page_id IS NOT NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT
new_anc.ancestor_id,
sub.descendant_id,
new_anc.depth + sub.depth + 1
FROM page_hierarchy new_anc
CROSS JOIN page_hierarchy sub
WHERE new_anc.descendant_id = NEW.parent_page_id
AND sub.ancestor_id = NEW.id
AND sub.descendant_id = ANY(subtree_ids);
END IF;
RETURN NEW;
END;
$$;
`.execute(db);
await sql`
CREATE OR REPLACE TRIGGER page_hierarchy_after_update_trigger
AFTER UPDATE ON pages
FOR EACH ROW
EXECUTE FUNCTION page_hierarchy_after_update();
`.execute(db);
await sql`SELECT rebuild_page_hierarchy()`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER IF EXISTS page_hierarchy_after_update_trigger ON pages`.execute(
db,
);
await sql`DROP TRIGGER IF EXISTS page_hierarchy_after_insert_trigger ON pages`.execute(
db,
);
await sql`DROP FUNCTION IF EXISTS page_hierarchy_after_update()`.execute(db);
await sql`DROP FUNCTION IF EXISTS page_hierarchy_after_insert()`.execute(db);
await sql`DROP FUNCTION IF EXISTS rebuild_page_hierarchy()`.execute(db);
await db.schema.dropTable('page_hierarchy').ifExists().execute();
}
@@ -1,93 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_access')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().unique().references('pages.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('access_level', 'varchar', (col) => col.notNull())
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createTable('page_permissions')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_access_id', 'uuid', (col) =>
col.notNull().references('page_access.id').onDelete('cascade'),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade'),
)
.addColumn('group_id', 'uuid', (col) =>
col.references('groups.id').onDelete('cascade'),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('page_access_user_unique', [
'page_access_id',
'user_id',
])
.addUniqueConstraint('page_access_group_unique', [
'page_access_id',
'group_id',
])
.addCheckConstraint(
'allow_either_user_id_or_group_id_check',
sql`((user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL))`,
)
.execute();
await db.schema
.createIndex('idx_page_access_workspace')
.on('page_access')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_page_access')
.on('page_permissions')
.column('page_access_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_user')
.on('page_permissions')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_group')
.on('page_permissions')
.column('group_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_permissions').ifExists().execute();
await db.schema.dropTable('page_access').ifExists().execute();
}
@@ -152,14 +152,4 @@ export class GroupUserRepo {
.where('groupId', '=', groupId)
.execute();
}
async getUserGroupIds(userId: string): Promise<string[]> {
const results = await this.db
.selectFrom('groupUsers')
.select('groupId')
.where('userId', '=', userId)
.execute();
return results.map((r) => r.groupId);
}
}
@@ -1,692 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertablePageAccess,
InsertablePagePermission,
PageAccess,
PagePermission,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { sql } from 'kysely';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
@Injectable()
export class PagePermissionRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
private readonly groupUserRepo: GroupUserRepo,
) {}
async findPageAccessByPageId(
pageId: string,
trx?: KyselyTransaction,
): Promise<PageAccess | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pageAccess')
.selectAll()
.where('pageId', '=', pageId)
.executeTakeFirst();
}
async insertPageAccess(
data: InsertablePageAccess,
trx?: KyselyTransaction,
): Promise<PageAccess> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('pageAccess')
.values(data)
.returningAll()
.executeTakeFirst();
}
async deletePageAccess(pageId: string, trx?: KyselyTransaction): Promise<void> {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('pageAccess').where('pageId', '=', pageId).execute();
}
async insertPagePermissions(
permissions: InsertablePagePermission[],
trx?: KyselyTransaction,
): Promise<void> {
if (permissions.length === 0) return;
const db = dbOrTx(this.db, trx);
await db
.insertInto('pagePermissions')
.values(permissions)
.execute();
}
async findPagePermissionByUserId(
pageAccessId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<PagePermission | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pagePermissions')
.selectAll()
.where('pageAccessId', '=', pageAccessId)
.where('userId', '=', userId)
.executeTakeFirst();
}
async findPagePermissionByGroupId(
pageAccessId: string,
groupId: string,
trx?: KyselyTransaction,
): Promise<PagePermission | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pagePermissions')
.selectAll()
.where('pageAccessId', '=', pageAccessId)
.where('groupId', '=', groupId)
.executeTakeFirst();
}
async deletePagePermissionByUserId(
pageAccessId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pagePermissions')
.where('pageAccessId', '=', pageAccessId)
.where('userId', '=', userId)
.execute();
}
async deletePagePermissionByGroupId(
pageAccessId: string,
groupId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pagePermissions')
.where('pageAccessId', '=', pageAccessId)
.where('groupId', '=', groupId)
.execute();
}
async updatePagePermissionRole(
pageAccessId: string,
role: string,
opts: { userId?: string; groupId?: string },
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
let query = db
.updateTable('pagePermissions')
.set({ role, updatedAt: new Date() })
.where('pageAccessId', '=', pageAccessId);
if (opts.userId) {
query = query.where('userId', '=', opts.userId);
} else if (opts.groupId) {
query = query.where('groupId', '=', opts.groupId);
}
await query.execute();
}
async countWritersByPageAccessId(
pageAccessId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('pagePermissions')
.select((eb) => eb.fn.count('id').as('count'))
.where('pageAccessId', '=', pageAccessId)
.where('role', '=', 'writer')
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async getPagePermissionsPaginated(
pageAccessId: string,
pagination: PaginationOptions,
) {
let query = this.db
.selectFrom('pagePermissions')
.leftJoin('users', 'users.id', 'pagePermissions.userId')
.leftJoin('groups', 'groups.id', 'pagePermissions.groupId')
.select([
'pagePermissions.id',
'pagePermissions.role',
'pagePermissions.createdAt',
'users.id as userId',
'users.name as userName',
'users.avatarUrl as userAvatarUrl',
'users.email as userEmail',
'groups.id as groupId',
'groups.name as groupName',
'groups.isDefault as groupIsDefault',
])
.select((eb) => this.groupRepo.withMemberCount(eb))
.where('pageAccessId', '=', pageAccessId)
.orderBy((eb) => eb('groups.id', 'is not', null), 'desc')
.orderBy('pagePermissions.createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb(
sql`f_unaccent(users.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
)
.or(
sql`users.email`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
)
.or(
sql`f_unaccent(groups.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
const result = await executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
const members = result.items.map((member) => {
if (member.userId) {
return {
id: member.userId,
name: member.userName,
email: member.userEmail,
avatarUrl: member.userAvatarUrl,
type: 'user' as const,
role: member.role,
createdAt: member.createdAt,
};
} else {
return {
id: member.groupId,
name: member.groupName,
memberCount: member.memberCount as number,
isDefault: member.groupIsDefault,
type: 'group' as const,
role: member.role,
createdAt: member.createdAt,
};
}
});
result.items = members as any;
return result;
}
async getUserPagePermission(
userId: string,
pageId: string,
): Promise<{ role: string } | undefined> {
const result = await this.db
.selectFrom('pageAccess')
.innerJoin('pagePermissions', 'pagePermissions.pageAccessId', 'pageAccess.id')
.select(['pagePermissions.role'])
.where('pageAccess.pageId', '=', pageId)
.where('pagePermissions.userId', '=', userId)
.unionAll(
this.db
.selectFrom('pageAccess')
.innerJoin('pagePermissions', 'pagePermissions.pageAccessId', 'pageAccess.id')
.innerJoin('groupUsers', 'groupUsers.groupId', 'pagePermissions.groupId')
.select(['pagePermissions.role'])
.where('pageAccess.pageId', '=', pageId)
.where('groupUsers.userId', '=', userId),
)
.executeTakeFirst();
return result;
}
async findRestrictedAncestor(
pageId: string,
): Promise<{ pageId: string; accessLevel: string; depth: number } | undefined> {
return this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.select([
'pageAccess.pageId',
'pageAccess.accessLevel',
'pageHierarchy.depth',
])
.where('pageHierarchy.descendantId', '=', pageId)
.orderBy('pageHierarchy.depth', 'asc')
.executeTakeFirst();
}
/**
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageHierarchy.descendantId', '=', pageId)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
}
/**
* Check if user can edit a page by verifying they have WRITER permission on ALL restricted ancestors.
*/
async canUserEditPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageHierarchy.descendantId', '=', pageId)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
}
/**
* Get user's access level for a page, checking ALL restricted ancestors.
* Returns:
* - hasRestriction: whether page or any ancestor has restrictions
* - canAccess: user has permission on all restricted ancestors (always true if no restrictions)
* - canEdit: user has writer permission on all restricted ancestors (always true if no restrictions)
*/
async getUserPageAccessLevel(
userId: string,
pageId: string,
): Promise<{ hasRestriction: boolean; canAccess: boolean; canEdit: boolean }> {
const result = await this.db
.selectFrom('pages')
.select((eb) => [
// hasRestriction: any ancestor has page_access entry
eb
.case()
.when(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.select('pageAccess.id')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id'),
),
)
.then(true)
.else(false)
.end()
.as('hasRestriction'),
// canAccess: no restricted ancestor without ANY permission
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canAccess'),
// canEdit: no restricted ancestor without WRITER permission
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canEdit'),
])
.where('pages.id', '=', pageId)
.executeTakeFirst();
return {
hasRestriction: Boolean(result?.hasRestriction),
canAccess: Boolean(result?.canAccess),
canEdit: Boolean(result?.canEdit),
};
}
/**
* Filter a list of page IDs to only those the user can access.
* Returns page IDs with their permission level (canEdit).
* Single query implementation for efficiency.
*/
async filterAccessiblePageIdsWithPermissions(
pageIds: string[],
userId: string,
): Promise<Array<{ id: string; canEdit: boolean }>> {
if (pageIds.length === 0) return [];
const results = await this.db
.selectFrom('pages')
.select('pages.id')
// Check if user lacks writer permission on any restricted ancestor
.select((eb) =>
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canEdit'),
)
.where('pages.id', 'in', pageIds)
// Filter: user must have access (any permission on all restricted ancestors)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.execute();
return results.map((r) => ({ id: r.id, canEdit: Boolean(r.canEdit) }));
}
/**
* Check if a page or any of its ancestors has restrictions.
* Used to determine if page-level permission checks are needed.
*/
async hasRestrictedAncestor(pageId: string): Promise<boolean> {
const result = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.select('pageAccess.id')
.where('pageHierarchy.descendantId', '=', pageId)
.executeTakeFirst();
return !!result;
}
/**
* Given a list of parent page IDs, return which ones have at least one accessible child.
* Efficient batch query for sidebar hasChildren calculation.
*/
async getParentIdsWithAccessibleChildren(
parentIds: string[],
userId: string,
): Promise<string[]> {
if (parentIds.length === 0) return [];
const results = await this.db
.selectFrom('pages as child')
.select('child.parentPageId')
.distinct()
.where('child.parentPageId', 'in', parentIds)
.where('child.deletedAt', 'is', null)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'child.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.execute();
return results.map((r) => r.parentPageId);
}
/**
* Check if any descendant of a page has restrictions that the user cannot access.
* Used to determine if includeSubPages can be enabled for sharing.
*/
async hasInaccessibleDescendants(
pageId: string,
userId: string,
): Promise<boolean> {
// Get all descendant page IDs (excluding the root page itself)
const descendants = await this.db
.selectFrom('pageHierarchy')
.select('descendantId')
.where('ancestorId', '=', pageId)
.where('depth', '>', 0)
.execute();
if (descendants.length === 0) {
return false;
}
const descendantIds = descendants.map((d) => d.descendantId);
// Check if any descendant has a restriction the user cannot access
const inaccessible = await this.db
.selectFrom('pageAccess')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageAccess.pageId', 'in', descendantIds)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !!inaccessible;
}
/**
* Get all descendant page IDs that have restrictions (page_access entries).
* Used to filter restricted pages from public share trees.
*/
async getRestrictedDescendantIds(pageId: string): Promise<string[]> {
const results = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.descendantId')
.select('pageHierarchy.descendantId')
.where('pageHierarchy.ancestorId', '=', pageId)
.execute();
return results.map((r) => r.descendantId);
}
}
@@ -293,24 +293,18 @@ export class PageRepo {
}
async getRecentPages(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', 'in', userSpaceIds)
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
@@ -137,25 +137,19 @@ export class ShareRepo {
}
async getShares(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('shares')
.select(this.baseFields)
.select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', userSpaceIds)
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
@@ -209,34 +209,33 @@ export class SpaceMemberRepo {
return roles;
}
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.db
getUserSpaceIdsQuery(userId: string) {
return this.db
.selectFrom('spaceMembers')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id'])
.select('spaces.id')
.where('userId', '=', userId)
.union(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id'])
.select('spaces.id')
.where('groupUsers.userId', '=', userId),
)
.execute();
);
}
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.getUserSpaceIdsQuery(userId).execute();
return membership.map((space) => space.id);
}
async getUserSpaces(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.getUserSpaceIds(userId);
let query = this.db
.selectFrom('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
//.where('workspaceId', '=', workspaceId)
.where('id', 'in', userSpaceIds)
.where('id', 'in', this.getUserSpaceIdsQuery(userId))
.orderBy('createdAt', 'asc');
if (pagination.query) {
@@ -253,14 +252,9 @@ export class SpaceMemberRepo {
);
}
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
}
-30
View File
@@ -197,12 +197,6 @@ export interface GroupUsers {
userId: string;
}
export interface PageHierarchy {
ancestorId: string;
descendantId: string;
depth: Generated<number>;
}
export interface PageHistory {
content: Json | null;
coverPhoto: string | null;
@@ -366,27 +360,6 @@ export interface Workspaces {
updatedAt: Generated<Timestamp>;
}
export interface PageAccess {
id: Generated<string>;
pageId: string;
workspaceId: string;
accessLevel: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
userId: string | null;
groupId: string | null;
role: string;
addedById: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -398,10 +371,7 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
pageAccess: PageAccess;
pageHierarchy: PageHierarchy;
pageHistory: PageHistory;
pagePermissions: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
@@ -9,10 +9,7 @@ import {
FileTasks,
Groups,
GroupUsers,
PageAccess,
PageHierarchy,
PageHistory,
PagePermissions,
Pages,
Shares,
SpaceMembers,
@@ -35,11 +32,8 @@ export interface DbInterface {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
pageAccess: PageAccess;
pageHierarchy: PageHierarchy;
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pagePermissions: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
@@ -3,9 +3,6 @@ import {
Attachments,
Comments,
Groups,
PageAccess as _PageAccess,
PageHierarchy as _PageHierarchy,
PagePermissions as _PagePermissions,
Pages,
Spaces,
Users,
@@ -134,17 +131,3 @@ export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
export type UpdatablePageEmbedding = Updateable<Omit<PageEmbeddings, 'id'>>;
// Page Hierarchy (closure table - composite primary key)
export type PageHierarchy = Selectable<_PageHierarchy>;
export type InsertablePageHierarchy = Insertable<_PageHierarchy>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
// Page Permission
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
@@ -105,7 +105,7 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'gemini', 'ollama'])
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@IsString()
AI_DRIVER: string;
@@ -117,11 +117,10 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000'])
@IsIn(['768', '1024', '1536', '2000', '3072'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@@ -129,13 +128,20 @@ export class EnvironmentVariables {
AI_COMPLETION_MODEL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'openai')
@ValidateIf(
(obj) =>
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
)
@IsString()
@IsNotEmpty()
OPENAI_API_KEY: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.OPENAI_API_URL && obj.AI_DRIVER === 'openai')
@ValidateIf(
(obj) =>
obj.AI_DRIVER === 'openai-compatible' ||
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
)
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@@ -10,46 +10,59 @@ import {
} from '@nestjs/common';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User } from '@docmost/db/types/entity.types';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../core/casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../../core/casl/abilities/workspace-ability.factory';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { FileTaskIdDto } from './dto/file-task-dto';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
@Controller('file-tasks')
export class FileTaskController {
constructor(
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceMemberRepo: SpaceMemberRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post()
async getFileTasks(@AuthUser() user: User) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id);
if (!userSpaceIds || userSpaceIds.length === 0) {
return [];
async getFileTasks(
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
const fileTasks = await this.db
const query = this.db
.selectFrom('fileTasks')
.selectAll()
.where('spaceId', 'in', userSpaceIds)
.execute();
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id))
.orderBy('createdAt', 'desc');
if (!fileTasks) {
throw new NotFoundException('File task not found');
}
return fileTasks;
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
}
@UseGuards(JwtAuthGuard)
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { ExcalidrawFollowPayload } from '../types/excalidraw.types';
@Injectable()
export class ExcalidrawCollabService {
// Track socket -> rooms mapping for disconnect handling
// (Socket.IO clears client.rooms before handleDisconnect runs)
private socketRooms = new Map<string, Set<string>>();
async handleJoinRoom(
client: Socket,
server: Server,
roomId: string,
): Promise<void> {
await client.join(roomId);
// Track room membership
if (!this.socketRooms.has(client.id)) {
this.socketRooms.set(client.id, new Set());
}
this.socketRooms.get(client.id).add(roomId);
const sockets = await server.in(roomId).fetchSockets();
if (sockets.length <= 1) {
server.to(client.id).emit('ex-first-in-room');
} else {
client.broadcast.to(roomId).emit('ex-new-user', client.id);
}
server.in(roomId).emit(
'ex-room-user-change',
sockets.map((socket) => socket.id),
);
}
async handleLeaveRoom(
client: Socket,
server: Server,
roomId: string,
): Promise<void> {
await client.leave(roomId);
// Remove from tracking
this.socketRooms.get(client.id)?.delete(roomId);
// Notify remaining users
const sockets = await server.in(roomId).fetchSockets();
if (sockets.length > 0) {
server.in(roomId).emit(
'ex-room-user-change',
sockets.map((socket) => socket.id),
);
}
}
handleServerBroadcast(
client: Socket,
roomId: string,
encryptedData: ArrayBuffer,
iv: Uint8Array,
): void {
client.broadcast.to(roomId).emit('ex-client-broadcast', encryptedData, iv);
}
handleServerVolatileBroadcast(
client: Socket,
roomId: string,
encryptedData: ArrayBuffer,
iv: Uint8Array,
): void {
client.volatile.broadcast
.to(roomId)
.emit('ex-client-broadcast', encryptedData, iv);
}
async handleUserFollow(
client: Socket,
server: Server,
payload: ExcalidrawFollowPayload,
): Promise<void> {
const roomId = `follow@${payload.userToFollow.socketId}`;
if (payload.action === 'FOLLOW') {
await client.join(roomId);
} else {
await client.leave(roomId);
}
const sockets = await server.in(roomId).fetchSockets();
const followedBy = sockets.map((socket) => socket.id);
server.to(payload.userToFollow.socketId).emit(
'ex-user-follow-room-change',
followedBy,
);
}
async handleDisconnecting(client: Socket, server: Server): Promise<void> {
// Use tracked rooms since client.rooms is empty by this point
const rooms = this.socketRooms.get(client.id) || new Set();
for (const roomId of rooms) {
const otherClients = (await server.in(roomId).fetchSockets()).filter(
(socket) => socket.id !== client.id,
);
const isFollowRoom = roomId.startsWith('follow@');
if (!isFollowRoom && otherClients.length > 0) {
server.to(roomId).emit(
'ex-room-user-change',
otherClients.map((socket) => socket.id),
);
}
if (isFollowRoom && otherClients.length === 0) {
const socketId = roomId.replace('follow@', '');
server.to(socketId).emit('ex-broadcast-unfollow');
}
}
// Clean up tracking
this.socketRooms.delete(client.id);
}
}
@@ -0,0 +1,9 @@
export type ExcalidrawUserToFollow = {
socketId: string;
username: string;
};
export type ExcalidrawFollowPayload = {
userToFollow: ExcalidrawUserToFollow;
action: 'FOLLOW' | 'UNFOLLOW';
};
+80 -1
View File
@@ -1,6 +1,8 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
@@ -11,17 +13,23 @@ import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import * as cookie from 'cookie';
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
import { ExcalidrawFollowPayload } from './types/excalidraw.types';
@WebSocketGateway({
cors: { origin: '*' },
transports: ['websocket'],
})
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
export class WsGateway
implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy
{
@WebSocketServer()
server: Server;
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
private excalidrawCollabService: ExcalidrawCollabService,
) {}
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
@@ -41,6 +49,8 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
client.join([workspaceRoom, ...spaceRooms]);
this.server.to(client.id).emit('init-room');
} catch (err) {
client.emit('Unauthorized');
client.disconnect();
@@ -76,6 +86,75 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
client.leave(roomName);
}
// Excalidraw Sync
@SubscribeMessage('ex-join-room')
async handleExJoinRoom(
@ConnectedSocket() client: Socket,
@MessageBody() roomId: string,
): Promise<void> {
await this.excalidrawCollabService.handleJoinRoom(
client,
this.server,
roomId,
);
}
@SubscribeMessage('ex-leave-room')
async handleExLeaveRoom(
@ConnectedSocket() client: Socket,
@MessageBody() roomId: string,
): Promise<void> {
await this.excalidrawCollabService.handleLeaveRoom(
client,
this.server,
roomId,
);
}
@SubscribeMessage('ex-server-broadcast')
handleServerBroadcast(
@ConnectedSocket() client: Socket,
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
): void {
this.excalidrawCollabService.handleServerBroadcast(
client,
roomId,
encryptedData,
iv,
);
}
@SubscribeMessage('ex-server-volatile-broadcast')
handleServerVolatileBroadcast(
@ConnectedSocket() client: Socket,
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
): void {
this.excalidrawCollabService.handleServerVolatileBroadcast(
client,
roomId,
encryptedData,
iv,
);
}
@SubscribeMessage('ex-user-follow')
async handleUserFollow(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ExcalidrawFollowPayload,
): Promise<void> {
await this.excalidrawCollabService.handleUserFollow(
client,
this.server,
payload,
);
}
async handleDisconnect(client: Socket): Promise<void> {
await this.excalidrawCollabService.handleDisconnecting(client, this.server);
}
onModuleDestroy() {
if (this.server) {
this.server.close();
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
import { TokenModule } from '../core/auth/token.module';
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
@Module({
imports: [TokenModule],
providers: [WsGateway],
providers: [WsGateway, ExcalidrawCollabService],
})
export class WsModule {}
+1
View File
@@ -19,6 +19,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "^7.1.0",
"@casl/ability": "^6.7.5",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "^2.15.3",
+744 -644
View File
File diff suppressed because it is too large Load Diff