feat: Implement document permission handling and sharing features

This commit is contained in:
M1ngdaXie
2026-01-10 21:19:12 -08:00
parent 6ba18854bf
commit 6b1ed8d11c
13 changed files with 340 additions and 31 deletions

View File

@@ -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,
})
}

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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),
}
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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
}