From 6b1ed8d11c39dfe8f052301cde24e73f172d682d Mon Sep 17 00:00:00 2001 From: M1ngdaXie <156019134+M1ngdaXie@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:19:12 -0800 Subject: [PATCH] feat: Implement document permission handling and sharing features --- backend/internal/handlers/document.go | 107 ++++++++++++++++++ backend/internal/handlers/share.go | 15 ++- backend/internal/handlers/websocket.go | 29 ++++- backend/internal/hub/hub.go | 51 +++++++-- backend/internal/models/share.go | 6 + backend/internal/store/postgres.go | 2 + backend/internal/store/share.go | 50 +++++++- .../scripts/004_add_share_link_permission.sql | 12 ++ frontend/src/api/document.ts | 16 +++ frontend/src/components/Editor/Editor.tsx | 13 ++- frontend/src/components/Share/ShareModal.tsx | 29 ++++- frontend/src/hooks/useYjsDocument.ts | 24 +++- frontend/src/pages/EditorPage.tsx | 17 ++- 13 files changed, 340 insertions(+), 31 deletions(-) create mode 100644 backend/scripts/004_add_share_link_permission.sql diff --git a/backend/internal/handlers/document.go b/backend/internal/handlers/document.go index ae54625..cecb183 100644 --- a/backend/internal/handlers/document.go +++ b/backend/internal/handlers/document.go @@ -233,4 +233,111 @@ func (h *DocumentHandler) DeleteDocument(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"}) +} + +// GetDocumentPermission returns the user's permission level for a document +func (h *DocumentHandler) GetDocumentPermission(c *gin.Context) { + documentID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondBadRequest(c, "Invalid document ID format") + return + } + + // 1. 先检查文档是否存在 (Good practice) + _, err = h.store.GetDocument(documentID) + if err != nil { + respondNotFound(c, "document") + return + } + + userID := auth.GetUserFromContext(c) + shareToken := c.Query("share") + + // 定义两个临时变量,用来存两边的结果 + var userPerm string // 存 document_shares 的结果 + var tokenPerm string // 存 share_token 的结果 + + // ==================================================== + // 步骤 A: 检查个人权限 (Base Permission) + // ==================================================== + if userID != nil { + perm, err := h.store.GetUserPermission(c.Request.Context(), documentID, *userID) + if err != nil { + respondInternalError(c, "Failed to get user permission", err) + return + } + userPerm = perm + // ⚠️ 注意:如果 perm 是空,这里不报错!继续往下走! + } + + // ==================================================== + // 步骤 B: 检查 Token 权限 (Upgrade Permission) + // ==================================================== + if shareToken != "" { + // 先验证 Token 是否有效 + valid, err := h.store.ValidateShareToken(c.Request.Context(), documentID, shareToken) + if err != nil { + respondInternalError(c, "Failed to validate token", err) + return + } + + // 只有 Token 有效才去取权限 + if valid { + p, err := h.store.GetShareLinkPermission(c.Request.Context(), documentID) + if err != nil { + respondInternalError(c, "Failed to get token permission", err) + return + } + tokenPerm = p + // 处理数据库老数据的 fallback + if tokenPerm == "" { tokenPerm = "view" } + } + } + + // ==================================================== + // 步骤 C: ⚡️ 权限合并与计算 (The Brain) + // ==================================================== + + finalPermission := "" + role := "viewer" // 默认角色 + + // 1. 如果是 Owner,无敌,直接返回 + if userPerm == "owner" { + finalPermission = "edit" + role = "owner" + // 直接返回,不用看 Token 了 + c.JSON(http.StatusOK, models.PermissionResponse{ + Permission: finalPermission, + Role: role, + }) + return + } + + // 2. 比较 User 和 Token,取最大值 + // 逻辑:只要任意一边给了 "edit",那就是 "edit" + if userPerm == "edit" || tokenPerm == "edit" { + finalPermission = "edit" + role = "editor" + } else if userPerm == "view" || tokenPerm == "view" { + finalPermission = "view" + role = "viewer" + } + + // ==================================================== + // 步骤 D: 最终判决 + // ==================================================== + if finalPermission == "" { + // 既没个人权限,Token 也不对(或者没 Token) + if userID == nil { + respondUnauthorized(c, "Authentication required") // 没登录且没Token + } else { + respondForbidden(c, "You don't have permission") // 登录了但没权限 + } + return + } + + c.JSON(http.StatusOK, models.PermissionResponse{ + Permission: finalPermission, + Role: role, + }) } \ No newline at end of file diff --git a/backend/internal/handlers/share.go b/backend/internal/handlers/share.go index 576f0ee..367e710 100644 --- a/backend/internal/handlers/share.go +++ b/backend/internal/handlers/share.go @@ -243,6 +243,16 @@ func (h *ShareHandler) GetShareLink(c *gin.Context) { return } + // Get the permission for the share link + permission, err := h.store.GetShareLinkPermission(c.Request.Context(), documentID) + if err != nil { + respondInternalError(c, "Failed to get share link permission", err) + return + } + if permission == "" { + permission = "edit" // Default fallback + } + frontendURL := os.Getenv("FRONTEND_URL") if frontendURL == "" { frontendURL = "http://localhost:5173" @@ -251,8 +261,9 @@ func (h *ShareHandler) GetShareLink(c *gin.Context) { shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token) c.JSON(http.StatusOK, gin.H{ - "url": shareURL, - "token": token, + "url": shareURL, + "token": token, + "permission": permission, }) } diff --git a/backend/internal/handlers/websocket.go b/backend/internal/handlers/websocket.go index 4865bca..b3aebac 100644 --- a/backend/internal/handlers/websocket.go +++ b/backend/internal/handlers/websocket.go @@ -114,18 +114,35 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { return } - // If authenticated with JWT, check document permissions + // Determine permission level + var permission string if userID != nil { - canView, err := wsh.store.CanViewDocument(c.Request.Context(), documentID, *userID) + // Authenticated user - get their permission level + perm, err := wsh.store.GetUserPermission(c.Request.Context(), documentID, *userID) if err != nil { - log.Printf("Error checking permissions: %v", err) + log.Printf("Error getting user permission: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"}) return } - if !canView { + if perm == "" { c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this document"}) return } + permission = perm + } else { + // Share token user - get share link permission + perm, err := wsh.store.GetShareLinkPermission(c.Request.Context(), documentID) + if err != nil { + log.Printf("Error getting share link permission: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"}) + return + } + if perm == "" { + // Share link doesn't exist or document isn't public + c.JSON(http.StatusForbidden, gin.H{"error": "Invalid share link"}) + return + } + permission = perm } // Upgrade connection @@ -135,9 +152,9 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { return } - // Create client with user information + // Create client with user information and permission clientID := uuid.New().String() - client := hub.NewClient(clientID, userID, userName, userAvatar, conn, wsh.hub, roomID) + client := hub.NewClient(clientID, userID, userName, userAvatar, permission, conn, wsh.hub, roomID) // Register client wsh.hub.Register <- client diff --git a/backend/internal/hub/hub.go b/backend/internal/hub/hub.go index ec98333..6d9c959 100644 --- a/backend/internal/hub/hub.go +++ b/backend/internal/hub/hub.go @@ -20,6 +20,7 @@ type Client struct { UserID *uuid.UUID // Authenticated user ID (nil for public share access) UserName string // User's display name for presence UserAvatar *string // User's avatar URL for presence + Permission string // User's permission level: "owner", "edit", "view" Conn *websocket.Conn send chan []byte sendMu sync.Mutex @@ -269,7 +270,9 @@ func (c *Client) ReadPump() { } break } - + if len(message) > 0 && message[0] == 1 { + log.Printf("DEBUG: 收到 Awareness (光标) 消息 from User %s Permission: %s", c.ID, c.Permission) +} // ========================================================== // 1. 偷听逻辑 (Sniff) - 必须放在转发之前! // ========================================================== @@ -288,7 +291,32 @@ func (c *Client) ReadPump() { } // ========================================================== - // 2. 转发逻辑 (Broadcast) - 恢复协作功能 + // 2. 权限检查 - 只有编辑权限的用户才能广播消息 + // ========================================================== + // ========================================================== + // 2. 权限检查 - 精细化拦截 (Fine-grained Permission Check) + // ========================================================== + if c.Permission == "view" { + // Yjs Protocol: + // message[0]: MessageType (0=Sync, 1=Awareness) + // message[1]: SyncMessageType (0=Step1, 1=Step2, 2=Update) + + // 只有当消息是 "Sync Update" (修改文档) 时,才拦截 + isSyncMessage := len(message) > 0 && message[0] == 0 + isUpdateOp := len(message) > 1 && message[1] == 2 + + if isSyncMessage && isUpdateOp { + log.Printf("🛡️ [Security] Blocked unauthorized WRITE from view-only user: %s", c.ID) + continue // ❌ 拦截修改 + } + + // ✅ 放行: + // 1. Awareness (光标): message[0] == 1 + // 2. SyncStep1/2 (握手加载文档): message[1] == 0 or 1 + } + + // ========================================================== + // 3. 转发逻辑 (Broadcast) - 恢复协作功能 // ========================================================== if messageType == websocket.BinaryMessage { // 注意:这里要检查 channel 是否已满,避免阻塞导致 ReadPump 卡死 @@ -341,16 +369,17 @@ func (c *Client) WritePump() { } -func NewClient(id string, userID *uuid.UUID, userName string, userAvatar *string, conn *websocket.Conn, hub *Hub, roomID string) *Client { +func NewClient(id string, userID *uuid.UUID, userName string, userAvatar *string, permission string, conn *websocket.Conn, hub *Hub, roomID string) *Client { return &Client{ - ID: id, - UserID: userID, - UserName: userName, - UserAvatar: userAvatar, - Conn: conn, - send: make(chan []byte, 1024), - hub: hub, - roomID: roomID, + ID: id, + UserID: userID, + UserName: userName, + UserAvatar: userAvatar, + Permission: permission, + Conn: conn, + send: make(chan []byte, 1024), + hub: hub, + roomID: roomID, observedYjsIDs: make(map[uint64]uint64), } } diff --git a/backend/internal/models/share.go b/backend/internal/models/share.go index 9690f17..69637d7 100644 --- a/backend/internal/models/share.go +++ b/backend/internal/models/share.go @@ -28,3 +28,9 @@ type DocumentShareWithUser struct { DocumentShare User User `json:"user"` } + +// PermissionResponse represents the user's permission level for a document +type PermissionResponse struct { + Permission string `json:"permission"` // "view" or "edit" + Role string `json:"role"` // "owner", "editor", or "viewer" +} diff --git a/backend/internal/store/postgres.go b/backend/internal/store/postgres.go index 0ce95bc..8da64e8 100644 --- a/backend/internal/store/postgres.go +++ b/backend/internal/store/postgres.go @@ -44,6 +44,8 @@ type Store interface { ValidateShareToken(ctx context.Context, documentID uuid.UUID, token string) (bool, error) RevokeShareToken(ctx context.Context, documentID uuid.UUID) error GetShareToken(ctx context.Context, documentID uuid.UUID) (string, bool, error) + GetUserPermission(ctx context.Context, documentID, userID uuid.UUID) (string, error) + GetShareLinkPermission(ctx context.Context, documentID uuid.UUID) (string, error) Close() error } diff --git a/backend/internal/store/share.go b/backend/internal/store/share.go index 780a180..c921425 100644 --- a/backend/internal/store/share.go +++ b/backend/internal/store/share.go @@ -129,16 +129,16 @@ func (s *PostgresStore) GenerateShareToken(ctx context.Context, documentID uuid. } token := base64.URLEncoding.EncodeToString(tokenBytes) - // Update document with share token + // Update document with share token and permission query := ` UPDATE documents - SET share_token = $1, is_public = true, updated_at = NOW() - WHERE id = $2 + SET share_token = $1, share_permission = $2, is_public = true, updated_at = NOW() + WHERE id = $3 RETURNING share_token ` var shareToken string - err := s.db.QueryRowContext(ctx, query, token, documentID).Scan(&shareToken) + err := s.db.QueryRowContext(ctx, query, token, permission, documentID).Scan(&shareToken) if err != nil { return "", fmt.Errorf("failed to set share token: %w", err) } @@ -197,4 +197,46 @@ func (s *PostgresStore) GetShareToken(ctx context.Context, documentID uuid.UUID) } return token, true, nil +} + +// GetUserPermission returns the permission level for a user on a document +// Returns "owner", "edit", "view", or "" if no access +func (s *PostgresStore) GetUserPermission(ctx context.Context, documentID, userID uuid.UUID) (string, error) { + query := ` + SELECT + CASE + WHEN EXISTS(SELECT 1 FROM documents WHERE id = $1 AND owner_id = $2) THEN 'owner' + WHEN EXISTS(SELECT 1 FROM document_shares WHERE document_id = $1 AND user_id = $2 AND permission = 'edit') THEN 'edit' + WHEN EXISTS(SELECT 1 FROM document_shares WHERE document_id = $1 AND user_id = $2 AND permission = 'view') THEN 'view' + ELSE '' + END as permission + ` + + var permission string + err := s.db.QueryRowContext(ctx, query, documentID, userID).Scan(&permission) + if err != nil { + return "", fmt.Errorf("failed to get user permission: %w", err) + } + + return permission, nil +} + +// GetShareLinkPermission returns the permission level for a public share link +// Returns the permission from documents.share_permission or "" if not public +func (s *PostgresStore) GetShareLinkPermission(ctx context.Context, documentID uuid.UUID) (string, error) { + query := ` + SELECT COALESCE(share_permission, 'edit') FROM documents + WHERE id = $1 AND is_public = true AND share_token IS NOT NULL + ` + + var permission string + err := s.db.QueryRowContext(ctx, query, documentID).Scan(&permission) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("failed to get share link permission: %w", err) + } + + return permission, nil } \ No newline at end of file diff --git a/backend/scripts/004_add_share_link_permission.sql b/backend/scripts/004_add_share_link_permission.sql new file mode 100644 index 0000000..bcf78e7 --- /dev/null +++ b/backend/scripts/004_add_share_link_permission.sql @@ -0,0 +1,12 @@ +-- Migration: Add permission column for public share links +-- Dependencies: Run after 003_add_public_sharing.sql +-- Purpose: Store permission level (view/edit) for public share links + +-- Add permission column to documents table +ALTER TABLE documents ADD COLUMN IF NOT EXISTS share_permission VARCHAR(20) DEFAULT 'edit' CHECK (share_permission IN ('view', 'edit')); + +-- Create index for performance +CREATE INDEX IF NOT EXISTS idx_documents_share_permission ON documents(share_permission) WHERE is_public = true; + +-- Documentation +COMMENT ON COLUMN documents.share_permission IS 'Permission level for public share link: view (read-only) or edit (read-write). Defaults to edit for backward compatibility.'; diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts index 9db1148..6851ce1 100644 --- a/frontend/src/api/document.ts +++ b/frontend/src/api/document.ts @@ -13,6 +13,11 @@ export type CreateDocumentRequest = { type: "editor" | "kanban"; } +export type PermissionResponse = { + permission: "view" | "edit"; + role: "owner" | "editor" | "viewer"; +} + export const documentsApi = { // List all documents list: async (): Promise<{ documents: DocumentType[]; total: number }> => { @@ -67,4 +72,15 @@ export const documentsApi = { }); if (!response.ok) throw new Error("Failed to update document state"); }, + + // Get user's permission for a document + getPermission: async (id: string, shareToken?: string): Promise => { + const url = shareToken + ? `${API_BASE_URL}/documents/${id}/permission?share=${shareToken}` + : `${API_BASE_URL}/documents/${id}/permission`; + + const response = await authFetch(url); + if (!response.ok) throw new Error("Failed to fetch document permission"); + return response.json(); + }, }; \ No newline at end of file diff --git a/frontend/src/components/Editor/Editor.tsx b/frontend/src/components/Editor/Editor.tsx index aa834e1..7cd2e5a 100644 --- a/frontend/src/components/Editor/Editor.tsx +++ b/frontend/src/components/Editor/Editor.tsx @@ -8,9 +8,12 @@ import Toolbar from "./Toolbar.tsx"; interface EditorProps { providers: YjsProviders; + permission: string | null; } -const Editor = ({ providers }: EditorProps) => { +const Editor = ({ providers, permission }: EditorProps) => { + const isEditable = permission !== "view"; + const editor = useEditor({ extensions: [ StarterKit.configure({ @@ -28,6 +31,7 @@ const Editor = ({ providers }: EditorProps) => { }), ], content: "", + editable: isEditable, }); useEffect(() => { @@ -49,8 +53,11 @@ const Editor = ({ providers }: EditorProps) => { return (
- - + {isEditable && } +
); }; diff --git a/frontend/src/components/Share/ShareModal.tsx b/frontend/src/components/Share/ShareModal.tsx index 83b07cc..30d6c77 100644 --- a/frontend/src/components/Share/ShareModal.tsx +++ b/frontend/src/components/Share/ShareModal.tsx @@ -6,9 +6,11 @@ import './ShareModal.css'; interface ShareModalProps { documentId: string; onClose: () => void; + currentPermission?: string; + currentRole?: string; } -function ShareModal({ documentId, onClose }: ShareModalProps) { +function ShareModal({ documentId, onClose, currentPermission, currentRole }: ShareModalProps) { const [activeTab, setActiveTab] = useState<'users' | 'link'>('users'); const [shares, setShares] = useState([]); const [shareLink, setShareLink] = useState(null); @@ -149,6 +151,31 @@ function ShareModal({ documentId, onClose }: ShareModalProps) { + {currentRole && ( +
+ + {currentRole === 'owner' ? '👑' : currentPermission === 'edit' ? '✏️' : '👁️'} + + + {currentRole === 'owner' && 'You are the owner'} + {currentRole === 'editor' && 'You have edit access'} + {currentRole === 'viewer' && 'You have view-only access'} + +
+ )} +
+ {permission === "view" && ( +
+ 👁️ + View only +
+ )}
{synced ? "✓ Synced" : "⟳ Syncing..."}
@@ -37,7 +43,7 @@ const EditorPage = () => {
- +
@@ -45,7 +51,12 @@ const EditorPage = () => {
{showShareModal && ( - setShowShareModal(false)} /> + setShowShareModal(false)} + currentPermission={permission || undefined} + currentRole={role || undefined} + /> )}
);