Compare commits
7
Commits
afb04e5cd3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e43773aa71 | ||
|
|
656976ff1e | ||
|
|
733a7218fa | ||
|
|
f363193fba | ||
|
|
aa67446f7c | ||
|
|
7b5558bc94 | ||
|
|
ce77e112ca |
@@ -0,0 +1,45 @@
|
|||||||
|
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: Import image into k3s containerd
|
||||||
|
# docker build only populates the Docker daemon's store; k3s runs its
|
||||||
|
# own containerd (imagePullPolicy: Never, never pulls a registry), so
|
||||||
|
# the image must be explicitly imported or the pod keeps running
|
||||||
|
# whatever was last imported.
|
||||||
|
run: docker save realtime-collab-backend:latest | k3s ctr -n k8s.io images import -
|
||||||
|
|
||||||
|
- 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
|
||||||
|
env:
|
||||||
|
# frontend/.env.local holds these for the manually-maintained
|
||||||
|
# /root/realtime-collab checkout, but it's gitignored, so this
|
||||||
|
# actions/checkout-based workspace never has it — without setting
|
||||||
|
# them here, Vite silently falls back to the localhost dev
|
||||||
|
# defaults and bakes a broken API URL into the production bundle.
|
||||||
|
VITE_API_URL: https://collab.m1ngdaxie.com/api
|
||||||
|
VITE_WS_URL: wss://collab.m1ngdaxie.com/ws
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy frontend
|
||||||
|
run: rsync -a --delete frontend/dist/ /var/www/realtime-collab/
|
||||||
+1
-2
@@ -41,5 +41,4 @@ loadtest/pprof
|
|||||||
|
|
||||||
/docs
|
/docs
|
||||||
|
|
||||||
# K3s secrets
|
.playwright-mcp/
|
||||||
k3s/secret.yaml
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -265,7 +265,3 @@ CREATE UNIQUE INDEX IF NOT EXISTS uniq_update_history_document_seq
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_update_history_document_seq
|
CREATE INDEX IF NOT EXISTS idx_update_history_document_seq
|
||||||
ON document_update_history(document_id, seq);
|
ON document_update_history(document_id, seq);
|
||||||
|
|
||||||
-- Add 'guest' as a valid provider for guest mode login
|
|
||||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_provider_check;
|
|
||||||
ALTER TABLE users ADD CONSTRAINT users_provider_check CHECK (provider IN ('google', 'github', 'guest'));
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: realtime-collab-backend
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: realtime-collab-backend
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: realtime-collab-backend
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: backend
|
|
||||||
image: realtime-collab-backend:latest
|
|
||||||
imagePullPolicy: Never
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
envFrom:
|
|
||||||
- secretRef:
|
|
||||||
name: realtime-collab-secret
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "32Mi"
|
|
||||||
cpu: "50m"
|
|
||||||
limits:
|
|
||||||
memory: "128Mi"
|
|
||||||
cpu: "300m"
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 10
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: realtime-collab-backend-svc
|
|
||||||
spec:
|
|
||||||
type: NodePort
|
|
||||||
selector:
|
|
||||||
app: realtime-collab-backend
|
|
||||||
ports:
|
|
||||||
- port: 8080
|
|
||||||
targetPort: 8080
|
|
||||||
nodePort: 30080
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: postgres-init-sql
|
|
||||||
data:
|
|
||||||
init.sql: |
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS documents (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
type VARCHAR(50) NOT NULL CHECK (type IN ('editor', 'kanban')),
|
|
||||||
yjs_state BYTEA,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_type ON documents(type);
|
|
||||||
CREATE INDEX idx_documents_created_at ON documents(created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS document_updates (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
update BYTEA NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_updates_document_id ON document_updates(document_id);
|
|
||||||
CREATE INDEX idx_updates_created_at ON document_updates(created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
avatar_url TEXT,
|
|
||||||
provider VARCHAR(50) NOT NULL CHECK (provider IN ('google', 'github', 'guest')),
|
|
||||||
provider_user_id VARCHAR(255) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
last_login_at TIMESTAMPTZ,
|
|
||||||
UNIQUE(provider, provider_user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_users_email ON users(email);
|
|
||||||
CREATE INDEX idx_users_provider ON users(provider, provider_user_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token_hash VARCHAR(64) NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
user_agent TEXT,
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
UNIQUE(token_hash)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
|
||||||
CREATE INDEX idx_sessions_token_hash ON sessions(token_hash);
|
|
||||||
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
|
||||||
|
|
||||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_documents_owner_id ON documents(owner_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS document_shares (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
permission VARCHAR(20) NOT NULL CHECK (permission IN ('view', 'edit')),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(document_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_shares_document_id ON document_shares(document_id);
|
|
||||||
CREATE INDEX idx_shares_user_id ON document_shares(user_id);
|
|
||||||
CREATE INDEX idx_shares_permission ON document_shares(document_id, permission);
|
|
||||||
|
|
||||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS share_token VARCHAR(255);
|
|
||||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT false NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_documents_share_token ON documents(share_token) WHERE share_token IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_documents_is_public ON documents(is_public) WHERE is_public = true;
|
|
||||||
|
|
||||||
ALTER TABLE documents ADD CONSTRAINT check_public_has_token
|
|
||||||
CHECK (is_public = false OR (is_public = true AND share_token IS NOT NULL));
|
|
||||||
|
|
||||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS share_permission VARCHAR(20) DEFAULT 'edit' CHECK (share_permission IN ('view', 'edit'));
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_documents_share_permission ON documents(share_permission) WHERE is_public = true;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
provider VARCHAR(50) NOT NULL,
|
|
||||||
access_token TEXT NOT NULL,
|
|
||||||
refresh_token TEXT,
|
|
||||||
token_type VARCHAR(50) DEFAULT 'Bearer',
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
scope TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
CONSTRAINT oauth_tokens_user_id_provider_key UNIQUE (user_id, provider)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_oauth_tokens_user_id ON oauth_tokens(user_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS document_versions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
yjs_snapshot BYTEA NOT NULL,
|
|
||||||
text_preview TEXT,
|
|
||||||
version_number INTEGER NOT NULL,
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
version_label TEXT,
|
|
||||||
is_auto_generated BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
CONSTRAINT unique_document_version UNIQUE(document_id, version_number)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_versions_document_id ON document_versions(document_id, created_at DESC);
|
|
||||||
CREATE INDEX idx_document_versions_created_by ON document_versions(created_by);
|
|
||||||
|
|
||||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS version_count INTEGER DEFAULT 0;
|
|
||||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS last_snapshot_at TIMESTAMPTZ;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION get_next_version_number(p_document_id UUID)
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
next_version INTEGER;
|
|
||||||
BEGIN
|
|
||||||
SELECT COALESCE(MAX(version_number), 0) + 1
|
|
||||||
INTO next_version
|
|
||||||
FROM document_versions
|
|
||||||
WHERE document_id = p_document_id;
|
|
||||||
RETURN next_version;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE document_updates ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE document_shares ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE document_versions ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY "Allow all operations on users" ON users FOR ALL USING (true);
|
|
||||||
CREATE POLICY "Allow all operations on sessions" ON sessions FOR ALL USING (true);
|
|
||||||
CREATE POLICY "Allow all operations on oauth_tokens" ON oauth_tokens FOR ALL USING (true);
|
|
||||||
CREATE POLICY "Allow all operations on documents" ON documents FOR ALL USING (true);
|
|
||||||
CREATE POLICY "Allow all operations on document_updates" ON document_updates FOR ALL USING (true);
|
|
||||||
CREATE POLICY "Allow all operations on document_shares" ON document_shares FOR ALL USING (true);
|
|
||||||
CREATE POLICY "Allow all operations on document_versions" ON document_versions FOR ALL USING (true);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stream_checkpoints (
|
|
||||||
document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
last_stream_id TEXT NOT NULL,
|
|
||||||
last_seq BIGINT NOT NULL DEFAULT 0,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_stream_checkpoints_updated_at ON stream_checkpoints(updated_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS document_update_history (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
stream_id TEXT NOT NULL,
|
|
||||||
seq BIGINT NOT NULL,
|
|
||||||
payload BYTEA NOT NULL,
|
|
||||||
msg_type TEXT,
|
|
||||||
server_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_update_history_document_stream_id ON document_update_history(document_id, stream_id);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_update_history_document_seq ON document_update_history(document_id, seq);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_update_history_document_seq ON document_update_history(document_id, seq);
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: postgres-pvc
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 5Gi
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: postgres
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: postgres
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: postgres
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: postgres
|
|
||||||
image: postgres:16-alpine
|
|
||||||
args: ["-c", "shared_buffers=128MB", "-c", "max_connections=50"]
|
|
||||||
ports:
|
|
||||||
- containerPort: 5432
|
|
||||||
envFrom:
|
|
||||||
- secretRef:
|
|
||||||
name: realtime-collab-secret
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "64Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
volumeMounts:
|
|
||||||
- name: postgres-data
|
|
||||||
mountPath: /var/lib/postgresql/data
|
|
||||||
- name: init-sql
|
|
||||||
mountPath: /docker-entrypoint-initdb.d
|
|
||||||
readinessProbe:
|
|
||||||
exec:
|
|
||||||
command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 10
|
|
||||||
volumes:
|
|
||||||
- name: postgres-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: postgres-pvc
|
|
||||||
- name: init-sql
|
|
||||||
configMap:
|
|
||||||
name: postgres-init-sql
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: postgres
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: postgres
|
|
||||||
ports:
|
|
||||||
- port: 5432
|
|
||||||
targetPort: 5432
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: redis-pvc
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 1Gi
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: redis
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: redis
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: redis
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: redis
|
|
||||||
image: redis:7-alpine
|
|
||||||
args: ["redis-server", "--appendonly", "yes", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
|
|
||||||
ports:
|
|
||||||
- containerPort: 6379
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "32Mi"
|
|
||||||
cpu: "50m"
|
|
||||||
limits:
|
|
||||||
memory: "128Mi"
|
|
||||||
cpu: "200m"
|
|
||||||
volumeMounts:
|
|
||||||
- name: redis-data
|
|
||||||
mountPath: /data
|
|
||||||
readinessProbe:
|
|
||||||
exec:
|
|
||||||
command: ["redis-cli", "ping"]
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
volumes:
|
|
||||||
- name: redis-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: redis-pvc
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: redis
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: redis
|
|
||||||
ports:
|
|
||||||
- port: 6379
|
|
||||||
targetPort: 6379
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: realtime-collab-secret
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
# Postgres
|
|
||||||
POSTGRES_USER: "replace"
|
|
||||||
POSTGRES_PASSWORD: "replace"
|
|
||||||
POSTGRES_DB: "replace"
|
|
||||||
# Backend
|
|
||||||
DATABASE_URL: "postgres://user:pass@postgres:5432/dbname?sslmode=disable"
|
|
||||||
REDIS_URL: "redis://redis:6379"
|
|
||||||
JWT_SECRET: "replace"
|
|
||||||
PORT: "8080"
|
|
||||||
ENVIRONMENT: "production"
|
|
||||||
BACKEND_URL: "https://collab.m1ngdaxie.com"
|
|
||||||
FRONTEND_URL: "https://collab.m1ngdaxie.com"
|
|
||||||
ALLOWED_ORIGINS: "https://collab.m1ngdaxie.com"
|
|
||||||
GOOGLE_CLIENT_ID: "replace"
|
|
||||||
GOOGLE_CLIENT_SECRET: "replace"
|
|
||||||
GOOGLE_REDIRECT_URL: "https://collab.m1ngdaxie.com/api/auth/google/callback"
|
|
||||||
GITHUB_CLIENT_ID: "replace"
|
|
||||||
GITHUB_CLIENT_SECRET: "replace"
|
|
||||||
GITHUB_REDIRECT_URL: "https://collab.m1ngdaxie.com/api/auth/github/callback"
|
|
||||||
Reference in New Issue
Block a user