Compare commits

..

3 Commits

Author SHA1 Message Date
Philipinho 404e6c0b2f fix 2025-12-18 18:32:24 +00:00
Philipinho 900e367677 Merge branch 'main' into permissions-2 2025-12-18 18:30:48 +00:00
Philipinho ace00a0b0a WIP 2025-08-14 08:58:23 -07:00
74 changed files with 3465 additions and 3085 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
node_modules
.git
.gitignore
dist
/data
.env*
.nx
data
+3 -3
View File
@@ -1,14 +1,13 @@
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
@@ -32,11 +31,12 @@ 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
+22 -21
View File
@@ -10,50 +10,51 @@
"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-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",
"@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",
"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.16.8",
"i18next-http-backend": "^2.7.3",
"jotai": "^2.16.2",
"i18next": "^23.14.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.12.5",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.27",
"katex": "0.16.22",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2",
"mermaid": "^11.11.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17",
"react-clear-modal": "^2.0.15",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.7",
"react-drawio": "^1.0.1",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-router-dom": "^7.12.0",
"semver": "^7.7.3",
"socket.io-client": "^4.8.3",
"react-router-dom": "^7.0.1",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"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": "スラッグ(URL識別子)",
"Sign Up": "アカウント登録",
"Slug": "Slug (URL用文字列)",
"Space": "スペース",
"Space description": "スペース説明",
"Space menu": "スペースメニュー",
"Space name": "スペース名",
"Space settings": "スペース設定",
"Space slug": "スペースのスラッグ(URL識別子)",
"Space slug": "スペースの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": "Draw.io 図を挿入・編集します",
"Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"Mermaid diagram": "Mermaidコード",
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
"Insert and design Drawio diagrams": "Drawio図を挿入してデザインします",
"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.": "認証アプリアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"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.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"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.": "認証アプリアクセスできない場合、これらのコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"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.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
"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,5 +1,3 @@
import { ENCRYPTION_KEY_BITS } from "@excalidraw/common";
type LibraryItems = any;
type LibraryPersistedData = {
@@ -10,8 +8,8 @@ export interface LibraryPersistenceAdapter {
load(metadata: { source: "load" | "save" }):
| Promise<{ libraryItems: LibraryItems } | null>
| {
libraryItems: LibraryItems;
}
libraryItems: LibraryItems;
}
| null;
save(libraryData: LibraryPersistedData): Promise<void> | void;
@@ -27,10 +25,7 @@ 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;
},
@@ -45,124 +40,3 @@ 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,14 +8,13 @@ import {
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useState, useCallback } from "react";
import { useState } 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, Gesture } from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
@@ -23,9 +22,8 @@ import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary, LiveCollaborationTrigger } from "@excalidraw/excalidraw";
import { useHandleLibrary } 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) => ({
@@ -48,16 +46,6 @@ 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;
@@ -169,14 +157,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
scrollToContent: true,
}}
theme={computedColorScheme}
onChange={handleChange}
onPointerUpdate={broadcastPointer}
renderTopRightUI={() => (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {}}
/>
)}
/>
</Suspense>
</div>
@@ -1,257 +0,0 @@
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;
@@ -1,266 +0,0 @@
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,21 +1,19 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { Link, useLocation, 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, pageSlug } = useParams();
const { spaceSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
const {
data: page,
isLoading,
@@ -25,20 +23,6 @@ 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,
@@ -61,7 +45,6 @@ export default function MentionView(props: NodeViewProps) {
to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
@@ -1,7 +1,5 @@
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", {
@@ -10,10 +8,7 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
return req.data;
}
export async function getFileTasks(
params?: QueryParams,
): Promise<IPagination<IFileTask>> {
const req = await api.post("/file-tasks", { ...params });
export async function getFileTasks(): Promise<IFileTask[]> {
const req = await api.post<IFileTask[]>("/file-tasks");
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(100),
name: z.string().trim().min(2).max(50),
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(100),
name: z.string().min(2).max(50),
description: z.string().max(500),
});
@@ -50,7 +50,7 @@ export default function GroupList() {
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle />
<div style={{ minWidth: 0, overflow: "hidden" }}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</Text>
@@ -269,15 +269,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => {
timerRef.current = setTimeout(async () => {
const page = await queryClient.fetchQuery({
queryKey: ["pages", node.data.id],
queryFn: () => getPageById({ pageId: node.data.id }),
timerRef.current = setTimeout(() => {
queryClient.prefetchQuery({
queryKey: ["pages", node.data.slugId],
queryFn: () => getPageById({ pageId: node.data.slugId }),
staleTime: 5 * 60 * 1000,
});
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150);
};
@@ -8,6 +8,7 @@ import {
Switch,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react";
@@ -20,12 +21,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;
@@ -34,9 +35,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const pageId = extractPageSlugId(pageSlug);
const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams();
const { isTrial } = useTrial();
@@ -27,7 +27,9 @@ 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,
@@ -70,7 +72,7 @@ export function useShareForPageQuery(
queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId),
enabled: !!pageId,
staleTime: 60 * 1000,
staleTime: 0,
retry: false,
});
@@ -1,7 +1,6 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
@@ -10,12 +9,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(2).max(100),
name: z.string().trim().min(2).max(50),
slug: z
.string()
.trim()
.min(2)
.max(100)
.max(50)
.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(100),
description: z.string().max(500),
name: z.string().min(2).max(50),
description: z.string().max(250),
slug: z
.string()
.min(2)
.max(100)
.max(50)
.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 style={{ minWidth: 0, overflow: "hidden" }}>
<div>
<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/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@ai-sdk/azure": "^2.0.47",
"@ai-sdk/google": "^2.0.18",
"@ai-sdk/openai": "^2.0.46",
"@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.3.0",
"@fastify/static": "^8.3.0",
"@langchain/core": "1.1.13",
"@langchain/textsplitters": "1.0.1",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11",
"@nestjs/common": "^11.1.9",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11",
"@nestjs/core": "^11.1.9",
"@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.11",
"@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/schedule": "^6.1.0",
"@nestjs/platform-fastify": "^11.1.9",
"@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.11",
"@nestjs/websockets": "^11.1.9",
"@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": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
"ai": "^5.0.65",
"ai-sdk-ollama": "^0.12.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3",
"cheerio": "^1.1.2",
"cheerio": "^1.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie": "^1.1.1",
"fs-extra": "^11.3.3",
"happy-dom": "20.1.0",
"cookie": "^1.0.2",
"fs-extra": "^11.3.0",
"happy-dom": "20.0.10",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.3",
"jsonwebtoken": "^9.0.2",
"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.12",
"nodemailer": "^7.0.11",
"openid-client": "^5.7.1",
"otpauth": "^9.4.1",
"otpauth": "^9.4.0",
"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.3",
"socket.io": "^4.8.1",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
"typesense": "^2.1.0",
"ws": "^8.19.0",
"ws": "^8.18.3",
"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.19.0",
"kysely-codegen": "^0.17.0",
"prettier": "^3.5.1",
"react-email": "3.0.2",
"source-map-support": "^0.5.21",
@@ -1,5 +1,6 @@
export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated',
PAGE_CREATED = 'page.created',
PAGE_UPDATED = 'page.updated',
PAGE_CONTENT_UPDATED = 'page-content-updated',
@@ -5,4 +5,4 @@ export const nanoIdGen = customAlphabet(alphabet, 10);
const slugIdAlphabet =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
@@ -10,6 +10,12 @@ export enum SpaceRole {
READER = 'reader', // can only read pages in space
}
export enum PageRole {
WRITER = 'writer', // can read and write pages in space
READER = 'reader', // can only read pages in space
RESTRICTED = 'restricted', // cannot access page
}
export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see
@@ -14,11 +14,18 @@ export class InternalLogFilter extends ConsoleLogger {
super();
const isProduction = process.env.NODE_ENV === 'production';
const isDebugMode = process.env.DEBUG_MODE === 'true';
if (isProduction && !isDebugMode) {
this.allowedLogLevels = ['log', 'error', 'fatal'];
} else {
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
this.allowedLogLevels = [
'log',
'debug',
'verbose',
'warn',
'error',
'fatal',
];
}
}
@@ -0,0 +1,168 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
AbilityBuilder,
createMongoAbility,
MongoAbility,
} from '@casl/ability';
import { PageRole, SpaceRole } from '../../../common/helpers/types/permission';
import { User } from '@docmost/db/types/entity.types';
import {
PagePermissionRepo,
PageMemberRole,
} from '@docmost/db/repos/page/page-permission-repo.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import {
PageCaslAction,
IPageAbility,
PageCaslSubject,
} from '../interfaces/page-ability.type';
import { findHighestUserSpaceRole } from '@docmost/db/repos/Space/utils';
import { UserSpaceRole } from '@docmost/db/repos/space/types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export default class PageAbilityFactory {
private readonly logger = new Logger(PageAbilityFactory.name);
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async createForUser(user: User, pageId: string) {
//user.id = '0197750c-a70c-73a6-83ad-65a193433f5c';
// This opens the possibility to share pages with individual users from other Spaces
/*
//TODO: we might account for space permission here too.
// we could just do it all here. no need to call two abilities.
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
user.id,
spaceId,
);
*/
// const userPageRole = findHighestUserPageRole(userPageRoles);
// if no role abort
// Check page-level permissions first if pageId provided
const permission = await this.pagePermissionRepo.getUserPagePermission({
pageId: pageId,
userId: user.id,
});
// does it pick one? what if the user has permissions via groups? what roles takes precedence?
if (!permission) {
//TODO: it means we should use the space level permission
// need deeper understanding here though
// call the space factory?
}
this.logger.log('permissions', permission);
if (permission) {
// make sure the permission is for this page
// or cascaded/inherited from a parent page
/*this.logger.debug('role', permission.role, 'cascade', permission.cascade);
if (permission.pageId !== pageId && !permission.cascade) {
this.logger.debug('no permission');
// No explicit access and not inheriting - deny
return new AbilityBuilder<MongoAbility<IPageAbility>>(
createMongoAbility,
).build();
}*/
}
// if no permission should we use space permission here?
// if non, skip for default to take precedence
switch (permission.role) {
case PageRole.WRITER:
return buildPageWriterAbility();
case PageRole.READER:
return buildPageReaderAbility();
case PageRole.RESTRICTED:
return buildPageRestrictedAbility();
default:
throw new NotFoundException('Page permissions not found');
}
}
private buildAbilityForRole(role: string) {
switch (role) {
case PageRole.WRITER:
return buildPageWriterAbility();
case PageRole.READER:
return buildPageReaderAbility();
case PageRole.RESTRICTED:
return buildPageRestrictedAbility();
default:
return new AbilityBuilder<MongoAbility<IPageAbility>>(
createMongoAbility,
).build();
}
}
}
function buildPageWriterAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
createMongoAbility,
);
can(PageCaslAction.Read, PageCaslSubject.Settings);
can(PageCaslAction.Read, PageCaslSubject.Member);
can(PageCaslAction.Manage, PageCaslSubject.Page);
can(PageCaslAction.Manage, PageCaslSubject.Share);
return build();
}
function buildPageReaderAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
createMongoAbility,
);
can(PageCaslAction.Read, PageCaslSubject.Settings);
can(PageCaslAction.Read, PageCaslSubject.Member);
can(PageCaslAction.Read, PageCaslSubject.Page);
can(PageCaslAction.Read, PageCaslSubject.Share);
return build();
}
function buildPageRestrictedAbility() {
const { cannot, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
createMongoAbility,
);
cannot(PageCaslAction.Read, PageCaslSubject.Settings);
cannot(PageCaslAction.Read, PageCaslSubject.Member);
cannot(PageCaslAction.Read, PageCaslSubject.Page);
cannot(PageCaslAction.Read, PageCaslSubject.Share);
return build();
}
export interface UserPageRole {
userId: string;
role: string;
}
export function findHighestUserPageRole(userPageRoles: UserPageRole[]) {
//TODO: perhaps, we want the lowest here?
if (!userPageRoles) {
return undefined;
}
const roleOrder: { [key in PageRole]: number } = {
[PageRole.WRITER]: 3,
[PageRole.READER]: 2,
[PageRole.RESTRICTED]: 1,
};
let highestRole: string;
for (const userPageRole of userPageRoles) {
const currentRole = userPageRole.role;
if (!highestRole || roleOrder[currentRole] > roleOrder[highestRole]) {
highestRole = currentRole;
}
}
return highestRole;
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { Global, Module } from '@nestjs/common';
import SpaceAbilityFactory from './abilities/space-ability.factory';
import WorkspaceAbilityFactory from './abilities/workspace-ability.factory';
import PageAbilityFactory from './abilities/page-ability.factory';
@Global()
@Module({
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory],
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory],
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory],
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory],
})
export class CaslModule {}
@@ -0,0 +1,19 @@
export enum PageCaslAction {
Manage = 'manage',
Create = 'create',
Read = 'read',
Edit = 'edit',
Delete = 'delete',
}
export enum PageCaslSubject {
Settings = 'settings',
Member = 'member',
Page = 'page',
Share = 'share',
}
export type IPageAbility =
| [PageCaslAction, PageCaslSubject.Settings]
| [PageCaslAction, PageCaslSubject.Member]
| [PageCaslAction, PageCaslSubject.Page]
| [PageCaslAction, PageCaslSubject.Share];
@@ -7,11 +7,11 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import {Transform, TransformFnParams} from "class-transformer";
import { Transform, TransformFnParams } from 'class-transformer';
export class CreateGroupDto {
@MinLength(2)
@MaxLength(100)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -0,0 +1,34 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsUUID,
} from 'class-validator';
import { PageIdDto } from './page.dto';
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
export class AddPageMembersDto extends PageIdDto {
@IsEnum(PageMemberRole)
role: string;
// optional
@IsArray()
@ArrayMaxSize(25, {
message: 'userIds must be an array with no more than 25 elements',
})
@IsUUID('all', { each: true })
userIds: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(25, {
message: 'groupIds must be an array with no more than 25 elements',
})
@IsUUID('all', { each: true })
groupIds: string[];
@IsBoolean()
@IsOptional()
cascade?: boolean; // Apply to all child pages
}
@@ -4,4 +4,4 @@ export class DeletedPageDto {
@IsNotEmpty()
@IsString()
spaceId: string;
}
}
@@ -17,8 +17,8 @@ export type CopyPageMapEntry = {
};
export type ICopyPageAttachment = {
newPageId: string,
oldPageId: string,
oldAttachmentId: string,
newAttachmentId: string,
newPageId: string;
oldPageId: string;
oldAttachmentId: string;
newAttachmentId: string;
};
@@ -0,0 +1,22 @@
import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator';
import { PageIdDto } from './page.dto';
import { Type } from 'class-transformer';
export class GetPageMembersDto extends PageIdDto {
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@IsString()
query?: string;
}
@@ -0,0 +1,7 @@
import { IsUUID } from 'class-validator';
import { PageIdDto } from './page.dto';
export class RemovePageMemberDto extends PageIdDto {
@IsUUID()
memberId: string;
}
@@ -0,0 +1,11 @@
import { IsEnum, IsUUID } from 'class-validator';
import { PageIdDto } from './page.dto';
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
export class UpdatePageMemberRoleDto extends PageIdDto {
@IsUUID()
memberId: string;
@IsEnum(PageMemberRole)
role: string;
}
@@ -0,0 +1,21 @@
import { IsBoolean, IsEnum, IsOptional, IsUUID } from 'class-validator';
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
export class UpdatePagePermissionDto {
@IsUUID()
pageId: string;
@IsUUID()
@IsOptional()
userId?: string;
@IsUUID()
@IsOptional()
groupId?: string;
@IsEnum(PageMemberRole)
role: string;
@IsBoolean()
cascade: boolean; // Apply to all child pages
}
+188 -2
View File
@@ -32,9 +32,24 @@ import {
} from '../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
import { AddPageMembersDto } from './dto/add-page-members.dto';
import { RemovePageMemberDto } from './dto/remove-page-member.dto';
import { UpdatePageMemberRoleDto } from './dto/update-page-member-role.dto';
import { UpdatePagePermissionDto } from './dto/update-page-permission.dto';
import { GetPageMembersDto } from './dto/get-page-members.dto';
import {
PagePermissionService,
PagePermissionsResponse,
} from './services/page-member.service';
import PageAbilityFactory from '../casl/abilities/page-ability.factory';
import {
PageCaslAction,
PageCaslSubject,
} from '../casl/interfaces/page-ability.type';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -44,6 +59,9 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAbility: PageAbilityFactory,
private readonly pagePermissionService: PagePermissionService,
private readonly sharedPagesRepo: SharedPagesRepo,
) {}
@HttpCode(HttpStatus.OK)
@@ -61,11 +79,21 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
const pageAbility = await this.pageAbility.createForUser(user, page.id);
if (pageAbility.cannot(PageCaslAction.Read, PageCaslSubject.Page)) {
throw new ForbiddenException();
}
/*const ability = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}*/
return page;
}
@@ -389,4 +417,162 @@ export class PageController {
}
return this.pageService.getPageBreadCrumbs(page.id);
}
@HttpCode(HttpStatus.OK)
@Post('permissions/restrict')
async restrictPage(@Body() dto: PageIdDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// TODO: make sure they have access to the page, and can restrict
// And the page is not already restricted
// They can add and remove page restriction
// When a page restriction is removed, we remove the entries in page permissions table.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pagePermissionService.restrictPage(user, page.id);
}
@HttpCode(HttpStatus.OK)
@Post('permissions/add')
async addPageMembers(
@Body() dto: AddPageMembersDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pagePermissionService.addMembersToPageBatch(
dto,
user,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('permissions/remove')
async removePageMember(
@Body() dto: RemovePageMemberDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pagePermissionService.removePageMember(dto, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('permissions/update-role')
async updatePageMemberRole(
@Body() dto: UpdatePageMemberRoleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pagePermissionService.updatePageMemberRole(dto, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('permissions/update')
async updatePagePermissions(
@Body() dto: UpdatePagePermissionDto,
@AuthUser() user: User,
): Promise<PagePermissionsResponse> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pagePermissionService.updatePagePermission(dto);
}
@HttpCode(HttpStatus.OK)
@Post('permissions/info')
async getPagePermissions(
@Body() dto: PageIdDto,
@AuthUser() user: User,
): Promise<PagePermissionsResponse> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pagePermissionService.getPagePermissions(dto.pageId);
}
@HttpCode(HttpStatus.OK)
@Post('permissions/list')
async getPageMembers(
@Body() dto: GetPageMembersDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const pagination: PaginationOptions = {
page: dto.page || 1,
limit: dto.limit || 20,
query: dto.query,
};
return this.pagePermissionService.getPageMembers(
dto.pageId,
workspace.id,
pagination,
);
}
@HttpCode(HttpStatus.OK)
@Post('shared')
async getUserSharedPages(@AuthUser() user: User) {
return this.sharedPagesRepo.getUserSharedPages(user.id);
}
}
+10 -2
View File
@@ -3,12 +3,20 @@ 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-member.service';
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
import { StorageModule } from '../../integrations/storage/storage.module';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService],
providers: [
PageService,
PageHistoryService,
TrashCleanupService,
PagePermissionService,
SharedPagesRepo,
],
exports: [PageService, PageHistoryService, PagePermissionService],
imports: [StorageModule],
})
export class PageModule {}
@@ -0,0 +1,648 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import {
PagePermissionRepo,
PageMemberRole,
} from '@docmost/db/repos/page/page-permission-repo.service';
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
import { AddPageMembersDto } from '../dto/add-page-members.dto';
import { InjectKysely } from 'nestjs-kysely';
import { Page, PagePermission, User } from '@docmost/db/types/entity.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RemovePageMemberDto } from '../dto/remove-page-member.dto';
import { UpdatePageMemberRoleDto } from '../dto/update-page-member-role.dto';
import { UpdatePagePermissionDto } from '../dto/update-page-permission.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { executeTx } from '@docmost/db/utils';
export interface IPagePermission {
id: string;
cascade: boolean;
member: {
id: string;
type: 'user' | 'group' | 'public';
email?: string;
displayName?: string;
avatarUrl?: string;
workspaceRole?: string;
name?: string;
memberCount?: number;
};
membershipRole: {
id: string;
level: string;
source: 'direct' | 'inherited';
};
grantedBy: {
id: string;
type: 'page' | 'space';
title?: string;
name?: string;
parentId?: string;
};
}
export interface PagePermissionsResponse {
page: {
id: string;
title: string;
hasCustomPermissions: boolean;
inheritPermissions: boolean;
permissions: IPagePermission[];
};
}
@Injectable()
export class PagePermissionService {
constructor(
private pageMemberRepo: PagePermissionRepo,
private pageRepo: PageRepo,
private sharedPagesRepo: SharedPagesRepo,
private userRepo: UserRepo,
private groupRepo: GroupRepo,
private spaceMemberRepo: SpaceMemberRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
async addUserToPage(
userId: string,
pageId: string,
role: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await this.pageMemberRepo.insertPageMember(
{
userId: userId,
pageId: pageId,
role: role,
},
trx,
);
}
async addGroupToPage(
groupId: string,
pageId: string,
role: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await this.pageMemberRepo.insertPageMember(
{
groupId: groupId,
pageId: pageId,
role: role,
},
trx,
);
}
async getPageMembers(
pageId: string,
workspaceId: string,
pagination: PaginationOptions,
) {
const page = await this.pageRepo.findById(pageId);
// const page = await this.pageRepo.findById(pageId, { workspaceId });
if (!page) {
throw new NotFoundException('Page not found');
}
const members = await this.pageMemberRepo.getPageMembersPaginated(
pageId,
pagination,
);
return members;
}
async restrictPage(authUser: User, pageId: string) {
// to add custom permissions to a page,
// we have to restrict the page first.
// the user is here because they can restrict this page
// TODO: make sure page is not in trash
// Not sure if normal users can see restricted pages in trash.
await this.db
.updateTable('pages')
.set({
isRestricted: true,
restrictedById: authUser.id,
})
.where('id', '=', pageId)
.execute();
}
async addMembersToPageBatch(
dto: AddPageMembersDto,
authUser: User,
workspaceId: string,
): Promise<void> {
try {
const page = await this.pageRepo.findById(dto.pageId);
//const page = await this.pageRepo.findById(dto.pageId, { workspaceId });
if (!page) {
throw new NotFoundException('Page not found');
}
// Validate role
if (!Object.values(PageMemberRole).includes(dto.role as PageMemberRole)) {
throw new BadRequestException(`Invalid role: ${dto.role}`);
}
// Enable custom permissions if adding first member
/*if (!page.hasCustomPermissions) {
await this.pageRepo.update(dto.pageId, {
hasCustomPermissions: true,
inheritPermissions: false,
});
}*/
// Make sure we have valid workspace users
const validUsersQuery = this.db
.selectFrom('users')
.select(['id', 'name'])
.where('users.id', 'in', dto.userIds)
.where('users.workspaceId', '=', workspaceId)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pagePermissions')
.select('id')
.whereRef('pagePermissions.userId', '=', 'users.id')
.where('pagePermissions.pageId', '=', dto.pageId),
),
),
);
const validGroupsQuery = this.db
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', dto.groupIds)
.where('groups.workspaceId', '=', workspaceId)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pagePermissions')
.select('id')
.whereRef('pagePermissions.groupId', '=', 'groups.id')
.where('pagePermissions.pageId', '=', dto.pageId),
),
),
);
let validUsers = [],
validGroups = [];
if (dto.userIds && dto.userIds.length > 0) {
validUsers = await validUsersQuery.execute();
}
if (dto.groupIds && dto.groupIds.length > 0) {
validGroups = await validGroupsQuery.execute();
}
const usersToAdd = [];
for (const user of validUsers) {
usersToAdd.push({
pageId: dto.pageId,
userId: user.id,
role: dto.role,
addedById: authUser.id,
});
// Track orphaned page access if user doesn't have parent access
if (page.parentPageId && dto.role !== PageMemberRole.NONE) {
const hasParentAccess = await this.checkParentAccess(
user.id,
page.parentPageId,
);
if (!hasParentAccess) {
await this.sharedPagesRepo.addSharedPage(user.id, dto.pageId);
}
}
}
const groupsToAdd = [];
for (const group of validGroups) {
groupsToAdd.push({
pageId: dto.pageId,
groupId: group.id,
role: dto.role,
addedById: authUser.id,
});
}
const membersToAdd = [...usersToAdd, ...groupsToAdd];
if (membersToAdd.length > 0) {
await this.db
.insertInto('pagePermissions')
.values(membersToAdd)
.execute();
}
// Apply to child pages if requested
if (dto.cascade) {
await this.cascadeToChildren(dto.pageId, membersToAdd);
}
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof BadRequestException
) {
throw error;
}
throw new BadRequestException(
'Failed to add members to page. Please try again.',
);
}
}
async removePageMember(
dto: RemovePageMemberDto,
workspaceId: string,
): Promise<void> {
const member = await this.db
.selectFrom('pagePermissions')
.innerJoin('pages', 'pages.id', 'pagePermissions.pageId')
.select(['pagePermissions.id', 'pagePermissions.userId'])
.where('pagePermissions.id', '=', dto.memberId)
.where('pagePermissions.pageId', '=', dto.pageId)
.where('pages.workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!member) {
throw new NotFoundException('Page member not found');
}
// Check if this is the last admin
const adminCount = await this.pageMemberRepo.roleCountByPageId(
PageMemberRole.ADMIN,
dto.pageId,
);
if (adminCount === 1) {
const memberToRemove = await this.pageMemberRepo.getPageMemberByTypeId(
dto.pageId,
{ userId: member.userId },
);
if (memberToRemove?.role === PageMemberRole.ADMIN) {
throw new BadRequestException('Cannot remove the last admin from page');
}
}
await this.pageMemberRepo.removePageMemberById(dto.memberId, dto.pageId);
// Remove from shared pages if it was tracked
if (member.userId) {
await this.sharedPagesRepo.removeSharedPage(member.userId, dto.pageId);
}
}
async updatePageMemberRole(
dto: UpdatePageMemberRoleDto,
workspaceId: string,
): Promise<void> {
const member = await this.db
.selectFrom('pagePermissions')
.innerJoin('pages', 'pages.id', 'pagePermissions.pageId')
.select(['pagePermissions.id', 'pagePermissions.role'])
.where('pagePermissions.id', '=', dto.memberId)
.where('pagePermissions.pageId', '=', dto.pageId)
.where('pages.workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!member) {
throw new NotFoundException('Page member not found');
}
if (
member.role === PageMemberRole.ADMIN &&
dto.role !== PageMemberRole.ADMIN
) {
const adminCount = await this.pageMemberRepo.roleCountByPageId(
PageMemberRole.ADMIN,
dto.pageId,
);
if (adminCount === 1) {
throw new BadRequestException('Cannot change role of the last admin');
}
}
await this.pageMemberRepo.updatePageMember(
{ role: dto.role },
dto.memberId,
dto.pageId,
);
}
async updatePagePermission(
dto: UpdatePagePermissionDto,
): Promise<PagePermissionsResponse> {
const { pageId, userId, groupId, role, cascade } = dto;
try {
// Validate inputs
if (!userId && !groupId) {
throw new BadRequestException(
'Either userId or groupId must be provided',
);
}
if (userId && groupId) {
throw new BadRequestException('Cannot provide both userId and groupId');
}
if (!Object.values(PageMemberRole).includes(role as PageMemberRole)) {
throw new BadRequestException(`Invalid role: ${role}`);
}
await executeTx(this.db, async (trx) => {
// Update the role
if (userId) {
await this.pageMemberRepo.upsertPageMember(
{
pageId,
userId,
role,
},
trx,
);
} else if (groupId) {
await this.pageMemberRepo.upsertPageMember(
{
pageId,
groupId,
role,
},
trx,
);
}
// Mark page as having custom permissions
/* await this.pageRepo.update(
pageId,
{
hasCustomPermissions: true,
inheritPermissions: false,
},
trx,
);*/
// Cascade to children if requested
if (cascade) {
const descendants = await this.pageRepo.getAllDescendants(
pageId,
trx,
);
for (const childId of descendants) {
if (userId) {
await this.pageMemberRepo.upsertPageMember(
{
pageId: childId,
userId,
role,
},
trx,
);
} else if (groupId) {
await this.pageMemberRepo.upsertPageMember(
{
pageId: childId,
groupId,
role,
},
trx,
);
}
}
}
});
// Return comprehensive permission data
return this.getPagePermissions(pageId);
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException(
'Failed to update page permissions. Please try again.',
);
}
}
async getPagePermissions(pageId: string): Promise<PagePermissionsResponse> {
const page = await this.pageRepo.findById(pageId, { includeSpace: true });
if (!page) {
throw new NotFoundException('Page not found');
}
const permissions: IPagePermission[] = [];
// 1. Get direct page members
const directMembers = await this.pageMemberRepo.getPageMembers(pageId);
// Batch fetch all users and groups
const userIds = directMembers.filter((m) => m.userId).map((m) => m.userId);
const groupIds = directMembers
.filter((m) => m.groupId)
.map((m) => m.groupId);
const [users, groups] = await Promise.all([
userIds.length > 0
? this.db
.selectFrom('users')
.selectAll()
.where('id', 'in', userIds)
.execute()
: Promise.resolve([]),
groupIds.length > 0
? this.db
.selectFrom('groups')
.selectAll()
.where('id', 'in', groupIds)
.execute()
: Promise.resolve([]),
]);
const userMap = new Map(users.map((u) => [u.id, u] as const));
const groupMap = new Map(groups.map((g) => [g.id, g] as const));
// Build permissions with batch-fetched data
for (const member of directMembers) {
let memberData: any = null;
if (member.userId) {
const user = userMap.get(member.userId);
if (user) {
memberData = {
id: user.id,
type: 'user' as const,
email: user.email,
displayName: user.name,
avatarUrl: user.avatarUrl,
workspaceRole: user.role,
};
}
} else if (member.groupId) {
const group = groupMap.get(member.groupId);
if (group) {
memberData = {
id: group.id,
type: 'group' as const,
name: group.name,
memberCount: await this.db
.selectFrom('groupUsers')
.select((eb) => eb.fn.count('userId').as('count'))
.where('groupId', '=', group.id)
.executeTakeFirst()
.then((result) => Number(result?.count || 0)),
};
}
}
if (memberData) {
permissions.push({
id: member.id,
cascade: true, // Page permissions cascade by default
member: memberData,
membershipRole: {
id: member.id,
level: member.role,
source: 'direct',
},
grantedBy: {
id: pageId,
type: 'page',
title: page.title,
},
});
}
}
// 2. Get inherited space members (if page inherits)
if (page) {
//if (page.inheritPermissions || !page.hasCustomPermissions) {
const spaceMembers = await this.spaceMemberRepo.getSpaceMembersPaginated(
page.spaceId,
{ page: 1, limit: 100 },
);
for (const spaceMember of spaceMembers.items as any[]) {
// Skip if user has direct page permission
const hasDirect = directMembers.some(
(dm) =>
(dm.userId === spaceMember.id && spaceMember.type === 'user') ||
(dm.groupId === spaceMember.id && spaceMember.type === 'group'),
);
if (!hasDirect) {
permissions.push({
id: `space-${spaceMember.id}`,
cascade: false, // Space permissions don't cascade to page children
member: {
id: spaceMember.id,
type: spaceMember.type as 'user' | 'group',
email: spaceMember.email,
displayName: spaceMember.name,
avatarUrl: spaceMember.avatarUrl,
name: spaceMember.name,
memberCount: Number(spaceMember.memberCount || 0),
},
membershipRole: {
id: `space-role-${spaceMember.id}`,
level: spaceMember.role,
source: 'inherited',
},
grantedBy: {
id: page.spaceId,
type: 'space',
name: (page as any).space?.name,
},
});
}
}
}
return {
page: {
id: page.id,
title: page.title,
hasCustomPermissions: true,
inheritPermissions: false,
permissions,
},
};
}
private async checkParentAccess(
userId: string,
parentPageId: string | null,
): Promise<boolean> {
if (!parentPageId) return true; // Root pages always accessible
const parentAccess = await this.pageMemberRepo.resolveUserPageAccess(
userId,
parentPageId,
);
return parentAccess !== null && parentAccess !== PageMemberRole.NONE;
}
private async cascadeToChildren(
pageId: string,
membersToAdd: any[],
): Promise<void> {
const descendants = await this.pageRepo.getAllDescendants(pageId);
if (descendants.length === 0) return;
// Separate user and group members for proper conflict handling
const userMembers = membersToAdd.filter((m) => m.userId);
const groupMembers = membersToAdd.filter((m) => m.groupId);
for (const childId of descendants) {
// Handle user members with proper conflict resolution
if (userMembers.length > 0) {
const childUserMembers = userMembers.map((m) => ({
...m,
pageId: childId,
}));
await this.db
.insertInto('pagePermissions')
.values(childUserMembers)
.onConflict((oc) =>
oc.columns(['pageId', 'userId']).doUpdateSet({
role: (eb) => eb.ref('excluded.role'),
updatedAt: new Date(),
}),
)
.execute();
}
// Handle group members separately
if (groupMembers.length > 0) {
const childGroupMembers = groupMembers.map((m) => ({
...m,
pageId: childId,
}));
await this.db
.insertInto('pagePermissions')
.values(childGroupMembers)
.onConflict((oc) =>
oc.columns(['pageId', 'groupId']).doUpdateSet({
role: (eb) => eb.ref('excluded.role'),
updatedAt: new Date(),
}),
)
.execute();
}
}
}
}
+10 -7
View File
@@ -74,13 +74,16 @@ export class SearchService {
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
queryResults = queryResults
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length > 0) {
queryResults = queryResults
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
+49 -51
View File
@@ -123,82 +123,80 @@ export class ShareService {
.withRecursive('page_hierarchy', (cte) =>
cte
.selectFrom('pages')
.leftJoin('shares', 'shares.pageId', 'pages.id')
.select([
'pages.id',
'pages.slugId',
'id',
'slugId',
'pages.title',
'pages.icon',
'pages.parentPageId',
'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) ? '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
.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),
),
)
.selectFrom('page_hierarchy')
.selectAll()
.where('shareId', 'is not', null)
.limit(1)
.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')
.executeTakeFirst();
if (!share || share.workspaceId !== workspaceId) {
if (!share || share.workspaceId != workspaceId) {
return undefined;
}
if ((share.level as number) > 0 && !share.includeSubPages) {
if (share.level === 1 && !share.includeSubPages) {
// we can only show a page if its shared ancestor permits it
return undefined;
}
return {
id: share.shareId,
key: share.shareKey,
id: share.id,
key: share.key,
includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing,
pageId: share.id,
pageId: share.pageId,
creatorId: share.creatorId,
spaceId: share.spaceId,
workspaceId: share.workspaceId,
createdAt: share.createdAt,
level: share.level,
sharedPage: {
id: share.id,
slugId: share.slugId,
title: share.title,
icon: share.icon,
id: share.sharedPageId,
slugId: share.sharedPageSlugId,
title: share.sharedPageTitle,
icon: share.sharedPageIcon,
},
};
}
@@ -5,11 +5,11 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import {Transform, TransformFnParams} from "class-transformer";
import { Transform, TransformFnParams } from 'class-transformer';
export class CreateSpaceDto {
@MinLength(2)
@MaxLength(100)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -19,7 +19,7 @@ export class CreateSpaceDto {
description?: string;
@MinLength(2)
@MaxLength(100)
@MaxLength(50)
@IsAlphanumeric()
slug: string;
}
+3 -1
View File
@@ -70,7 +70,9 @@ export class UserService {
);
if (!isPasswordMatch) {
throw new BadRequestException('You must provide the correct password to change your email');
throw new BadRequestException(
'You must provide the correct password to change your email',
);
}
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
@@ -26,6 +26,7 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission-repo.service';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@@ -78,6 +79,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
BacklinkRepo,
ShareRepo,
PageListener,
PagePermissionRepo,
],
exports: [
WorkspaceRepo,
@@ -93,6 +95,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
UserTokenRepo,
BacklinkRepo,
ShareRepo,
PagePermissionRepo,
],
})
export class DatabaseModule
@@ -3,7 +3,7 @@ import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('pages')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo("{}"))
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
.execute();
}
@@ -0,0 +1,102 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_permissions')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade'),
)
.addColumn('group_id', 'uuid', (col) =>
col.references('groups.id').onDelete('cascade'),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('cascade', 'boolean', (col) => col.defaultTo(true).notNull()) // children can inherit
.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()`),
)
.addColumn('deleted_at', 'timestamptz')
.addUniqueConstraint('unique_page_user', ['page_id', 'user_id'])
.addUniqueConstraint('unique_page_group', ['page_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
.alterTable('pages')
.addColumn('is_restricted', 'boolean', (col) =>
col.defaultTo(false).notNull(),
)
.addColumn('restricted_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.execute();
// Add indexes for performance
await db.schema
.createIndex('idx_page_permissions_page_id')
.on('page_permissions')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_user_id')
.on('page_permissions')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_group_id')
.on('page_permissions')
.column('group_id')
.execute();
// Create user_shared_pages table for tracking orphaned page access
await db.schema
.createTable('user_shared_pages')
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('shared_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addPrimaryKeyConstraint('user_shared_pages_pkey', ['user_id', 'page_id'])
.execute();
await db.schema
.createIndex('idx_user_shared_pages_user_id')
.on('user_shared_pages')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_user_shared_pages_shared_at')
.on('user_shared_pages')
.column('shared_at')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('pages').dropColumn('is_restricted').execute();
await db.schema.alterTable('pages').dropColumn('restricted_by_id').execute();
await db.schema.dropTable('user_shared_pages').execute();
await db.schema.dropTable('page_permissions').execute();
}
@@ -23,9 +23,9 @@ export class PaginationOptions {
@IsOptional()
@IsString()
query: string;
query?: string;
@IsOptional()
@IsBoolean()
adminView: boolean;
adminView?: boolean;
}
@@ -105,7 +105,10 @@ export class CommentRepo {
return Number(result?.count) > 0;
}
async hasChildrenFromOtherUsers(commentId: string, userId: string): Promise<boolean> {
async hasChildrenFromOtherUsers(
commentId: string,
userId: string,
): Promise<boolean> {
const result = await this.db
.selectFrom('comments')
.select((eb) => eb.fn.count('id').as('count'))
@@ -57,7 +57,11 @@ export class GroupUserRepo {
if (pagination.query) {
query = query.where((eb) =>
eb(sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`),
eb(
sql`f_unaccent(users.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
@@ -114,7 +114,11 @@ export class GroupRepo {
if (pagination.query) {
query = query.where((eb) =>
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
eb(
sql`f_unaccent(name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`f_unaccent(description)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
File diff suppressed because it is too large Load Diff
@@ -293,18 +293,24 @@ 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', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('spaceId', 'in', userSpaceIds)
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
return executeWithPagination(query, {
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
@@ -448,4 +454,46 @@ export class PageRepo {
.selectAll()
.execute();
}
async update(
pageId: string,
updatablePage: UpdatablePage,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('pages')
.set({ ...updatablePage, updatedAt: new Date() })
.where('id', '=', pageId)
.execute();
}
async getAllDescendants(
pageId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
const db = dbOrTx(this.db, trx);
// Recursive CTE to get all descendants
const descendants = await db
.withRecursive('page_tree', (qb) =>
qb
.selectFrom('pages')
.select(['id', 'parentPageId'])
.where('parentPageId', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((eb) =>
eb
.selectFrom('pages as p')
.innerJoin('page_tree as pt', 'p.parentPageId', 'pt.id')
.select(['p.id', 'p.parentPageId'])
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_tree')
.select('id')
.execute();
return descendants.map((d) => d.id);
}
}
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '../../types/kysely.types';
import { Page } from '../../types/entity.types';
import { PageMemberRole } from './page-permission-repo.service';
@Injectable()
export class SharedPagesRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async addSharedPage(userId: string, pageId: string): Promise<void> {
await this.db
.insertInto('userSharedPages')
.values({
userId,
pageId,
sharedAt: new Date(),
})
.onConflict((oc) => oc.columns(['userId', 'pageId']).doNothing())
.execute();
}
async removeSharedPage(userId: string, pageId: string): Promise<void> {
await this.db
.deleteFrom('userSharedPages')
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.execute();
}
async getUserSharedPages(userId: string): Promise<Page[]> {
return await this.db
.selectFrom('userSharedPages as usp')
.innerJoin('pages as p', 'p.id', 'usp.pageId')
.innerJoin('pagePermissions as pm', (join) =>
join
.onRef('pm.pageId', '=', 'p.id')
.on('pm.userId', '=', userId)
.on('pm.role', '!=', PageMemberRole.NONE),
)
.selectAll('p')
.where('usp.userId', '=', userId)
.where('p.deletedAt', 'is', null)
.orderBy('usp.sharedAt', 'desc')
.execute();
}
async isPageSharedWithUser(userId: string, pageId: string): Promise<boolean> {
const result = await this.db
.selectFrom('userSharedPages')
.select('userId')
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.executeTakeFirst();
return !!result;
}
}
@@ -137,19 +137,25 @@ 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', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('spaceId', 'in', userSpaceIds)
.orderBy('updatedAt', 'desc');
return executeWithPagination(query, {
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
@@ -209,33 +209,34 @@ export class SpaceMemberRepo {
return roles;
}
getUserSpaceIdsQuery(userId: string) {
return this.db
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await 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('id', 'in', this.getUserSpaceIdsQuery(userId))
//.where('workspaceId', '=', workspaceId)
.where('id', 'in', userSpaceIds)
.orderBy('createdAt', 'asc');
if (pagination.query) {
@@ -252,9 +253,14 @@ export class SpaceMemberRepo {
);
}
return executeWithPagination(query, {
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
}
+23
View File
@@ -214,6 +214,19 @@ export interface PageHistory {
workspaceId: string;
}
export interface PagePermissions {
addedById: string | null;
cascade: Generated<boolean>;
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
groupId: string | null;
id: Generated<string>;
pageId: string;
role: string;
updatedAt: Generated<Timestamp>;
userId: string | null;
}
export interface Pages {
content: Json | null;
contributorIds: Generated<string[] | null>;
@@ -225,9 +238,11 @@ export interface Pages {
icon: string | null;
id: Generated<string>;
isLocked: Generated<boolean>;
isRestricted: Generated<boolean>;
lastUpdatedById: string | null;
parentPageId: string | null;
position: string | null;
restrictedById: string | null;
slugId: string;
spaceId: string;
textContent: string | null;
@@ -313,6 +328,12 @@ export interface Users {
workspaceId: string | null;
}
export interface UserSharedPages {
pageId: string;
sharedAt: Generated<Timestamp>;
userId: string;
}
export interface UserTokens {
createdAt: Generated<Timestamp>;
expiresAt: Timestamp | null;
@@ -372,12 +393,14 @@ export interface DB {
groups: Groups;
groupUsers: GroupUsers;
pageHistory: PageHistory;
pagePermissions: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userSharedPages: UserSharedPages;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
@@ -10,12 +10,14 @@ import {
Groups,
GroupUsers,
PageHistory,
PagePermissions,
Pages,
Shares,
SpaceMembers,
Spaces,
UserMfa,
Users,
UserSharedPages,
UserTokens,
WorkspaceInvitations,
Workspaces,
@@ -33,6 +35,7 @@ export interface DbInterface {
groups: Groups;
groupUsers: GroupUsers;
pageEmbeddings: PageEmbeddings;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
@@ -40,6 +43,7 @@ export interface DbInterface {
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userSharedPages: UserSharedPages;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
@@ -4,8 +4,10 @@ import {
Comments,
Groups,
Pages,
PagePermissions,
Spaces,
Users,
UserSharedPages,
Workspaces,
PageHistory as History,
GroupUsers,
@@ -50,6 +52,15 @@ export type SpaceMember = Selectable<SpaceMembers>;
export type InsertableSpaceMember = Insertable<SpaceMembers>;
export type UpdatableSpaceMember = Updateable<Omit<SpaceMembers, 'id'>>;
// PageMember
export type PagePermission = Selectable<PagePermissions>;
export type InsertablePagePermission = Insertable<PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<PagePermissions, 'id'>>;
// UserSharedPage
export type UserSharedPage = Selectable<UserSharedPages>;
export type InsertableUserSharedPage = Insertable<UserSharedPages>;
// Group
export type ExtendedGroup = Groups & { memberCount: number };
@@ -105,7 +105,7 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@IsIn(['openai', 'gemini', 'ollama'])
@IsString()
AI_DRIVER: string;
@@ -117,10 +117,11 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000', '3072'])
@IsIn(['768', '1024', '1536', '2000'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@@ -128,20 +129,13 @@ export class EnvironmentVariables {
AI_COMPLETION_MODEL: string;
@IsOptional()
@ValidateIf(
(obj) =>
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
)
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'openai')
@IsString()
@IsNotEmpty()
OPENAI_API_KEY: string;
@IsOptional()
@ValidateIf(
(obj) =>
obj.AI_DRIVER === 'openai-compatible' ||
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
)
@ValidateIf((obj) => obj.AI_DRIVER && obj.OPENAI_API_URL && obj.AI_DRIVER === 'openai')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@@ -41,4 +41,4 @@ export class ExportSpaceDto {
@IsOptional()
@IsBoolean()
includeAttachments?: boolean;
}
}
@@ -107,7 +107,7 @@ export class ExportService {
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (page){
if (page) {
pages = [page];
}
}
@@ -69,17 +69,21 @@ function taskList(turndownService: TurndownService) {
'input[type="checkbox"]',
) as HTMLInputElement;
const isChecked = checkbox.checked;
// Process content like regular list items
content = content
.replace(/^\n+/, '') // remove leading newlines
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one
.replace(/\n/gm, '\n '); // indent nested content with 2 spaces
// Create the checkbox prefix
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '');
return (
prefix +
content +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
);
},
});
}
@@ -15,4 +15,4 @@ export type ImportPageNode = {
parentPageId: string | null;
fileExtension: string;
filePath: string;
};
};
@@ -10,59 +10,46 @@ import {
} from '@nestjs/common';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { User } 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 spaceAbility: SpaceAbilityFactory,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory,
@InjectKysely() private readonly db: KyselyDB,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post()
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();
async getFileTasks(@AuthUser() user: User) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id);
if (!userSpaceIds || userSpaceIds.length === 0) {
return [];
}
const query = this.db
const fileTasks = await this.db
.selectFrom('fileTasks')
.selectAll()
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id))
.orderBy('createdAt', 'desc');
.where('spaceId', 'in', userSpaceIds)
.execute();
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
if (!fileTasks) {
throw new NotFoundException('File task not found');
}
return fileTasks;
}
@UseGuards(JwtAuthGuard)
@@ -1,5 +1,4 @@
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
import { MentionNode } from '../../../common/helpers/prosemirror/utils';
export interface IPageBacklinkJob {
pageId: string;
@@ -9,4 +8,4 @@ export interface IPageBacklinkJob {
export interface IStripeSeatsSyncJob {
workspaceId: string;
}
}
@@ -42,7 +42,7 @@ export class LocalDriver implements StorageDriver {
try {
const fromFullPath = this._fullPath(fromFilePath);
const toFullPath = this._fullPath(toFilePath);
if (await this.exists(fromFilePath)) {
await fs.copy(fromFullPath, toFullPath);
}
@@ -40,8 +40,8 @@ export const storageDriverConfigProvider = {
},
};
case StorageOption.S3:
{ const s3Config = {
case StorageOption.S3: {
const s3Config = {
driver,
config: {
region: environmentService.getAwsS3Region(),
@@ -68,7 +68,8 @@ export const storageDriverConfigProvider = {
};
}
return s3Config; }
return s3Config;
}
default:
throw new Error(`Unknown storage driver: ${driver}`);
@@ -1,127 +0,0 @@
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);
}
}
@@ -1,9 +0,0 @@
export type ExcalidrawUserToFollow = {
socketId: string;
username: string;
};
export type ExcalidrawFollowPayload = {
userToFollow: ExcalidrawUserToFollow;
action: 'FOLLOW' | 'UNFOLLOW';
};
+1 -80
View File
@@ -1,8 +1,6 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
@@ -13,23 +11,17 @@ 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, OnGatewayDisconnect, OnModuleDestroy
{
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
@WebSocketServer()
server: Server;
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
private excalidrawCollabService: ExcalidrawCollabService,
) {}
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
@@ -49,8 +41,6 @@ export class WsGateway
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();
@@ -86,75 +76,6 @@ export class WsGateway
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();
+1 -2
View File
@@ -1,10 +1,9 @@
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, ExcalidrawCollabService],
providers: [WsGateway],
})
export class WsModule {}
-1
View File
@@ -19,7 +19,6 @@
},
"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",
+645 -745
View File
File diff suppressed because it is too large Load Diff