diff --git a/.gitignore b/.gitignore index 6066bf2..a0150ea 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,7 @@ postgres_data/ #test folder profiles loadtest/pprof -/docs \ No newline at end of file +/docs + +# K3s secrets +k3s/secret.yaml \ No newline at end of file diff --git a/backend/scripts/init.sql b/backend/scripts/init.sql index d99c7f4..2321a00 100644 --- a/backend/scripts/init.sql +++ b/backend/scripts/init.sql @@ -265,3 +265,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uniq_update_history_document_seq CREATE INDEX IF NOT EXISTS idx_update_history_document_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')); diff --git a/k3s/backend.yaml b/k3s/backend.yaml new file mode 100644 index 0000000..4201df1 --- /dev/null +++ b/k3s/backend.yaml @@ -0,0 +1,49 @@ +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 diff --git a/k3s/configmap.yaml b/k3s/configmap.yaml new file mode 100644 index 0000000..0e9d8f1 --- /dev/null +++ b/k3s/configmap.yaml @@ -0,0 +1,178 @@ +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); diff --git a/k3s/postgres.yaml b/k3s/postgres.yaml new file mode 100644 index 0000000..00381b2 --- /dev/null +++ b/k3s/postgres.yaml @@ -0,0 +1,69 @@ +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 diff --git a/k3s/redis.yaml b/k3s/redis.yaml new file mode 100644 index 0000000..67d3526 --- /dev/null +++ b/k3s/redis.yaml @@ -0,0 +1,61 @@ +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 diff --git a/k3s/secret.example.yaml b/k3s/secret.example.yaml new file mode 100644 index 0000000..ff54e98 --- /dev/null +++ b/k3s/secret.example.yaml @@ -0,0 +1,25 @@ +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"