[BACKEND] Initialize Go backend structure and dependencies (#23)
This commit is contained in:
355
.gitea/workflows/ci.yaml
Normal file
355
.gitea/workflows/ci.yaml
Normal file
@@ -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}"
|
||||
@@ -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;"]
|
||||
@@ -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"]
|
||||
100
k8s/backend.yaml
Normal file
100
k8s/backend.yaml
Normal file
@@ -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
|
||||
135
k8s/deployment.yaml
Normal file
135
k8s/deployment.yaml
Normal file
@@ -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
|
||||
80
k8s/frontend.yaml
Normal file
80
k8s/frontend.yaml
Normal file
@@ -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
|
||||
45
k8s/ingress.yaml
Normal file
45
k8s/ingress.yaml
Normal file
@@ -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
|
||||
104
k8s/lite.yaml
Normal file
104
k8s/lite.yaml
Normal file
@@ -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
|
||||
@@ -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;
|
||||
29
nginx.frontend.conf
Normal file
29
nginx.frontend.conf
Normal file
@@ -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;
|
||||
}
|
||||
61
scripts/db-bootstrap.sh
Normal file
61
scripts/db-bootstrap.sh
Normal file
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
91
scripts/db-migrate.sh
Normal file
91
scripts/db-migrate.sh
Normal file
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
28
scripts/docker-entrypoint.sh
Normal file
28
scripts/docker-entrypoint.sh
Normal file
@@ -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
|
||||
44
server/.env.example
Normal file
44
server/.env.example
Normal file
@@ -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=
|
||||
@@ -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"]
|
||||
ENTRYPOINT ["switchboard"]
|
||||
|
||||
36
server/config/config.go
Normal file
36
server/config/config.go
Normal file
@@ -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
|
||||
}
|
||||
55
server/database/database.go
Normal file
55
server/database/database.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
// 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"}) }
|
||||
|
||||
@@ -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 {
|
||||
@@ -94,3 +84,23 @@ func TestRouterSetup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
68
server/middleware/auth.go
Normal file
68
server/middleware/auth.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
100
server/middleware/cors.go
Normal file
100
server/middleware/cors.go
Normal file
@@ -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)
|
||||
}
|
||||
114
server/middleware/errors.go
Normal file
114
server/middleware/errors.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
130
server/middleware/logging.go
Normal file
130
server/middleware/logging.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
148
server/models/models.go
Normal file
148
server/models/models.go
Normal file
@@ -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"`
|
||||
}
|
||||
8
server/version.go
Normal file
8
server/version.go
Normal file
@@ -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"
|
||||
7
src/js/version.js
Normal file
7
src/js/version.js
Normal file
@@ -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';
|
||||
Reference in New Issue
Block a user