Compare commits

..
6 Commits
Author SHA1 Message Date
M1ngdaXieandClaude Sonnet 5 5def3c14e8 ci: add manual Gitea Actions deploy workflow
Rebuilds and rolls out the backend, then builds and syncs the
frontend to nginx. Triggered manually via workflow_dispatch so pushes
don't auto-deploy to production.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 15:26:33 +08:00
M1ngdaXie f811fa1e52 Merge branch 'master' into self-hosted 2026-08-15 14:56:22 +08:00
M1ngdaXieandClaude Sonnet 5 f363193fba chore: remove redundant target from tsconfig.node.json
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 14:56:10 +08:00
M1ngdaXieandClaude Sonnet 5 aa67446f7c fix: allow share-link users to view/edit and autosave via share token
Users without personal document access can still load/save state when
a valid share token is present, matching the permission level granted
by the share link.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 14:55:59 +08:00
M1ngdaXie 7b5558bc94 fix: ensure redirect handling in LoginPage after user login 2026-03-15 03:17:28 -07:00
M1ngdaXie ce77e112ca fix: guest login now restores redirect URL from sessionStorage 2026-03-15 09:57:31 +00:00
8 changed files with 108 additions and 13 deletions
+30
View File
@@ -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/
+26 -1
View File
@@ -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
+37 -2
View File
@@ -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{
+6 -2
View File
@@ -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,
+3 -3
View File
@@ -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]);
}; };
+1 -1
View File
@@ -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(() => {
+5 -3
View File
@@ -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
View File
@@ -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"],