feat: Implement document permission handling and sharing features
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
12
backend/scripts/004_add_share_link_permission.sql
Normal file
12
backend/scripts/004_add_share_link_permission.sql
Normal file
@@ -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.';
|
||||
Reference in New Issue
Block a user