diff --git a/backend/internal/handlers/document.go b/backend/internal/handlers/document.go index 28be434..84b5061 100644 --- a/backend/internal/handlers/document.go +++ b/backend/internal/handlers/document.go @@ -137,6 +137,15 @@ func (h *DocumentHandler) GetDocumentState(c *gin.Context) { respondInternalError(c, "Failed to check permissions", err) return } + if !canView && shareToken != "" { + // Logged-in user without personal permission: fall back to share link + valid, err := h.store.ValidateShareToken(c.Request.Context(), id, shareToken) + if err != nil { + respondInternalError(c, "Failed to validate share token", err) + return + } + canView = valid + } if !canView { respondForbidden(c, "Access denied") return @@ -189,12 +198,28 @@ func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) { return } - // Check edit permission + // Check edit permission (personal share OR edit-level share link) + shareToken := c.Query("share") canEdit, err := h.store.CanEditDocument(c.Request.Context(), id, *userID) if err != nil { respondInternalError(c, "Failed to check permissions", err) return } + if !canEdit && shareToken != "" { + valid, err := h.store.ValidateShareToken(c.Request.Context(), id, shareToken) + if err != nil { + respondInternalError(c, "Failed to validate share token", err) + return + } + if valid { + perm, err := h.store.GetShareLinkPermission(c.Request.Context(), id) + if err != nil { + respondInternalError(c, "Failed to get token permission", err) + return + } + canEdit = perm == "edit" + } + } if !canEdit { respondForbidden(c, "Edit access denied") return diff --git a/backend/internal/handlers/document_test.go b/backend/internal/handlers/document_test.go index 2796912..51773dd 100644 --- a/backend/internal/handlers/document_test.go +++ b/backend/internal/handlers/document_test.go @@ -381,8 +381,6 @@ func (s *DocumentHandlerSuite) TestGetDocumentState_Success() { s.assertSuccessResponse(w, http.StatusOK) s.Equal("application/octet-stream", w.Header().Get("Content-Type")) - // State should be empty bytes for new document - s.NotNil(w.Body.Bytes()) } func (s *DocumentHandlerSuite) TestGetDocumentState_EmptyState() { @@ -416,6 +414,17 @@ func (s *DocumentHandlerSuite) TestGetDocumentState_InvalidID() { s.assertErrorResponse(w, http.StatusBadRequest, "bad_request", "Invalid document ID") } +func (s *DocumentHandlerSuite) TestGetDocumentState_AuthenticatedWithShareToken() { + // Charlie (logged in, not owner/shared) reads Alice's public doc via share link + path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, s.testData.PublicShareToken) + w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.CharlieID) + s.Require().NoError(err) + + s.router.ServeHTTP(w, httpReq) + s.assertSuccessResponse(w, http.StatusOK) + s.Equal("application/octet-stream", w.Header().Get("Content-Type")) +} + // ======================================== // UpdateDocumentState Tests // ======================================== @@ -458,6 +467,32 @@ func (s *DocumentHandlerSuite) TestUpdateDocumentState_ViewOnlyDenied() { s.router.ServeHTTP(w, httpReq) s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied") } +func (s *DocumentHandlerSuite) TestUpdateDocumentState_AuthenticatedWithEditShareToken() { + // Give Alice's public doc an "edit" share link + ctx := context.Background() + editToken, err := s.store.GenerateShareToken(ctx, s.testData.AlicePublicDoc, "edit") + s.Require().NoError(err) + + // Charlie (logged in, not owner/shared) edits via edit share link + req := models.UpdateStateRequest{State: []byte("shared edit")} + path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, editToken) + w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.CharlieID) + s.Require().NoError(err) + + s.router.ServeHTTP(w, httpReq) + s.assertSuccessResponse(w, http.StatusOK) +} +func (s *DocumentHandlerSuite) TestUpdateDocumentState_AuthenticatedWithViewShareTokenDenied() { + // Alice's public doc has a "view" share link (from seed). + // Charlie (logged in) cannot write via a view-only share link. + req := models.UpdateStateRequest{State: []byte("attempt write")} + path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, s.testData.PublicShareToken) + w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.CharlieID) + s.Require().NoError(err) + + s.router.ServeHTTP(w, httpReq) + s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied") +} func (s *DocumentHandlerSuite) TestUpdateDocumentState_Unauthorized() { req := models.UpdateStateRequest{ diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts index 8504040..1cc69d6 100644 --- a/frontend/src/api/document.ts +++ b/frontend/src/api/document.ts @@ -64,12 +64,16 @@ export const documentsApi = { }, // Update document Yjs state - updateState: async (id: string, state: Uint8Array): Promise => { + updateState: async (id: string, state: Uint8Array, shareToken?: string): Promise => { // Create a new ArrayBuffer copy to ensure compatibility const buffer = new ArrayBuffer(state.byteLength); new Uint8Array(buffer).set(state); - const response = await authFetch(`${API_BASE_URL}/documents/${id}/state`, { + const url = shareToken + ? `${API_BASE_URL}/documents/${id}/state?share=${shareToken}` + : `${API_BASE_URL}/documents/${id}/state`; + + const response = await authFetch(url, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: buffer, diff --git a/frontend/src/hooks/useAutoSave.ts b/frontend/src/hooks/useAutoSave.ts index a4a80d3..b68ce26 100644 --- a/frontend/src/hooks/useAutoSave.ts +++ b/frontend/src/hooks/useAutoSave.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import * as Y from 'yjs'; import { documentsApi } from '../api/document'; -export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => { +export const useAutoSave = (documentId: string, ydoc: Y.Doc | null, shareToken?: string) => { const saveTimeoutRef = useRef(null); const isSavingRef = useRef(false); @@ -25,7 +25,7 @@ export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => { isSavingRef.current = true; try { const state = Y.encodeStateAsUpdate(ydoc); - await documentsApi.updateState(documentId, state); + await documentsApi.updateState(documentId, state, shareToken); console.log('✓ Document saved to database'); } catch (error) { console.error('Failed to save document:', error); @@ -44,5 +44,5 @@ export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => { clearTimeout(saveTimeoutRef.current); } }; - }, [documentId, ydoc]); + }, [documentId, ydoc, shareToken]); }; diff --git a/frontend/src/hooks/useYjsDocument.ts b/frontend/src/hooks/useYjsDocument.ts index 7845977..406d2b6 100644 --- a/frontend/src/hooks/useYjsDocument.ts +++ b/frontend/src/hooks/useYjsDocument.ts @@ -17,7 +17,7 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => { const [role, setRole] = useState(null); // Enable auto-save when providers are ready - useAutoSave(documentId, providers?.ydoc || null); + useAutoSave(documentId, providers?.ydoc || null, shareToken); // Fetch permission when component mounts useEffect(() => {