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

@@ -234,3 +234,110 @@ func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"}) 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 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") frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" { if frontendURL == "" {
frontendURL = "http://localhost:5173" 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) shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"url": shareURL, "url": shareURL,
"token": token, "token": token,
"permission": permission,
}) })
} }

View File

@@ -114,18 +114,35 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
return return
} }
// If authenticated with JWT, check document permissions // Determine permission level
var permission string
if userID != nil { 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 { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
return return
} }
if !canView { if perm == "" {
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this document"}) c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this document"})
return 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 // Upgrade connection
@@ -135,9 +152,9 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
return return
} }
// Create client with user information // Create client with user information and permission
clientID := uuid.New().String() 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 // Register client
wsh.hub.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) UserID *uuid.UUID // Authenticated user ID (nil for public share access)
UserName string // User's display name for presence UserName string // User's display name for presence
UserAvatar *string // User's avatar URL for presence UserAvatar *string // User's avatar URL for presence
Permission string // User's permission level: "owner", "edit", "view"
Conn *websocket.Conn Conn *websocket.Conn
send chan []byte send chan []byte
sendMu sync.Mutex sendMu sync.Mutex
@@ -269,7 +270,9 @@ func (c *Client) ReadPump() {
} }
break break
} }
if len(message) > 0 && message[0] == 1 {
log.Printf("DEBUG: 收到 Awareness (光标) 消息 from User %s Permission: %s", c.ID, c.Permission)
}
// ========================================================== // ==========================================================
// 1. 偷听逻辑 (Sniff) - 必须放在转发之前! // 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 { if messageType == websocket.BinaryMessage {
// 注意:这里要检查 channel 是否已满,避免阻塞导致 ReadPump 卡死 // 注意:这里要检查 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{ return &Client{
ID: id, ID: id,
UserID: userID, UserID: userID,
UserName: userName, UserName: userName,
UserAvatar: userAvatar, UserAvatar: userAvatar,
Conn: conn, Permission: permission,
send: make(chan []byte, 1024), Conn: conn,
hub: hub, send: make(chan []byte, 1024),
roomID: roomID, hub: hub,
roomID: roomID,
observedYjsIDs: make(map[uint64]uint64), observedYjsIDs: make(map[uint64]uint64),
} }
} }

View File

@@ -28,3 +28,9 @@ type DocumentShareWithUser struct {
DocumentShare DocumentShare
User User `json:"user"` 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) ValidateShareToken(ctx context.Context, documentID uuid.UUID, token string) (bool, error)
RevokeShareToken(ctx context.Context, documentID uuid.UUID) error RevokeShareToken(ctx context.Context, documentID uuid.UUID) error
GetShareToken(ctx context.Context, documentID uuid.UUID) (string, bool, 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 Close() error
} }

View File

@@ -129,16 +129,16 @@ func (s *PostgresStore) GenerateShareToken(ctx context.Context, documentID uuid.
} }
token := base64.URLEncoding.EncodeToString(tokenBytes) token := base64.URLEncoding.EncodeToString(tokenBytes)
// Update document with share token // Update document with share token and permission
query := ` query := `
UPDATE documents UPDATE documents
SET share_token = $1, is_public = true, updated_at = NOW() SET share_token = $1, share_permission = $2, is_public = true, updated_at = NOW()
WHERE id = $2 WHERE id = $3
RETURNING share_token RETURNING share_token
` `
var shareToken string 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 { if err != nil {
return "", fmt.Errorf("failed to set share token: %w", err) return "", fmt.Errorf("failed to set share token: %w", err)
} }
@@ -198,3 +198,45 @@ func (s *PostgresStore) GetShareToken(ctx context.Context, documentID uuid.UUID)
return token, true, nil 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
}

View 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.';

View File

@@ -13,6 +13,11 @@ export type CreateDocumentRequest = {
type: "editor" | "kanban"; type: "editor" | "kanban";
} }
export type PermissionResponse = {
permission: "view" | "edit";
role: "owner" | "editor" | "viewer";
}
export const documentsApi = { export const documentsApi = {
// List all documents // List all documents
list: async (): Promise<{ documents: DocumentType[]; total: number }> => { 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"); 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<PermissionResponse> => {
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();
},
}; };

View File

@@ -8,9 +8,12 @@ import Toolbar from "./Toolbar.tsx";
interface EditorProps { interface EditorProps {
providers: YjsProviders; providers: YjsProviders;
permission: string | null;
} }
const Editor = ({ providers }: EditorProps) => { const Editor = ({ providers, permission }: EditorProps) => {
const isEditable = permission !== "view";
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
@@ -28,6 +31,7 @@ const Editor = ({ providers }: EditorProps) => {
}), }),
], ],
content: "", content: "",
editable: isEditable,
}); });
useEffect(() => { useEffect(() => {
@@ -49,8 +53,11 @@ const Editor = ({ providers }: EditorProps) => {
return ( return (
<div className="editor-container"> <div className="editor-container">
<Toolbar editor={editor} /> {isEditable && <Toolbar editor={editor} />}
<EditorContent editor={editor} className="editor-content" /> <EditorContent
editor={editor}
className={`editor-content ${!isEditable ? 'view-only' : ''}`}
/>
</div> </div>
); );
}; };

View File

@@ -6,9 +6,11 @@ import './ShareModal.css';
interface ShareModalProps { interface ShareModalProps {
documentId: string; documentId: string;
onClose: () => void; 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 [activeTab, setActiveTab] = useState<'users' | 'link'>('users');
const [shares, setShares] = useState<DocumentShareWithUser[]>([]); const [shares, setShares] = useState<DocumentShareWithUser[]>([]);
const [shareLink, setShareLink] = useState<ShareLink | null>(null); const [shareLink, setShareLink] = useState<ShareLink | null>(null);
@@ -149,6 +151,31 @@ function ShareModal({ documentId, onClose }: ShareModalProps) {
<button className="close-button" onClick={onClose}>×</button> <button className="close-button" onClick={onClose}>×</button>
</div> </div>
{currentRole && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 16px',
backgroundColor: currentRole === 'owner' ? '#e8f5e9' : currentPermission === 'edit' ? '#fff3e0' : '#e3f2fd',
borderRadius: '8px',
margin: '16px 0',
fontSize: '14px',
fontWeight: '500',
}}
>
<span style={{ fontSize: '18px' }}>
{currentRole === 'owner' ? '👑' : currentPermission === 'edit' ? '✏️' : '👁️'}
</span>
<span>
{currentRole === 'owner' && 'You are the owner'}
{currentRole === 'editor' && 'You have edit access'}
{currentRole === 'viewer' && 'You have view-only access'}
</span>
</div>
)}
<div className="tabs"> <div className="tabs">
<button <button
className={`tab ${activeTab === 'users' ? 'active' : ''}`} className={`tab ${activeTab === 'users' ? 'active' : ''}`}

View File

@@ -7,15 +7,36 @@ import {
type YjsProviders, type YjsProviders,
} from "../lib/yjs"; } from "../lib/yjs";
import { useAutoSave } from "./useAutoSave"; import { useAutoSave } from "./useAutoSave";
import { documentsApi } from "../api/document";
export const useYjsDocument = (documentId: string, shareToken?: string) => { export const useYjsDocument = (documentId: string, shareToken?: string) => {
const { user, token } = useAuth(); const { user, token } = useAuth();
const [providers, setProviders] = useState<YjsProviders | null>(null); const [providers, setProviders] = useState<YjsProviders | null>(null);
const [synced, setSynced] = useState(false); const [synced, setSynced] = useState(false);
const [permission, setPermission] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
// Enable auto-save when providers are ready // Enable auto-save when providers are ready
useAutoSave(documentId, providers?.ydoc || null); useAutoSave(documentId, providers?.ydoc || null);
// Fetch permission when component mounts
useEffect(() => {
if (!documentId) return;
const fetchPermission = async () => {
try {
const permData = await documentsApi.getPermission(documentId, shareToken);
setPermission(permData.permission);
setRole(permData.role);
console.log(`📋 Permission loaded: ${permData.role} (${permData.permission})`);
} catch (error) {
console.error("Failed to fetch permission:", error);
}
};
fetchPermission();
}, [documentId, shareToken]);
useEffect(() => { useEffect(() => {
// Wait for auth (unless we have a share token for public access) // Wait for auth (unless we have a share token for public access)
if (!shareToken && (!user || !token)) { if (!shareToken && (!user || !token)) {
@@ -73,6 +94,7 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
color: getColorFromUserId(currentId), color: getColorFromUserId(currentId),
avatar: currentAvatar, avatar: currentAvatar,
}); });
console.log(currentAvatar)
// NEW: Add awareness event logging // NEW: Add awareness event logging
const handleAwarenessChange = ({ const handleAwarenessChange = ({
@@ -154,5 +176,5 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
}, [documentId, user, token, shareToken]); }, [documentId, user, token, shareToken]);
return { providers, synced }; return { providers, synced, permission, role };
}; };

View File

@@ -11,7 +11,7 @@ const EditorPage = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const shareToken = searchParams.get('share') || undefined; const shareToken = searchParams.get('share') || undefined;
const { providers, synced } = useYjsDocument(id!, shareToken); const { providers, synced, permission, role } = useYjsDocument(id!, shareToken);
const [showShareModal, setShowShareModal] = useState(false); const [showShareModal, setShowShareModal] = useState(false);
if (!providers) { if (!providers) {
@@ -24,6 +24,12 @@ const EditorPage = () => {
<div className="page-header"> <div className="page-header">
<button onClick={() => navigate("/")}> Back to Home</button> <button onClick={() => navigate("/")}> Back to Home</button>
<div className="header-actions"> <div className="header-actions">
{permission === "view" && (
<div className="view-only-badge" style={{ display: 'flex', alignItems: 'center', gap: '4px', padding: '4px 12px', backgroundColor: '#f0f0f0', borderRadius: '4px', fontSize: '14px' }}>
<span>👁</span>
<span>View only</span>
</div>
)}
<div className="sync-status"> <div className="sync-status">
{synced ? "✓ Synced" : "⟳ Syncing..."} {synced ? "✓ Synced" : "⟳ Syncing..."}
</div> </div>
@@ -37,7 +43,7 @@ const EditorPage = () => {
<div className="page-content"> <div className="page-content">
<div className="main-area"> <div className="main-area">
<Editor providers={providers} /> <Editor providers={providers} permission={permission} />
</div> </div>
<div className="sidebar"> <div className="sidebar">
<UserList awareness={providers.awareness} /> <UserList awareness={providers.awareness} />
@@ -45,7 +51,12 @@ const EditorPage = () => {
</div> </div>
{showShareModal && ( {showShareModal && (
<ShareModal documentId={id!} onClose={() => setShowShareModal(false)} /> <ShareModal
documentId={id!}
onClose={() => setShowShareModal(false)}
currentPermission={permission || undefined}
currentRole={role || undefined}
/>
)} )}
</div> </div>
); );