Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5def3c14e8 | ||
|
|
f811fa1e52 | ||
|
|
f363193fba | ||
|
|
aa67446f7c | ||
|
|
7b5558bc94 | ||
|
|
ce77e112ca |
@@ -0,0 +1,30 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest # hits the host runner (vps-runner) already on this box
|
||||||
|
env:
|
||||||
|
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build backend image
|
||||||
|
run: docker build -t realtime-collab-backend:latest ./backend
|
||||||
|
|
||||||
|
- name: Roll out backend
|
||||||
|
run: |
|
||||||
|
kubectl rollout restart deployment/realtime-collab-backend
|
||||||
|
kubectl rollout status deployment/realtime-collab-backend --timeout=90s
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy frontend
|
||||||
|
run: rsync -a --delete frontend/dist/ /var/www/realtime-collab/
|
||||||
@@ -137,6 +137,15 @@ func (h *DocumentHandler) GetDocumentState(c *gin.Context) {
|
|||||||
respondInternalError(c, "Failed to check permissions", err)
|
respondInternalError(c, "Failed to check permissions", err)
|
||||||
return
|
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 {
|
if !canView {
|
||||||
respondForbidden(c, "Access denied")
|
respondForbidden(c, "Access denied")
|
||||||
return
|
return
|
||||||
@@ -189,12 +198,28 @@ func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) {
|
|||||||
return
|
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)
|
canEdit, err := h.store.CanEditDocument(c.Request.Context(), id, *userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondInternalError(c, "Failed to check permissions", err)
|
respondInternalError(c, "Failed to check permissions", err)
|
||||||
return
|
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 {
|
if !canEdit {
|
||||||
respondForbidden(c, "Edit access denied")
|
respondForbidden(c, "Edit access denied")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -381,8 +381,6 @@ func (s *DocumentHandlerSuite) TestGetDocumentState_Success() {
|
|||||||
s.assertSuccessResponse(w, http.StatusOK)
|
s.assertSuccessResponse(w, http.StatusOK)
|
||||||
|
|
||||||
s.Equal("application/octet-stream", w.Header().Get("Content-Type"))
|
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() {
|
func (s *DocumentHandlerSuite) TestGetDocumentState_EmptyState() {
|
||||||
@@ -416,6 +414,17 @@ func (s *DocumentHandlerSuite) TestGetDocumentState_InvalidID() {
|
|||||||
s.assertErrorResponse(w, http.StatusBadRequest, "bad_request", "Invalid document ID")
|
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
|
// UpdateDocumentState Tests
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -458,6 +467,32 @@ func (s *DocumentHandlerSuite) TestUpdateDocumentState_ViewOnlyDenied() {
|
|||||||
s.router.ServeHTTP(w, httpReq)
|
s.router.ServeHTTP(w, httpReq)
|
||||||
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied")
|
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() {
|
func (s *DocumentHandlerSuite) TestUpdateDocumentState_Unauthorized() {
|
||||||
req := models.UpdateStateRequest{
|
req := models.UpdateStateRequest{
|
||||||
|
|||||||
@@ -64,12 +64,16 @@ export const documentsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Update document Yjs state
|
// Update document Yjs state
|
||||||
updateState: async (id: string, state: Uint8Array): Promise<void> => {
|
updateState: async (id: string, state: Uint8Array, shareToken?: string): Promise<void> => {
|
||||||
// Create a new ArrayBuffer copy to ensure compatibility
|
// Create a new ArrayBuffer copy to ensure compatibility
|
||||||
const buffer = new ArrayBuffer(state.byteLength);
|
const buffer = new ArrayBuffer(state.byteLength);
|
||||||
new Uint8Array(buffer).set(state);
|
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",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
body: buffer,
|
body: buffer,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { documentsApi } from '../api/document';
|
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<number | null>(null);
|
const saveTimeoutRef = useRef<number | null>(null);
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => {
|
|||||||
isSavingRef.current = true;
|
isSavingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const state = Y.encodeStateAsUpdate(ydoc);
|
const state = Y.encodeStateAsUpdate(ydoc);
|
||||||
await documentsApi.updateState(documentId, state);
|
await documentsApi.updateState(documentId, state, shareToken);
|
||||||
console.log('✓ Document saved to database');
|
console.log('✓ Document saved to database');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save document:', error);
|
console.error('Failed to save document:', error);
|
||||||
@@ -44,5 +44,5 @@ export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => {
|
|||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [documentId, ydoc]);
|
}, [documentId, ydoc, shareToken]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
|
|||||||
const [role, setRole] = 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, shareToken);
|
||||||
|
|
||||||
// Fetch permission when component mounts
|
// Fetch permission when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && user) {
|
if (!loading && user) {
|
||||||
navigate('/');
|
const redirect = searchParams.get('redirect');
|
||||||
|
navigate(redirect ? decodeURIComponent(redirect) : '/');
|
||||||
}
|
}
|
||||||
}, [user, loading, navigate]);
|
}, [user, loading, navigate, searchParams]);
|
||||||
|
|
||||||
const saveRedirectAndGo = (oauthUrl: string) => {
|
const saveRedirectAndGo = (oauthUrl: string) => {
|
||||||
const redirect = searchParams.get('redirect');
|
const redirect = searchParams.get('redirect');
|
||||||
@@ -40,7 +41,8 @@ function LoginPage() {
|
|||||||
setGuestLoading(true);
|
setGuestLoading(true);
|
||||||
const token = await guestLogin();
|
const token = await guestLogin();
|
||||||
await login(token);
|
await login(token);
|
||||||
const redirect = searchParams.get('redirect');
|
const redirect = searchParams.get('redirect') || sessionStorage.getItem('oauth_redirect');
|
||||||
|
sessionStorage.removeItem('oauth_redirect');
|
||||||
navigate(redirect ? decodeURIComponent(redirect) : '/');
|
navigate(redirect ? decodeURIComponent(redirect) : '/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Guest login failed:', err);
|
console.error('Guest login failed:', err);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|||||||
Reference in New Issue
Block a user