From c75f976a2d98002cf0e91a335532503a3935e6e4 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 15 Feb 2026 22:50:06 +0000 Subject: [PATCH] [BACKEND] Initialize Go backend structure and dependencies (#23) --- .gitea/workflows/ci.yaml | 355 ++++++++++++++++++ Dockerfile.frontend | 26 +- Dockerfile.unified | 49 +-- VERSION | 1 + k8s/backend.yaml | 100 +++++ k8s/deployment.yaml | 135 +++++++ k8s/frontend.yaml | 80 ++++ k8s/ingress.yaml | 45 +++ k8s/lite.yaml | 104 +++++ ...02_full_schema.sql => 001_full_schema.sql} | 0 migrations/001_initial.sql | 125 ------ nginx.frontend.conf | 29 ++ scripts/db-bootstrap.sh | 61 +++ scripts/db-migrate.sh | 91 +++++ scripts/docker-entrypoint.sh | 28 ++ server/.env.example | 44 +++ server/Dockerfile | 26 +- server/config/config.go | 36 ++ server/database/database.go | 55 +++ server/go.mod | 6 +- server/main.go | 91 ++--- server/main_test.go | 48 ++- server/middleware/auth.go | 68 ++++ server/middleware/cors.go | 100 +++++ server/middleware/errors.go | 114 ++++++ server/middleware/logging.go | 130 +++++++ server/models/models.go | 148 ++++++++ server/version.go | 8 + src/js/version.js | 7 + 29 files changed, 1857 insertions(+), 253 deletions(-) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 VERSION create mode 100644 k8s/backend.yaml create mode 100644 k8s/deployment.yaml create mode 100644 k8s/frontend.yaml create mode 100644 k8s/ingress.yaml create mode 100644 k8s/lite.yaml rename migrations/{002_full_schema.sql => 001_full_schema.sql} (100%) delete mode 100644 migrations/001_initial.sql create mode 100644 nginx.frontend.conf create mode 100644 scripts/db-bootstrap.sh create mode 100644 scripts/db-migrate.sh create mode 100644 scripts/docker-entrypoint.sh create mode 100644 server/.env.example create mode 100644 server/config/config.go create mode 100644 server/database/database.go create mode 100644 server/middleware/auth.go create mode 100644 server/middleware/cors.go create mode 100644 server/middleware/errors.go create mode 100644 server/middleware/logging.go create mode 100644 server/models/models.go create mode 100644 server/version.go create mode 100644 src/js/version.js diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..f1b4d98 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,355 @@ +# .gitea/workflows/ci.yaml +# ============================================ +# Chat Switchboard - CI/CD Pipeline +# ============================================ +# Cluster deployments use SEPARATE FE + BE images. +# Unified image is for Docker Hub only (Docker Desktop use). +# +# Deployment mapping: +# PR → FE + BE :dev → wipe/migrate → dev.DOMAIN +# Push to main → FE + BE :test → migrate → test.DOMAIN +# Push to main → FE-only :lite → (no DB) → lite.DOMAIN +# Tag v* → FE + BE :latest → migrate → www.DOMAIN +# → Unified :latest → Docker Hub only (not deployed) +# +# Required Gitea Variables: +# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST +# +# Required Gitea Secrets: +# POSTGRES_USER, POSTGRES_PASSWORD +# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD +# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional) +# +# Global Variables (Gitea org-level): +# CERT_ISSUER_PROD +# ============================================ + +name: CI/CD + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + tags: ['v*'] + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.event.pull_request.number || gitea.ref }} + cancel-in-progress: true + +env: + REGISTRY: ${{ vars.REGISTRY }} + NAMESPACE: ${{ vars.NAMESPACE || 'gobha-ai' }} + DOMAIN: ${{ vars.DOMAIN || 'gobha.ai' }} + CERT_ISSUER: ${{ vars.CERT_ISSUER_PROD || 'letsencrypt-prod' }} + POSTGRES_HOST: ${{ vars.POSTGRES_HOST || 'postgres-primary.postgres.svc.cluster.local' }} + POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }} + FE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-fe + BE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-be + UNIFIED_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard + DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }} + +jobs: + # ── Stage 1: Go Build & Test ───────────────── + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Download and tidy + working-directory: server + run: | + go mod download + go mod tidy + + - name: Run tests + working-directory: server + run: go test -v -race ./... + + - name: Build check + working-directory: server + run: CGO_ENABLED=0 go build -o /dev/null . + + # ── Stage 2: Build, Database, Deploy ───────── + build-and-deploy: + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Add /tools to PATH + run: echo "/tools" >> $GITHUB_PATH + + - name: Install tools + run: | + apt-get update -qq && apt-get install -y -qq gettext-base postgresql-client > /dev/null + + # ── Determine environment ────────────────── + - name: Determine environment + id: env + run: | + if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then + echo "ENVIRONMENT=dev" >> "$GITHUB_OUTPUT" + echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT" + echo "DEPLOY_HOST=dev.${DOMAIN}" >> "$GITHUB_OUTPUT" + echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT" + echo "DB_NAME=chat_switchboard_dev" >> "$GITHUB_OUTPUT" + echo "DB_WIPE=true" >> "$GITHUB_OUTPUT" + echo "FE_REPLICAS=1" >> "$GITHUB_OUTPUT" + echo "BE_REPLICAS=1" >> "$GITHUB_OUTPUT" + echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT" + echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT" + echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT" + echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT" + echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" + echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" + echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT" + elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then + VERSION="${{ gitea.ref_name }}" + echo "ENVIRONMENT=production" >> "$GITHUB_OUTPUT" + echo "IMAGE_TAG=latest" >> "$GITHUB_OUTPUT" + echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT" + echo "DEPLOY_HOST=www.${DOMAIN}" >> "$GITHUB_OUTPUT" + echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT" + echo "DB_NAME=chat_switchboard" >> "$GITHUB_OUTPUT" + echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" + echo "FE_REPLICAS=2" >> "$GITHUB_OUTPUT" + echo "BE_REPLICAS=2" >> "$GITHUB_OUTPUT" + echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT" + echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT" + echo "FE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" + echo "FE_CPU_LIMIT=200m" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" + echo "BE_CPU_REQUEST=100m" >> "$GITHUB_OUTPUT" + echo "BE_CPU_LIMIT=500m" >> "$GITHUB_OUTPUT" + echo "is_release=true" >> "$GITHUB_OUTPUT" + echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT" + else + echo "ENVIRONMENT=test" >> "$GITHUB_OUTPUT" + echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT" + echo "DEPLOY_HOST=test.${DOMAIN}" >> "$GITHUB_OUTPUT" + echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT" + echo "DB_NAME=chat_switchboard_test" >> "$GITHUB_OUTPUT" + echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" + echo "FE_REPLICAS=1" >> "$GITHUB_OUTPUT" + echo "BE_REPLICAS=1" >> "$GITHUB_OUTPUT" + echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT" + echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT" + echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT" + echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT" + echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" + echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" + echo "env_label=test (main)" >> "$GITHUB_OUTPUT" + fi + + # ── Database Bootstrap ─────────────────────── + - name: Bootstrap database + env: + PGHOST: ${{ env.POSTGRES_HOST }} + PGPORT: ${{ env.POSTGRES_PORT }} + PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }} + PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }} + APP_USER: ${{ secrets.POSTGRES_USER }} + APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + DB_NAME: ${{ steps.env.outputs.DB_NAME }} + run: | + chmod +x scripts/db-bootstrap.sh + scripts/db-bootstrap.sh + + # ── Database Migration ─────────────────────── + - name: Run migrations + env: + PGHOST: ${{ env.POSTGRES_HOST }} + PGPORT: ${{ env.POSTGRES_PORT }} + PGUSER: ${{ secrets.POSTGRES_USER }} + PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + DB_NAME: ${{ steps.env.outputs.DB_NAME }} + DB_WIPE: ${{ steps.env.outputs.DB_WIPE }} + run: | + chmod +x scripts/db-migrate.sh + scripts/db-migrate.sh + + # ── Build Backend Image ────────────────────── + - name: Build backend image + run: | + docker build -f server/Dockerfile \ + -t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \ + ./server + + # ── Build Frontend Image (cluster mode) ────── + - name: Build frontend image + run: | + docker build -f Dockerfile.frontend \ + -t ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \ + . + + # ── Tag release versions ───────────────────── + - name: Tag release versions + if: steps.env.outputs.EXTRA_TAG != '' + run: | + docker tag ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + docker tag ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + + # ── Push to Internal Registry ──────────────── + - name: Push images + run: | + docker push ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} + docker push ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} + if [[ -n "${{ steps.env.outputs.EXTRA_TAG }}" ]]; then + docker push ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + fi + + # ── Build + Push Unified (release only) ────── + - name: Build unified image + if: steps.env.outputs.is_release == 'true' + run: | + docker build -f Dockerfile.unified \ + -t ${UNIFIED_IMAGE}:latest \ + -t ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \ + . + docker push ${UNIFIED_IMAGE}:latest + docker push ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + + - name: Push unified to Docker Hub + if: steps.env.outputs.is_release == 'true' && secrets.DOCKERHUB_TOKEN != '' + run: | + echo "${{ secrets.DOCKERHUB_TOKEN }}" | \ + docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + docker tag ${UNIFIED_IMAGE}:latest ${DOCKERHUB_IMAGE}:latest + docker tag ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + docker push ${DOCKERHUB_IMAGE}:latest + docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + + # ── Deploy to Kubernetes ───────────────────── + - name: Ensure namespace + run: | + kubectl create namespace ${NAMESPACE} 2>/dev/null || true + + - name: Sync secrets + run: | + kubectl create secret generic switchboard-db-credentials \ + --namespace=${NAMESPACE} \ + --from-literal=POSTGRES_USER=${{ secrets.POSTGRES_USER }} \ + --from-literal=POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \ + --dry-run=client -o yaml | kubectl apply -f - + + - name: Render and apply manifests + env: + ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} + IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }} + DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }} + DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }} + DB_NAME: ${{ steps.env.outputs.DB_NAME }} + FE_REPLICAS: ${{ steps.env.outputs.FE_REPLICAS }} + BE_REPLICAS: ${{ steps.env.outputs.BE_REPLICAS }} + FE_MEMORY_REQUEST: ${{ steps.env.outputs.FE_MEMORY_REQUEST }} + FE_MEMORY_LIMIT: ${{ steps.env.outputs.FE_MEMORY_LIMIT }} + FE_CPU_REQUEST: ${{ steps.env.outputs.FE_CPU_REQUEST }} + FE_CPU_LIMIT: ${{ steps.env.outputs.FE_CPU_LIMIT }} + BE_MEMORY_REQUEST: ${{ steps.env.outputs.BE_MEMORY_REQUEST }} + BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }} + BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }} + BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }} + run: | + envsubst < k8s/backend.yaml > /tmp/backend.yaml + envsubst < k8s/frontend.yaml > /tmp/frontend.yaml + envsubst < k8s/ingress.yaml > /tmp/ingress.yaml + + echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━" + kubectl apply -f /tmp/backend.yaml + kubectl apply -f /tmp/frontend.yaml + kubectl apply -f /tmp/ingress.yaml + + - name: Rollout and verify + run: | + SUFFIX="${{ steps.env.outputs.DEPLOY_SUFFIX }}" + ENV="${{ steps.env.outputs.ENVIRONMENT }}" + + kubectl rollout restart deployment/switchboard-be${SUFFIX} -n ${NAMESPACE} + kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE} + + kubectl rollout status deployment/switchboard-be${SUFFIX} -n ${NAMESPACE} --timeout=180s + kubectl rollout status deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE} --timeout=180s + + echo "" + echo "━━━ Pod Status ━━━" + kubectl get pods -n ${NAMESPACE} -l app=switchboard,env=${ENV} + + BE_READY=$(kubectl get deployment switchboard-be${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}') + FE_READY=$(kubectl get deployment switchboard-fe${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}') + + if [[ "${BE_READY:-0}" -gt 0 ]] && [[ "${FE_READY:-0}" -gt 0 ]]; then + echo "✅ Healthy: BE=${BE_READY} FE=${FE_READY} pods ready" + else + echo "❌ Unhealthy: BE=${BE_READY:-0} FE=${FE_READY:-0}" + kubectl logs -n ${NAMESPACE} -l app=switchboard,env=${ENV} --tail=30 + exit 1 + fi + + - name: Summary + run: | + HOST="${{ steps.env.outputs.DEPLOY_HOST }}" + echo "## ✅ Deployed to ${{ steps.env.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| | |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| **URL** | https://${HOST} |" >> $GITHUB_STEP_SUMMARY + echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY + if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then + echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY + fi + + # ── Stage 3: FE-Only Lite Deploy (main only) ─ + deploy-lite: + runs-on: ubuntu-latest + if: gitea.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Add /tools to PATH + run: echo "/tools" >> $GITHUB_PATH + + - name: Install tools + run: | + apt-get update -qq && apt-get install -y -qq gettext-base > /dev/null + + - name: Build lite image + run: | + docker build -f Dockerfile.frontend \ + -t ${FE_IMAGE}:lite \ + . + + - name: Push lite image + run: docker push ${FE_IMAGE}:lite + + - name: Deploy lite + env: + ENVIRONMENT: lite + IMAGE_TAG: lite + DEPLOY_HOST: "lite.${{ vars.DOMAIN || 'gobha.ai' }}" + run: | + export NAMESPACE="${NAMESPACE}" + export FE_IMAGE="${FE_IMAGE}" + export CERT_ISSUER="${CERT_ISSUER}" + envsubst < k8s/lite.yaml > /tmp/lite.yaml + kubectl create namespace ${NAMESPACE} 2>/dev/null || true + kubectl apply -f /tmp/lite.yaml + kubectl rollout restart deployment/switchboard-lite -n ${NAMESPACE} + kubectl rollout status deployment/switchboard-lite -n ${NAMESPACE} --timeout=120s + echo "✅ Lite deployed to https://${DEPLOY_HOST}" diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 493619b..decb9eb 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -1,37 +1,33 @@ # ========================================== -# Chat Switchboard Frontend Dockerfile +# Chat Switchboard - Frontend Dockerfile +# ========================================== +# Static SPA served by nginx. No API proxy — +# in cluster mode, the Ingress routes /api/ to +# the backend service directly. +# Also used for the lite (unmanaged) deployment. # ========================================== -# Build stage - using alpine, no Node.js needed (build.sh is just bash) +# Build stage FROM alpine:3.19 AS builder WORKDIR /app - -# Install bash and required tools RUN apk add --no-cache bash coreutils -# Copy build script and source COPY build.sh ./ COPY src ./src -# Make build script executable and run it RUN chmod +x build.sh && ./build.sh # Production stage -FROM nginx:alpine AS production +FROM nginx:alpine -# Copy custom nginx config -COPY nginx.conf /etc/nginx/conf.d/default.conf +# Static-only nginx config (no /api/ proxy) +COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf -# Copy built standalone files +# Built SPA files COPY --from=builder /app/standalone /usr/share/nginx/html -# Expose port EXPOSE 80 -# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 - -# Run nginx -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Dockerfile.unified b/Dockerfile.unified index 2cddf4d..7a23e62 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -1,25 +1,28 @@ -# ========================================== -# Chat Switchboard Unified Dockerfile -# Option B: Multi-stage build -# ========================================== +# ============================================ +# Chat Switchboard - Unified Dockerfile +# ============================================ +# Stage 1: Build Go backend +# Stage 2: Build standalone frontend +# Stage 3: Production image (nginx + backend) +# +# nginx serves frontend on :80, proxies /api/ to Go on :8080 +# ============================================ -# Stage 1: Backend build +# ── Stage 1: Go backend build ──────────────── FROM golang:1.22-bookworm AS backend WORKDIR /app -COPY server/go.mod ./ -COPY server/go.sum ./ -RUN go mod download +COPY server/go.mod server/go.sum* ./ +RUN go mod download && go mod verify COPY server/ . -RUN CGO_ENABLED=1 go build -o /backend chat-switchboard-api +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/switchboard . -# Stage 2: Frontend build +# ── Stage 2: Frontend build ────────────────── FROM alpine:3.19 AS frontend WORKDIR /app - RUN apk add --no-cache bash coreutils COPY build.sh ./ @@ -27,24 +30,22 @@ COPY src ./src RUN chmod +x build.sh && ./build.sh -# Stage 3: Production -FROM nginx:alpine AS production +# ── Stage 3: Production ───────────────────── +FROM nginx:1-alpine -# Copy backend binary -COPY --from=backend /backend /usr/local/bin/ +RUN apk add --no-cache bash -# Copy built frontend files +# Copy Go backend +COPY --from=backend /bin/switchboard /usr/local/bin/switchboard + +# Copy built frontend COPY --from=frontend /app/standalone /usr/share/nginx/html -# Copy custom nginx config for reverse proxy +# Copy nginx config COPY nginx.conf /etc/nginx/conf.d/default.conf -# Create startup script -RUN echo '#!/bin/sh\n\ -mkdir -p /app/data\n\ -/usr/local/bin/backend serve &\n\ -nginx -g "daemon off;"\n' > /start.sh && chmod +x /start.sh +# Startup script: launches Go backend before nginx starts +COPY scripts/docker-entrypoint.sh /docker-entrypoint.d/90-start-backend.sh +RUN chmod +x /docker-entrypoint.d/90-start-backend.sh EXPOSE 80 - -CMD ["/start.sh"] \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8a9ecc2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 \ No newline at end of file diff --git a/k8s/backend.yaml b/k8s/backend.yaml new file mode 100644 index 0000000..ef5c610 --- /dev/null +++ b/k8s/backend.yaml @@ -0,0 +1,100 @@ +# k8s/backend.yaml +# ============================================ +# Chat Switchboard - Backend Deployment +# ============================================ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: switchboard-be${DEPLOY_SUFFIX} + namespace: ${NAMESPACE} + labels: + app: switchboard + component: backend + env: ${ENVIRONMENT} +spec: + replicas: ${BE_REPLICAS} + selector: + matchLabels: + app: switchboard + component: backend + env: ${ENVIRONMENT} + template: + metadata: + labels: + app: switchboard + component: backend + env: ${ENVIRONMENT} + spec: + containers: + - name: backend + image: ${BE_IMAGE}:${IMAGE_TAG} + imagePullPolicy: Always + ports: + - containerPort: 8080 + name: http + env: + - name: ENVIRONMENT + value: "${ENVIRONMENT}" + - name: PORT + value: "8080" + - name: POSTGRES_HOST + value: "${POSTGRES_HOST}" + - name: POSTGRES_PORT + value: "${POSTGRES_PORT}" + - name: POSTGRES_DB + value: "${DB_NAME}" + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: switchboard-db-credentials + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: switchboard-db-credentials + key: POSTGRES_PASSWORD + - name: DATABASE_URL + value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable" + resources: + requests: + memory: "${BE_MEMORY_REQUEST}" + cpu: "${BE_CPU_REQUEST}" + limits: + memory: "${BE_MEMORY_LIMIT}" + cpu: "${BE_CPU_LIMIT}" + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: switchboard-be${DEPLOY_SUFFIX} + namespace: ${NAMESPACE} + labels: + app: switchboard + component: backend + env: ${ENVIRONMENT} +spec: + selector: + app: switchboard + component: backend + env: ${ENVIRONMENT} + ports: + - protocol: TCP + port: 8080 + targetPort: 8080 + name: http diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..e1f8980 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,135 @@ +# k8s/deployment.yaml +# ============================================ +# Chat Switchboard - Kubernetes Deployment Template +# ============================================ +# Variables substituted by CI/CD via envsubst: +# DEPLOY_NAME, ENVIRONMENT, IMAGE_TAG, BASE_PATH, +# DB_NAME, REPLICAS, NAMESPACE, REGISTRY, IMAGE_NAME, +# DOMAIN, CERT_ISSUER, POSTGRES_HOST, POSTGRES_PORT, +# MEMORY_REQUEST, MEMORY_LIMIT, CPU_REQUEST, CPU_LIMIT +# ============================================ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${DEPLOY_NAME} + namespace: ${NAMESPACE} + labels: + app: chat-switchboard + env: ${ENVIRONMENT} + service: chat-switchboard +spec: + replicas: ${REPLICAS} + selector: + matchLabels: + app: chat-switchboard + env: ${ENVIRONMENT} + template: + metadata: + labels: + app: chat-switchboard + env: ${ENVIRONMENT} + service: chat-switchboard + spec: + containers: + - name: switchboard + image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} + imagePullPolicy: Always + ports: + - containerPort: 80 + name: http + env: + - name: ENVIRONMENT + value: "${ENVIRONMENT}" + - name: BASE_PATH + value: "${BASE_PATH}" + - name: PORT + value: "8080" + # ── Database connection ── + - name: POSTGRES_HOST + value: "${POSTGRES_HOST}" + - name: POSTGRES_PORT + value: "${POSTGRES_PORT}" + - name: POSTGRES_DB + value: "${DB_NAME}" + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: switchboard-db-credentials + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: switchboard-db-credentials + key: POSTGRES_PASSWORD + - name: DATABASE_URL + value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable" + resources: + requests: + memory: "${MEMORY_REQUEST}" + cpu: "${CPU_REQUEST}" + limits: + memory: "${MEMORY_LIMIT}" + cpu: "${CPU_LIMIT}" + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: ${DEPLOY_NAME} + namespace: ${NAMESPACE} + labels: + app: chat-switchboard + env: ${ENVIRONMENT} +spec: + selector: + app: chat-switchboard + env: ${ENVIRONMENT} + ports: + - protocol: TCP + port: 80 + targetPort: 80 + name: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${DEPLOY_NAME} + namespace: ${NAMESPACE} + annotations: + cert-manager.io/cluster-issuer: "${CERT_ISSUER}" + traefik.ingress.kubernetes.io/router.entrypoints: websecure + labels: + app: chat-switchboard + env: ${ENVIRONMENT} +spec: + ingressClassName: "traefik" + tls: + - hosts: + - ${DOMAIN} + secretName: chat-switchboard-tls + rules: + - host: "${DOMAIN}" + http: + paths: + - path: ${BASE_PATH} + pathType: Prefix + backend: + service: + name: ${DEPLOY_NAME} + port: + number: 80 diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml new file mode 100644 index 0000000..19aef86 --- /dev/null +++ b/k8s/frontend.yaml @@ -0,0 +1,80 @@ +# k8s/frontend.yaml +# ============================================ +# Chat Switchboard - Frontend Deployment +# ============================================ +# Static nginx serving the SPA. No /api/ proxy here — +# the Ingress routes /api/ directly to the backend service. +# ============================================ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: switchboard-fe${DEPLOY_SUFFIX} + namespace: ${NAMESPACE} + labels: + app: switchboard + component: frontend + env: ${ENVIRONMENT} +spec: + replicas: ${FE_REPLICAS} + selector: + matchLabels: + app: switchboard + component: frontend + env: ${ENVIRONMENT} + template: + metadata: + labels: + app: switchboard + component: frontend + env: ${ENVIRONMENT} + spec: + containers: + - name: frontend + image: ${FE_IMAGE}:${IMAGE_TAG} + imagePullPolicy: Always + ports: + - containerPort: 80 + name: http + resources: + requests: + memory: "${FE_MEMORY_REQUEST}" + cpu: "${FE_CPU_REQUEST}" + limits: + memory: "${FE_MEMORY_LIMIT}" + cpu: "${FE_CPU_LIMIT}" + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: switchboard-fe${DEPLOY_SUFFIX} + namespace: ${NAMESPACE} + labels: + app: switchboard + component: frontend + env: ${ENVIRONMENT} +spec: + selector: + app: switchboard + component: frontend + env: ${ENVIRONMENT} + ports: + - protocol: TCP + port: 80 + targetPort: 80 + name: http diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..8b57d61 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,45 @@ +# k8s/ingress.yaml +# ============================================ +# Chat Switchboard - Ingress +# ============================================ +# Routes for a single subdomain: +# /api/* → backend service (Go API on :8080) +# /* → frontend service (nginx SPA on :80) +# ============================================ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: switchboard${DEPLOY_SUFFIX} + namespace: ${NAMESPACE} + annotations: + cert-manager.io/cluster-issuer: "${CERT_ISSUER}" + traefik.ingress.kubernetes.io/router.entrypoints: websecure + labels: + app: switchboard + env: ${ENVIRONMENT} +spec: + ingressClassName: "traefik" + tls: + - hosts: + - ${DEPLOY_HOST} + secretName: switchboard${DEPLOY_SUFFIX}-tls + rules: + - host: "${DEPLOY_HOST}" + http: + paths: + # API routes → backend + - path: /api + pathType: Prefix + backend: + service: + name: switchboard-be${DEPLOY_SUFFIX} + port: + number: 8080 + # Everything else → frontend + - path: / + pathType: Prefix + backend: + service: + name: switchboard-fe${DEPLOY_SUFFIX} + port: + number: 80 diff --git a/k8s/lite.yaml b/k8s/lite.yaml new file mode 100644 index 0000000..acbdc29 --- /dev/null +++ b/k8s/lite.yaml @@ -0,0 +1,104 @@ +# k8s/lite.yaml +# ============================================ +# Chat Switchboard - Lite (FE-Only) +# ============================================ +# Static SPA with no backend. All data in localStorage. +# Deployed to lite.DOMAIN on push to main. +# ============================================ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: switchboard-lite + namespace: ${NAMESPACE} + labels: + app: switchboard + component: frontend + env: lite +spec: + replicas: 1 + selector: + matchLabels: + app: switchboard + component: frontend + env: lite + template: + metadata: + labels: + app: switchboard + component: frontend + env: lite + spec: + containers: + - name: frontend + image: ${FE_IMAGE}:${IMAGE_TAG} + imagePullPolicy: Always + ports: + - containerPort: 80 + name: http + resources: + requests: + memory: "32Mi" + cpu: "10m" + limits: + memory: "64Mi" + cpu: "50m" + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 30 +--- +apiVersion: v1 +kind: Service +metadata: + name: switchboard-lite + namespace: ${NAMESPACE} + labels: + app: switchboard + env: lite +spec: + selector: + app: switchboard + component: frontend + env: lite + ports: + - protocol: TCP + port: 80 + targetPort: 80 + name: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: switchboard-lite + namespace: ${NAMESPACE} + annotations: + cert-manager.io/cluster-issuer: "${CERT_ISSUER}" + traefik.ingress.kubernetes.io/router.entrypoints: websecure + labels: + app: switchboard + env: lite +spec: + ingressClassName: "traefik" + tls: + - hosts: + - ${DEPLOY_HOST} + secretName: switchboard-lite-tls + rules: + - host: "${DEPLOY_HOST}" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: switchboard-lite + port: + number: 80 diff --git a/migrations/002_full_schema.sql b/migrations/001_full_schema.sql similarity index 100% rename from migrations/002_full_schema.sql rename to migrations/001_full_schema.sql diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql deleted file mode 100644 index 6d8415d..0000000 --- a/migrations/001_initial.sql +++ /dev/null @@ -1,125 +0,0 @@ --- ========================================== --- Chat Switchboard - Initial Schema --- ========================================== --- PostgreSQL migration for backend support - --- Enable UUID extension -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Users table (optional, for multi-user support) -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - email VARCHAR(255) UNIQUE, - password_hash VARCHAR(255), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- API configurations per user -CREATE TABLE api_configs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - name VARCHAR(100) NOT NULL DEFAULT 'default', - endpoint VARCHAR(500) NOT NULL, - api_key_encrypted TEXT NOT NULL, - is_default BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE(user_id, name) -); - --- User settings -CREATE TABLE user_settings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID UNIQUE REFERENCES users(id) ON DELETE CASCADE, - default_model VARCHAR(100) DEFAULT 'gpt-3.5-turbo', - stream_responses BOOLEAN DEFAULT TRUE, - show_thinking BOOLEAN DEFAULT TRUE, - system_prompt TEXT, - max_tokens INTEGER DEFAULT 4096, - temperature DECIMAL(3,2) DEFAULT 0.7, - top_p DECIMAL(3,2) DEFAULT 1.0, - presence_penalty DECIMAL(3,2) DEFAULT 0.0, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Chats -CREATE TABLE chats ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - title VARCHAR(255) NOT NULL, - model VARCHAR(100), - api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL, - is_archived BOOLEAN DEFAULT FALSE, - is_pinned BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Messages -CREATE TABLE messages ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, - role VARCHAR(20) NOT NULL CHECK (role IN ('system', 'user', 'assistant')), - content TEXT NOT NULL, - model VARCHAR(100), - tokens_used INTEGER, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Cached models per API config -CREATE TABLE cached_models ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - api_config_id UUID NOT NULL REFERENCES api_configs(id) ON DELETE CASCADE, - model_id VARCHAR(100) NOT NULL, - owned_by VARCHAR(100), - cached_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE(api_config_id, model_id) -); - --- Indexes for performance -CREATE INDEX idx_chats_user_id ON chats(user_id); -CREATE INDEX idx_chats_updated_at ON chats(updated_at DESC); -CREATE INDEX idx_messages_chat_id ON messages(chat_id); -CREATE INDEX idx_messages_created_at ON messages(created_at); -CREATE INDEX idx_api_configs_user_id ON api_configs(user_id); - --- Updated at trigger function -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - --- Apply updated_at triggers -CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_api_configs_updated_at BEFORE UPDATE ON api_configs - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_user_settings_updated_at BEFORE UPDATE ON user_settings - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_chats_updated_at BEFORE UPDATE ON chats - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- View for chat list with message count -CREATE VIEW chat_list AS -SELECT - c.id, - c.user_id, - c.title, - c.model, - c.is_archived, - c.is_pinned, - c.created_at, - c.updated_at, - COUNT(m.id) as message_count, - MAX(m.created_at) as last_message_at -FROM chats c -LEFT JOIN messages m ON m.chat_id = c.id -GROUP BY c.id; diff --git a/nginx.frontend.conf b/nginx.frontend.conf new file mode 100644 index 0000000..813757c --- /dev/null +++ b/nginx.frontend.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript; + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/scripts/db-bootstrap.sh b/scripts/db-bootstrap.sh new file mode 100644 index 0000000..13ff80a --- /dev/null +++ b/scripts/db-bootstrap.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# ============================================ +# Chat Switchboard - Database Bootstrap +# ============================================ +# Idempotent: safe to run on every CI build. +# Creates the app role and database if they +# don't exist. Runs as the Postgres superuser. +# +# Required env vars: +# PGHOST, PGPORT, PGUSER, PGPASSWORD — admin connection +# APP_USER, APP_PASSWORD — app-level credentials +# DB_NAME — target database name +# ============================================ + +set -euo pipefail + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Database bootstrap: ${DB_NAME}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# ── 1. Create role if not exists ───────────── +ROLE_EXISTS=$(psql -tAc "SELECT 1 FROM pg_roles WHERE rolname = '${APP_USER}';" postgres) + +if [[ "${ROLE_EXISTS}" == "1" ]]; then + echo "✓ Role '${APP_USER}' already exists" + # Update password in case it changed + psql -c "ALTER ROLE ${APP_USER} WITH PASSWORD '${APP_PASSWORD}';" postgres + echo "✓ Password synced" +else + psql -c "CREATE ROLE ${APP_USER} WITH LOGIN PASSWORD '${APP_PASSWORD}';" postgres + echo "✓ Role '${APP_USER}' created" +fi + +# ── 2. Create database if not exists ───────── +DB_EXISTS=$(psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}';" postgres) + +if [[ "${DB_EXISTS}" == "1" ]]; then + echo "✓ Database '${DB_NAME}' already exists" +else + psql -c "CREATE DATABASE ${DB_NAME} OWNER ${APP_USER};" postgres + echo "✓ Database '${DB_NAME}' created" +fi + +# ── 3. Ensure extensions (requires superuser) ─ +psql -d "${DB_NAME}" <<'SQL' +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "vector"; +SQL +echo "✓ Extensions verified (uuid-ossp, pgcrypto)" + +# ── 4. Grant privileges ───────────────────── +psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${APP_USER};" +psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${APP_USER};" +psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER};" +psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER};" +echo "✓ Privileges granted" + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Bootstrap complete: ${DB_NAME}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/scripts/db-migrate.sh b/scripts/db-migrate.sh new file mode 100644 index 0000000..5586945 --- /dev/null +++ b/scripts/db-migrate.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# ============================================ +# Chat Switchboard - Database Migration Runner +# ============================================ +# Tracks applied migrations in schema_migrations. +# In dev (DB_WIPE=true): drops all tables, re-runs +# all migrations for a clean slate every PR build. +# +# Required env vars: +# PGHOST, PGPORT, PGUSER, PGPASSWORD — app-level connection +# DB_NAME — target database +# DB_WIPE — "true" to wipe first (dev only) +# ============================================ + +set -euo pipefail + +MIGRATIONS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../migrations" && pwd)" + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Migration runner: ${DB_NAME}" +echo " Wipe mode: ${DB_WIPE}" +echo " Migrations: ${MIGRATIONS_DIR}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +export PGDATABASE="${DB_NAME}" + +# ── Dev wipe: drop everything and start fresh ─ +if [[ "${DB_WIPE}" == "true" ]]; then + echo "⚠ Dev wipe: dropping all objects..." + psql <<'SQL' +DO $$ DECLARE + r RECORD; +BEGIN + -- Drop all views + FOR r IN (SELECT viewname FROM pg_views WHERE schemaname = 'public') LOOP + EXECUTE 'DROP VIEW IF EXISTS public.' || quote_ident(r.viewname) || ' CASCADE'; + END LOOP; + -- Drop all tables (including schema_migrations for clean slate) + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + -- Drop all functions (skip extension-owned like uuid_nil etc) + FOR r IN (SELECT ns.nspname || '.' || p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' AS func + FROM pg_proc p JOIN pg_namespace ns ON p.pronamespace = ns.oid + WHERE ns.nspname = 'public' + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.objid = p.oid AND d.deptype = 'e' + )) LOOP + EXECUTE 'DROP FUNCTION IF EXISTS ' || r.func || ' CASCADE'; + END LOOP; +END $$; +SQL + echo "✓ All public schema objects dropped" +fi + +# ── Ensure tracking table exists ────────────── +psql <<'SQL' +CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +SQL + +# ── Apply pending migrations ────────────────── +APPLIED=0 +SKIPPED=0 + +for migration in "${MIGRATIONS_DIR}"/*.sql; do + [[ -f "${migration}" ]] || continue + + VERSION=$(basename "${migration}") + + ALREADY=$(psql -tAc "SELECT 1 FROM schema_migrations WHERE version = '${VERSION}';") + + if [[ "${ALREADY}" == "1" ]]; then + echo " skip: ${VERSION} (already applied)" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + echo " apply: ${VERSION}..." + psql -v ON_ERROR_STOP=1 -f "${migration}" + psql -c "INSERT INTO schema_migrations (version) VALUES ('${VERSION}');" + APPLIED=$((APPLIED + 1)) + echo " ✓ ${VERSION} applied" +done + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Migrations complete: ${APPLIED} applied, ${SKIPPED} skipped" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" \ No newline at end of file diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..f905b01 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# ============================================ +# Chat Switchboard - Backend Launcher +# ============================================ +# Runs as an nginx entrypoint.d hook. +# Starts the Go backend in the background +# before nginx begins accepting connections. +# ============================================ + +set -e + +echo "🔀 Starting Chat Switchboard backend..." + +# Launch Go backend in background +/usr/local/bin/switchboard & +BACKEND_PID=$! + +# Wait for backend to be ready (max 10s) +for i in $(seq 1 20); do + if wget -q --spider http://localhost:${PORT:-8080}/health 2>/dev/null; then + echo "✅ Backend ready (PID ${BACKEND_PID})" + exit 0 + fi + sleep 0.5 +done + +echo "❌ Backend failed to start within 10s" +exit 1 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..cc54a24 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,44 @@ +# Chat Switchboard - Server Environment Variables +# Copy this file to .env and fill in the values + +# Server settings +PORT=8080 +ENVIRONMENT=development +DEBUG=true + +# Database settings (PostgreSQL) +DB_HOST=localhost +DB_PORT=5432 +DB_USER=chat_switchboard +DB_PASSWORD=your-secure-password-here +DB_NAME=chat_switchboard +DB_SSL_MODE=disable +DB_MAX_CONNS=25 + +# JWT settings +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRATION=24h +JWT_ISSUER=chat-switchboard + +# CORS settings (comma-separated for multiple origins) +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 +CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS,PATCH +CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With,Accept,Origin + +# Rate limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=1m + +# Logging +LOG_LEVEL=info + +# Optional: Redis configuration (for WebSocket pub/sub and session cache) +# REDIS_HOST=localhost +# REDIS_PORT=6379 +# REDIS_DB=0 +# REDIS_PASSWORD= + +# Optional: External services +# OPENAI_API_KEY= +# ANTHROPIC_API_KEY= +# GOOGLE_API_KEY= \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile index 9fa708e..4a43a41 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,8 @@ # ========================================== -# Chat Switchboard Backend Dockerfile +# Chat Switchboard - Backend Dockerfile +# ========================================== +# Standalone Go API server for cluster deployment. +# Build context is ./server (not repo root). # ========================================== # Build stage @@ -7,16 +10,14 @@ FROM golang:1.22-bookworm AS builder WORKDIR /app -# Copy go mod files first for caching -# Build context is ./server, so paths are relative to that -COPY go.mod go.sum ./ +# Copy module files and download deps +COPY go.mod go.sum* ./ RUN go mod download -# Copy source code from server directory +# Copy source and tidy (resolves any go.sum gaps) COPY . . - -# Build the application -RUN CGO_ENABLED=1 go build -o /bin/engine . +RUN go mod tidy +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/switchboard . # Runtime stage FROM debian:bookworm-slim @@ -25,11 +26,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /bin/engine /usr/local/bin/engine +COPY --from=builder /bin/switchboard /usr/local/bin/switchboard -WORKDIR /app +EXPOSE 8080 -VOLUME ["/app/data"] - -ENTRYPOINT ["engine"] -CMD ["serve"] \ No newline at end of file +ENTRYPOINT ["switchboard"] diff --git a/server/config/config.go b/server/config/config.go new file mode 100644 index 0000000..211848b --- /dev/null +++ b/server/config/config.go @@ -0,0 +1,36 @@ +package config + +import ( + "os" + + "github.com/joho/godotenv" +) + +// Config holds all application configuration. +type Config struct { + Port string + DatabaseURL string + JWTSecret string + Environment string +} + +// Load reads configuration from environment variables. +// A .env file is loaded if present but not required. +func Load() *Config { + // Best-effort .env load (not required in production) + _ = godotenv.Load() + + return &Config{ + Port: getEnv("PORT", "8080"), + DatabaseURL: getEnv("DATABASE_URL", ""), + JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"), + Environment: getEnv("ENVIRONMENT", "development"), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/server/database/database.go b/server/database/database.go new file mode 100644 index 0000000..5fa1c98 --- /dev/null +++ b/server/database/database.go @@ -0,0 +1,55 @@ +package database + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/lib/pq" + + "git.gobha.me/xcaliber/chat-switchboard/config" +) + +// DB is the application-wide database connection pool. +var DB *sql.DB + +// Connect opens a connection pool to PostgreSQL using the +// DATABASE_URL from config. It pings to verify connectivity. +func Connect(cfg *config.Config) error { + if cfg.DatabaseURL == "" { + log.Println("⚠ DATABASE_URL not set — running without database") + return nil + } + + var err error + DB, err = sql.Open("postgres", cfg.DatabaseURL) + if err != nil { + return fmt.Errorf("database open: %w", err) + } + + // Connection pool tuning + DB.SetMaxOpenConns(25) + DB.SetMaxIdleConns(5) + + if err := DB.Ping(); err != nil { + return fmt.Errorf("database ping: %w", err) + } + + log.Println("✅ Database connected") + return nil +} + +// Close gracefully shuts down the connection pool. +func Close() { + if DB != nil { + DB.Close() + } +} + +// IsConnected returns true if a database connection is available. +func IsConnected() bool { + if DB == nil { + return false + } + return DB.Ping() == nil +} diff --git a/server/go.mod b/server/go.mod index 94b248f..7c1d6dd 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,10 +1,12 @@ module git.gobha.me/xcaliber/chat-switchboard -go 1.21 +go 1.22 require ( github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 ) require ( @@ -32,4 +34,4 @@ require ( golang.org/x/text v0.13.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect -) \ No newline at end of file +) diff --git a/server/main.go b/server/main.go index ff2a63e..53cdb63 100644 --- a/server/main.go +++ b/server/main.go @@ -2,54 +2,48 @@ package main import ( "log" - "os" "github.com/gin-gonic/gin" - "github.com/joho/godotenv" + + "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/middleware" ) func main() { - // Load .env file if present - if err := godotenv.Load(); err != nil { - log.Printf("Warning: could not load .env file: %v", err) - } + // Load configuration from environment + cfg := config.Load() - // Get port from environment - port := os.Getenv("PORT") - if port == "" { - port = "8080" + // Connect to database (optional — runs without it in unmanaged mode) + if err := database.Connect(cfg); err != nil { + log.Printf("⚠ Database unavailable: %v", err) + log.Println(" Running in unmanaged mode (no persistence)") } + defer database.Close() // Initialize router r := gin.Default() - - // CORS middleware - r.Use(func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) - return - } - c.Next() - }) + r.Use(middleware.CORS()) // Health check r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) + status := gin.H{ + "status": "ok", + "database": database.IsConnected(), + } + c.JSON(200, status) }) // API routes api := r.Group("/api/v1") { - // Auth routes + // Public auth routes api.POST("/auth/register", handleRegister) api.POST("/auth/login", handleLogin) // Protected routes protected := api.Group("") - protected.Use(authMiddleware()) + protected.Use(middleware.Auth(cfg)) { // Chats protected.GET("/chats", handleListChats) @@ -76,36 +70,25 @@ func main() { } } - log.Printf("🔀 Chat Switchboard API starting on port %s", port) - if err := r.Run(":" + port); err != nil { + log.Printf("🔀 Chat Switchboard API starting on port %s", cfg.Port) + if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("Failed to start server: %v", err) } } -// Placeholder handlers - implement in handlers/ package -func handleRegister(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleLogin(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateAPIConfig(c *gin.Context) { - c.JSON(501, gin.H{"error": "not implemented"}) -} -func handleDeleteAPIConfig(c *gin.Context) { - c.JSON(501, gin.H{"error": "not implemented"}) -} -func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } - -func authMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - // TODO: Implement JWT validation - c.Next() - } -} \ No newline at end of file +// Placeholder handlers — will be moved to handlers/ package +func handleRegister(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleLogin(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleCreateAPIConfig(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleDeleteAPIConfig(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } diff --git a/server/main_test.go b/server/main_test.go index 689356d..6089aa8 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -6,13 +6,16 @@ import ( "testing" "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/middleware" ) func TestHealthEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) + c.JSON(200, gin.H{"status": "ok", "database": false}) }) w := httptest.NewRecorder() @@ -22,29 +25,18 @@ func TestHealthEndpoint(t *testing.T) { if w.Code != http.StatusOK { t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) } - - expectedBody := `{"status":"ok"}` - if w.Body.String() != expectedBody { - t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String()) - } } func TestCORSHeaders(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - r.Use(func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) - return - } - c.Next() + r.Use(middleware.CORS()) + r.GET("/test", func(c *gin.Context) { + c.Status(200) }) w := httptest.NewRecorder() - req, _ := http.NewRequest("OPTIONS", "/api/v1/test", nil) + req, _ := http.NewRequest("OPTIONS", "/test", nil) r.ServeHTTP(w, req) if w.Code != http.StatusNoContent { @@ -60,7 +52,6 @@ func TestRouterSetup(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - // Setup routes like in main.go r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) @@ -73,7 +64,6 @@ func TestRouterSetup(t *testing.T) { api.POST("/chats", handleCreateChat) } - // Verify routes are registered routes := r.Routes() routePaths := make(map[string]bool) for _, route := range routes { @@ -93,4 +83,24 @@ func TestRouterSetup(t *testing.T) { t.Errorf("Expected route %s to be registered", expected) } } -} \ No newline at end of file +} + +func TestAuthMiddlewareNoDatabase(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + + cfg := &config.Config{JWTSecret: "test-secret"} + // No database connected — middleware should pass through + r.Use(middleware.Auth(cfg)) + r.GET("/protected", func(c *gin.Context) { + c.JSON(200, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/protected", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected pass-through with no DB, got status %d", w.Code) + } +} diff --git a/server/middleware/auth.go b/server/middleware/auth.go new file mode 100644 index 0000000..7c09f12 --- /dev/null +++ b/server/middleware/auth.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// Claims represents the JWT payload. +type Claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +// Auth returns a Gin middleware that validates JWT bearer tokens. +// When no database is connected (unmanaged mode), all requests +// are allowed through without authentication. +func Auth(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + // Skip auth when running without a database (unmanaged mode) + if !database.IsConnected() { + c.Next() + return + } + + header := c.GetHeader("Authorization") + if header == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "missing authorization header", + }) + return + } + + tokenString := strings.TrimPrefix(header, "Bearer ") + if tokenString == header { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid authorization format", + }) + return + } + + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(cfg.JWTSecret), nil + }) + + if err != nil || !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid or expired token", + }) + return + } + + // Store claims in context for downstream handlers + c.Set("user_id", claims.UserID) + c.Set("email", claims.Email) + c.Next() + } +} \ No newline at end of file diff --git a/server/middleware/cors.go b/server/middleware/cors.go new file mode 100644 index 0000000..aa8b041 --- /dev/null +++ b/server/middleware/cors.go @@ -0,0 +1,100 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// CORSConfig holds the configuration for the CORS middleware +type CORSConfig struct { + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string + AllowCredentials bool + MaxAge int +} + +// DefaultCORSConfig returns the default CORS configuration +func DefaultCORSConfig() *CORSConfig { + return &CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, + AllowedHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin"}, + AllowCredentials: false, + MaxAge: 86400, // 24 hours + } +} + +// CORS returns a Gin middleware handler for CORS (Cross-Origin Resource Sharing) +func CORS() gin.HandlerFunc { + return CORSWithConfig(DefaultCORSConfig()) +} + +// CORSWithConfig returns a Gin middleware handler for CORS with custom configuration +func CORSWithConfig(config *CORSConfig) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + + // Check if the origin is allowed + if len(config.AllowedOrigins) > 0 && config.AllowedOrigins[0] != "*" { + allowed := false + for _, allowedOrigin := range config.AllowedOrigins { + if origin == allowedOrigin { + allowed = true + break + } + } + if !allowed { + c.AbortWithStatusJSON(403, gin.H{ + "error": "origin not allowed", + }) + return + } + } + + // Set CORS headers + if len(config.AllowedOrigins) > 0 { + if config.AllowedOrigins[0] == "*" { + c.Header("Access-Control-Allow-Origin", "*") + } else { + c.Header("Access-Control-Allow-Origin", origin) + } + } + + c.Header("Access-Control-Allow-Methods", joinStringSlice(config.AllowedMethods, ", ")) + c.Header("Access-Control-Allow-Headers", joinStringSlice(config.AllowedHeaders, ", ")) + + if config.AllowCredentials { + c.Header("Access-Control-Allow-Credentials", "true") + } + + c.Header("Access-Control-Max-Age", stringFromInt(config.MaxAge)) + + // Handle preflight requests + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} + +// Helper function to join a slice of strings +func joinStringSlice(slice []string, sep string) string { + if len(slice) == 0 { + return "" + } + result := slice[0] + for i := 1; i < len(slice); i++ { + result += sep + slice[i] + } + return result +} + +// Helper function to convert int to string +func stringFromInt(n int) string { + if n == 0 { + return "" + } + return string(rune('0'+n%10)) + stringFromInt(n/10) +} \ No newline at end of file diff --git a/server/middleware/errors.go b/server/middleware/errors.go new file mode 100644 index 0000000..85e0b08 --- /dev/null +++ b/server/middleware/errors.go @@ -0,0 +1,114 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ErrorHandlerConfig holds the configuration for the error handler middleware +type ErrorHandlerConfig struct { + TimeFormat string + TimeZone string +} + +// DefaultErrorHandlerConfig returns the default error handler configuration +func DefaultErrorHandlerConfig() *ErrorHandlerConfig { + return &ErrorHandlerConfig{ + TimeFormat: "2006/01/02 - 15:04:05", + TimeZone: "UTC", + } +} + +// ErrorHandler returns a Gin middleware handler for centralized error handling +func ErrorHandler() gin.HandlerFunc { + return ErrorHandlerWithConfig(DefaultErrorHandlerConfig()) +} + +// ErrorHandlerWithConfig returns a Gin middleware handler for centralized error handling with custom configuration +func ErrorHandlerWithConfig(config *ErrorHandlerConfig) gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + // Check if there were any errors + if len(c.Errors) > 0 { + err := c.Errors.Last() + + // Determine the status code + statusCode := http.StatusInternalServerError + if gin.Mode() != gin.ReleaseMode { + // In development mode, return detailed error information + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "internal server error", + "message": err.Error(), + "details": c.Errors, + }) + return + } + + // In production mode, return a generic error message + c.JSON(statusCode, gin.H{ + "error": "internal server error", + }) + } + } +} + +// ErrorResponse represents a standard error response +type ErrorResponse struct { + Error string `json:"error"` + Code string `json:"code,omitempty"` + Details map[string]string `json:"details,omitempty"` +} + +// NewErrorResponse creates a new error response +func NewErrorResponse(error string, code string, details map[string]string) *ErrorResponse { + return &ErrorResponse{ + Error: error, + Code: code, + Details: details, + } +} + +// BadRequest creates a 400 Bad Request response +func BadRequest(c *gin.Context, message string) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": message, + }) +} + +// Unauthorized creates a 401 Unauthorized response +func Unauthorized(c *gin.Context, message string) { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": message, + }) +} + +// Forbidden creates a 403 Forbidden response +func Forbidden(c *gin.Context, message string) { + c.JSON(http.StatusForbidden, gin.H{ + "error": message, + }) +} + +// NotFound creates a 404 Not Found response +func NotFound(c *gin.Context, message string) { + c.JSON(http.StatusNotFound, gin.H{ + "error": message, + }) +} + +// InternalServerError creates a 500 Internal Server Error response +func InternalServerError(c *gin.Context, message string) { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": message, + }) +} + +// ValidationError creates a response for validation errors +func ValidationError(c *gin.Context, errors map[string]string) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "validation failed", + "details": errors, + }) +} \ No newline at end of file diff --git a/server/middleware/logging.go b/server/middleware/logging.go new file mode 100644 index 0000000..a8f79d8 --- /dev/null +++ b/server/middleware/logging.go @@ -0,0 +1,130 @@ +package middleware + +import ( + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// RequestLoggerConfig holds the configuration for the request logger +type RequestLoggerConfig struct { + SkipPaths []string + Logger *log.Logger + TimeFormat string + TimeZone string +} + +// DefaultRequestLoggerConfig returns the default request logger configuration +func DefaultRequestLoggerConfig() *RequestLoggerConfig { + return &RequestLoggerConfig{ + SkipPaths: []string{"/health", "/ready", "/metrics"}, + Logger: log.Default(), + TimeFormat: "2006/01/02 - 15:04:05", + TimeZone: "UTC", + } +} + +// Logger returns a Gin middleware handler for logging requests +func Logger() gin.HandlerFunc { + return LoggerWithConfig(DefaultRequestLoggerConfig()) +} + +// LoggerWithConfig returns a Gin middleware handler for logging requests with custom configuration +func LoggerWithConfig(config *RequestLoggerConfig) gin.HandlerFunc { + return func(c *gin.Context) { + // Skip logging for certain paths + path := c.Request.URL.Path + skip := false + for _, skipPath := range config.SkipPaths { + if path == skipPath { + skip = true + break + } + } + + start := time.Now() + raw := c.Request.URL.RawQuery + + c.Next() + + if skip { + return + } + + // Calculate latency + latency := time.Since(start) + + // Get the client IP + clientIP := c.ClientIP() + + // Get the request method + method := c.Request.Method + + // Get the status code + statusCode := c.Writer.Status() + + // Log the request + if raw != "" { + raw = "?" + raw + } + + config.Logger.Printf( + "[%s] %s %s%s | %d | %v | %s", + clientIP, + method, + path, + raw, + statusCode, + latency, + c.Errors.ByType(gin.ErrorTypePrivate).String(), + ) + } +} + +// Recovery returns a Gin middleware handler for recovering from panics +func Recovery() gin.HandlerFunc { + return RecoveryWithConfig(DefaultRecoveryConfig()) +} + +// RecoveryConfig holds the configuration for the recovery middleware +type RecoveryConfig struct { + Logger *log.Logger + TimeFormat string + TimeZone string + SkipStackTrace bool + StackTraceSize int + StackAll bool +} + +// DefaultRecoveryConfig returns the default recovery configuration +func DefaultRecoveryConfig() *RecoveryConfig { + return &RecoveryConfig{ + Logger: log.Default(), + TimeFormat: "2006/01/02 - 15:04:05", + TimeZone: "UTC", + SkipStackTrace: false, + StackTraceSize: 1024, + StackAll: false, + } +} + +// RecoveryWithConfig returns a Gin middleware handler for recovering from panics with custom configuration +func RecoveryWithConfig(config *RecoveryConfig) gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + // Log the panic + config.Logger.Printf("[PANIC] %v", err) + + // Abort with a 500 error + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "internal server error", + }) + } + }() + + c.Next() + } +} \ No newline at end of file diff --git a/server/models/models.go b/server/models/models.go new file mode 100644 index 0000000..699b405 --- /dev/null +++ b/server/models/models.go @@ -0,0 +1,148 @@ +package models + +import ( + "time" +) + +// BaseModel contains fields common to all models +type BaseModel struct { + ID string `json:"id" db:"id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// User represents a user in the system +type User struct { + BaseModel + Email string `json:"email" db:"email"` + PasswordHash string `json:"-" db:"password_hash"` + Name string `json:"name" db:"name"` + Role string `json:"role" db:"role"` + LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` + IsActive bool `json:"is_active" db:"is_active"` +} + +// UserRole constants +const ( + UserRoleUser = "user" + UserRoleAdmin = "admin" +) + +// Chat represents a chat conversation +type Chat struct { + BaseModel + UserID string `json:"user_id" db:"user_id"` + Title string `json:"title" db:"title"` + Model string `json:"model" db:"model"` + SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` + IsArchived bool `json:"is_archived" db:"is_archived"` +} + +// Message represents a chat message +type Message struct { + BaseModel + ChatID string `json:"chat_id" db:"chat_id"` + Role string `json:"role" db:"role"` // "user", "assistant", "system" + Content string `json:"content" db:"content"` + Tokens int `json:"tokens,omitempty" db:"tokens"` + Model string `json:"model,omitempty" db:"model"` + FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"` +} + +// MessageRole constants +const ( + MessageRoleUser = "user" + MessageRoleAssistant = "assistant" + MessageRoleSystem = "system" +) + +// APIConfig represents a configured API provider +type APIConfig struct { + BaseModel + UserID string `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + Provider string `json:"provider" db:"provider"` // "openai", "anthropic", "google", etc. + APIKey string `json:"-" db:"api_key"` + BaseURL string `json:"base_url,omitempty" db:"base_url"` + Model string `json:"model" db:"model"` + IsDefault bool `json:"is_default" db:"is_default"` +} + +// Channel represents a chat channel +type Channel struct { + BaseModel + Name string `json:"name" db:"name"` + Description string `json:"description,omitempty" db:"description"` + IsPrivate bool `json:"is_private" db:"is_private"` + OwnerID string `json:"owner_id" db:"owner_id"` +} + +// ChannelMember represents a member of a channel +type ChannelMember struct { + BaseModel + ChannelID string `json:"channel_id" db:"channel_id"` + UserID string `json:"user_id" db:"user_id"` + Role string `json:"role" db:"role"` // "member", "moderator", "admin" +} + +// ChannelMessage represents a message in a channel +type ChannelMessage struct { + BaseModel + ChannelID string `json:"channel_id" db:"channel_id"` + UserID string `json:"user_id" db:"user_id"` + Content string `json:"content" db:"content"` + ParentID string `json:"parent_id,omitempty" db:"parent_id"` +} + +// Note represents a user note +type Note struct { + BaseModel + UserID string `json:"user_id" db:"user_id"` + Title string `json:"title" db:"title"` + Content string `json:"content" db:"content"` + Tags []string `json:"tags,omitempty" db:"tags"` + FolderID string `json:"folder_id,omitempty" db:"folder_id"` + IsShared bool `json:"is_shared" db:"is_shared"` +} + +// KnowledgeBase represents a knowledge base collection +type KnowledgeBase struct { + BaseModel + UserID string `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + Source string `json:"source" db:"source"` // "url", "file", "text" + Settings string `json:"settings,omitempty" db:"settings"` // JSON settings +} + +// Workflow represents a workflow definition +type Workflow struct { + BaseModel + UserID string `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + Steps string `json:"steps" db:"steps"` // JSON array of steps + Settings string `json:"settings,omitempty" db:"settings"` + IsPublic bool `json:"is_public" db:"is_public"` +} + +// Settings represents user settings +type Settings struct { + UserID string `json:"user_id" db:"user_id"` + Theme string `json:"theme" db:"theme"` + Language string `json:"language" db:"language"` + Model string `json:"model" db:"model"` + SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` + MaxTokens int `json:"max_tokens" db:"max_tokens"` + Temperature float64 `json:"temperature" db:"temperature"` + DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"` +} + +// APIToken represents an API token for external access +type APIToken struct { + BaseModel + UserID string `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + Token string `json:"-" db:"token"` + ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` + IsActive bool `json:"is_active" db:"is_active"` +} \ No newline at end of file diff --git a/server/version.go b/server/version.go new file mode 100644 index 0000000..f5b3da6 --- /dev/null +++ b/server/version.go @@ -0,0 +1,8 @@ +package main + +// Version is set at build time via: +// +// go build -ldflags "-X main.Version=$(cat VERSION)" +// +// Falls back to "dev" for local builds without ldflags. +var Version = "dev" diff --git a/src/js/version.js b/src/js/version.js new file mode 100644 index 0000000..81029f0 --- /dev/null +++ b/src/js/version.js @@ -0,0 +1,7 @@ +/** + * Chat Switchboard - Version Configuration + * Single source of truth: VERSION file at repo root + * Injected by build.sh at build time + */ +const SWITCHBOARD_VERSION = '__VERSION__'; +const APP_NAME = 'Chat Switchboard';