From 88216ec4cba7b65a13f7a4f6736d77283655a6b7 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Wed, 25 Feb 2026 21:38:49 +0000 Subject: [PATCH] Changeset 0.12.0 (#63) --- .gitea/workflows/ci.yaml | 54 +- ARCHITECTURE.md | 22 +- CHANGELOG.md | 36 + DESIGN-0.12.0.md | 1304 ++++++++++++ README.md | 9 + ROADMAP.md | 124 +- VERSION | 2 +- admin.go | 668 ++++++ api.js | 636 ++++++ channels.go | 458 ++++ chat.js | 632 ++++++ docker-compose.yml | 5 + index.html | 931 ++++++++ k8s/backend.yaml | 97 + k8s/storage-pvc.yaml | 32 + main.go | 508 +++++ rekey.go | 115 + server/.env.example | 21 +- server/config/config.go | 44 + server/crypto/rekey.go | 115 + server/crypto/status.go | 46 + .../database/migrations/007_attachments.sql | 32 + server/extraction/queue.go | 294 +++ server/extraction/queue_test.go | 240 +++ server/go.mod | 1 + server/handlers/admin.go | 23 +- server/handlers/attachments.go | 476 +++++ server/handlers/channels.go | 84 +- server/handlers/completion.go | 204 +- server/handlers/integration_test.go | 14 +- server/handlers/messages.go | 14 +- server/handlers/storage.go | 62 + server/main.go | 148 +- server/models/models.go | 18 + server/providers/anthropic.go | 75 + server/providers/multimodal_test.go | 202 ++ server/providers/openai.go | 68 +- server/providers/provider.go | 24 + server/storage/init.go | 78 + server/storage/pvc.go | 278 +++ server/storage/pvc_test.go | 295 +++ server/storage/s3.go | 312 +++ server/storage/s3_test.go | 326 +++ server/storage/storage.go | 73 + server/store/interfaces.go | 19 + server/store/postgres/attachment.go | 187 ++ server/store/postgres/stores.go | 1 + src/css/styles.css | 130 ++ src/index.html | 19 + src/js/api.js | 63 +- src/js/app.js | 11 + src/js/attachments.js | 836 ++++++++ src/js/chat.js | 90 +- src/js/ui-admin.js | 29 + src/js/ui-core.js | 2 + src/sw.js | 1 + status.go | 46 + styles.css | 1893 +++++++++++++++++ ui-admin.js | 727 +++++++ 59 files changed, 13115 insertions(+), 139 deletions(-) create mode 100644 DESIGN-0.12.0.md create mode 100644 admin.go create mode 100644 api.js create mode 100644 channels.go create mode 100644 chat.js create mode 100644 index.html create mode 100644 k8s/storage-pvc.yaml create mode 100644 main.go create mode 100644 rekey.go create mode 100644 server/crypto/rekey.go create mode 100644 server/crypto/status.go create mode 100644 server/database/migrations/007_attachments.sql create mode 100644 server/extraction/queue.go create mode 100644 server/extraction/queue_test.go create mode 100644 server/handlers/attachments.go create mode 100644 server/handlers/storage.go create mode 100644 server/providers/multimodal_test.go create mode 100644 server/storage/init.go create mode 100644 server/storage/pvc.go create mode 100644 server/storage/pvc_test.go create mode 100644 server/storage/s3.go create mode 100644 server/storage/s3_test.go create mode 100644 server/storage/storage.go create mode 100644 server/store/postgres/attachment.go create mode 100644 src/js/attachments.js create mode 100644 status.go create mode 100644 styles.css create mode 100644 ui-admin.js diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 7c916ad..9eff9d9 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,6 +1,6 @@ # .gitea/workflows/ci.yaml # ============================================ -# Chat Switchboard - CI/CD Pipeline (v0.9.4) +# Chat Switchboard - CI/CD Pipeline (v0.12.0) # ============================================ # Cluster deployments use SEPARATE FE + BE images. # Unified image is for Docker Hub only (docker-compose use). @@ -31,6 +31,9 @@ # Required Gitea Variables: # REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST # SEED_USERS (optional) — CSV: "user:pass:role,..." for dev/test seed accounts +# STORAGE_CLASS — StorageClass for PVC (e.g. "cephfs", required for file storage) +# STORAGE_SIZE — PVC capacity (e.g. "10Gi", default: "10Gi") +# STORAGE_BACKEND — "pvc" or "s3" (default: "pvc") # # Required Gitea Secrets: # POSTGRES_USER, POSTGRES_PASSWORD @@ -39,6 +42,7 @@ # ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32) # VENICE_API_KEY (live provider integration tests — optional, tests skip if missing) # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional) +# S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY (only when STORAGE_BACKEND=s3) # # Global Variables (Gitea org-level): # CERT_ISSUER_PROD @@ -450,6 +454,26 @@ jobs: --from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \ --dry-run=client -o yaml | kubectl apply -f - + - name: Sync S3 secrets + if: vars.STORAGE_BACKEND == 's3' + env: + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }} + S3_PREFIX: ${{ vars.S3_PREFIX }} + run: | + kubectl create secret generic switchboard-s3 \ + --namespace=${NAMESPACE} \ + --from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \ + --from-literal=S3_BUCKET="${S3_BUCKET}" \ + --from-literal=S3_ACCESS_KEY="${S3_ACCESS_KEY}" \ + --from-literal=S3_SECRET_KEY="${S3_SECRET_KEY}" \ + --from-literal=S3_REGION="${S3_REGION}" \ + --from-literal=S3_PREFIX="${S3_PREFIX}" \ + --dry-run=client -o yaml | kubectl apply -f - + - name: Render and apply manifests env: ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} @@ -468,12 +492,31 @@ jobs: 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 }} + # Storage (v0.12.0+) + STORAGE_CLASS: ${{ vars.STORAGE_CLASS }} + STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }} + STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }} + EXTRACTION_CONCURRENCY: ${{ vars.EXTRACTION_CONCURRENCY || '3' }} run: | + # Render PVC first (must exist before backend references it) + if [[ -n "${STORAGE_CLASS}" ]]; then + envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml + fi + 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} ━━━" + + # Apply PVC (idempotent — won't recreate if exists) + if [[ -n "${STORAGE_CLASS}" ]]; then + kubectl apply -f /tmp/storage-pvc.yaml + echo " ✓ Storage PVC (class: ${STORAGE_CLASS}, size: ${STORAGE_SIZE})" + else + echo " ⚠ STORAGE_CLASS not set — file storage disabled" + fi + kubectl apply -f /tmp/backend.yaml kubectl apply -f /tmp/frontend.yaml kubectl apply -f /tmp/ingress.yaml @@ -536,6 +579,15 @@ jobs: echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY + if [[ -n "${{ vars.STORAGE_CLASS }}" ]]; then + if [[ "${{ vars.STORAGE_BACKEND }}" == "s3" ]]; then + echo "| **Storage** | S3 (bucket: ${{ vars.S3_BUCKET }}, PVC scratch: ${{ vars.STORAGE_CLASS }}) |" >> $GITHUB_STEP_SUMMARY + else + echo "| **Storage** | PVC (class: ${{ vars.STORAGE_CLASS }}, size: ${{ vars.STORAGE_SIZE || '10Gi' }}) |" >> $GITHUB_STEP_SUMMARY + fi + else + echo "| **Storage** | Disabled (STORAGE_CLASS not set) |" >> $GITHUB_STEP_SUMMARY + fi if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY fi diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 27fb5f9..3f85d4d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -234,13 +234,33 @@ Communication: `app.js` calls `API.*` methods, updates state, then calls `UI.*` - **Auth**: JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable) - **Admin Bootstrap**: `SWITCHBOARD_ADMIN_USERNAME`/`PASSWORD` env vars create/update admin on every startup (K8s secret pattern) -- **API Key Storage**: Provider API keys stored in `api_key_enc` column (TODO: at-rest encryption) +- **API Key Storage**: Provider API keys encrypted with AES-256-GCM. Two-tier: org keys use `ENCRYPTION_KEY` env var, personal BYOK keys use per-user encryption keys (admins cannot recover) - **Policies**: Boolean flags in `global_settings` table control registration, BYOK, team providers, etc. - **Audit**: All admin operations logged to `audit_log` with actor, action, resource type/ID, and diff - **Banner**: Environment classification banner configurable via admin settings (text, color, position) - **CORS**: Configurable allowed origins via env var - **No sensitive terminology**: Banner system avoids classification-related terms for security compliance +## File Storage + +Blob storage for attachments, extracted text, and (future) knowledge base documents. + +**Interface**: `storage.ObjectStore` — `Put`, `Get`, `Delete`, `DeletePrefix`, `Exists`, `Healthy`, `Stats`, `Backend`. All handlers use the interface; backend is selected at startup. + +**Backends**: + +| Backend | Config | Use Case | +|---------|--------|----------| +| **PVC** | `STORAGE_BACKEND=pvc` + `STORAGE_PATH` | Single-node, dev, docker-compose. Local filesystem with atomic writes (temp+rename). | +| **S3** | `STORAGE_BACKEND=s3` + `S3_ENDPOINT`, `S3_BUCKET`, credentials | Multi-node production. MinIO, Ceph RGW, AWS S3. Uses minio-go v7. | +| **Auto** | `STORAGE_BACKEND=` (empty) | Tries PVC at `STORAGE_PATH`; disables if not writable. | + +**Key layout**: `attachments/{channel_id}/{attachment_id}_{filename}`. S3 prefix (`S3_PREFIX`) prepended for shared buckets. + +**PVC always mounted**: Even with S3 backend, the PVC mount at `STORAGE_PATH` is needed for the extraction queue's local scratch directory (`processing/{id}/status.json`). With S3, the PVC can be small (1Gi) — only transient coordination state, not blobs. + +**Admin panel**: Settings → Storage tab shows backend type, health status, file count, total size, endpoint/bucket (S3) or path (PVC), orphan detection and cleanup. + ## Backward Compatibility v0.9 maintains backward-compatible API routes: diff --git a/CHANGELOG.md b/CHANGELOG.md index e001ec1..08b4501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to Chat Switchboard. +## [0.12.0] — 2026-02-25 + +### Added +- **File handling and vision support.** Upload images, PDFs, and documents + into chat via 📎 button, drag-and-drop, or paste. Multimodal message + assembly injects base64 images for vision-capable models and extracted + text for documents. Staged attachment strip shows upload progress and + extraction status. Auth-aware blob rendering with Bearer tokens. Image + lightbox viewer. Vision capability hints on image attachments. +- **Storage backend abstraction.** `ObjectStore` interface with two + implementations: PVC (local filesystem) for single-node/dev and S3 + (minio-go) for multi-node production. S3 backend works with any + S3-compatible API: MinIO, Ceph RGW, AWS S3. Auto-detection when + `STORAGE_BACKEND` is not set (tries PVC, disables if not writable). + Both backends share identical semantics — all handlers, attachment + CRUD, multimodal assembly, and orphan cleanup work regardless of + backend. +- **S3 configuration.** `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, + `S3_SECRET_KEY`, `S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` env + vars. Endpoint auto-detects SSL from scheme. Path-style URLs default + on (required for MinIO/Ceph). Optional key prefix for shared buckets. + CI pipeline syncs S3 secrets to K8s when `STORAGE_BACKEND=s3`. +- **Text extraction pipeline.** PDF, DOCX, XLSX, PPTX, ODT, RTF text + extraction via filesystem-based queue. Inline and sidecar modes. + Crash recovery for items stuck in processing state. +- **Admin storage panel.** Backend health, file count, total size, + orphan detection and cleanup. Shows PVC path or S3 endpoint/bucket + depending on active backend. Extraction queue status. +- **Vault CLI commands.** `switchboard vault rekey` re-encrypts all + provider API keys when rotating the encryption key. + `switchboard vault status` shows encryption health. Admin UI + encryption status indicator in Settings tab. +- **Per-chat model persistence.** Server-side `channels.settings` + JSONB field stores `last_selector_id`. Roams across devices with + localStorage write-through cache as fallback. + ## [0.11.0] — 2026-02-25 ### Added diff --git a/DESIGN-0.12.0.md b/DESIGN-0.12.0.md new file mode 100644 index 0000000..98f8c4b --- /dev/null +++ b/DESIGN-0.12.0.md @@ -0,0 +1,1304 @@ +# v0.12.0 — File Handling + Vision (Design) + +## Overview + +File input into chat — table stakes for serious use. Image uploads for +vision-capable models, document uploads for text extraction into context, +and the storage backend that v0.14.0 (Knowledge Bases) and v0.15.0 +(Compaction snapshots) will reuse. + +Also picks up stragglers deferred from earlier releases. + +**Depends on:** v0.11.0 (extension foundation — complete). +**Reused by:** v0.14.0 (KBs), v0.15.0 (compaction), v0.22.0 (exports). + +--- + +## Stragglers (from v0.9.4 deferred) + +Items deferred during vault work that were never scheduled: + +### `switchboard vault rekey` CLI command +Re-encrypts all global/team API keys when `ENCRYPTION_KEY` is rotated. +Personal BYOK keys are unaffected (keyed to UEK, not env var). + +Implementation: standalone CLI entrypoint that reads old + new env vars, +iterates `api_configs` and `team_providers` where `key_scope IN ('global', +'team')`, decrypts with old key, re-encrypts with new key, updates in a +transaction. + +### Admin UI: encryption status indicator +Badge in admin Settings showing whether `ENCRYPTION_KEY` is set and how +many keys are encrypted vs. plaintext (migration stragglers). Single +`GET /admin/storage/status` endpoint. + +### Per-chat model/preset persistence (server-side) +Currently localStorage bandaid from v0.10.2 — doesn't roam across devices. + +**Fix:** Store `last_selector_id` in `channels.settings` JSONB on each +completion success. On chat selection, resolve: +`channel.settings.last_selector_id` → match against available +models/presets → fall back to `channel.model` → global default. + +No migration needed (`channels.settings` JSONB column already exists). +Single `PATCH /channels/:id` with settings merge on completion success. + +--- + +## Track 1: Storage Backend + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. If not set: `pvc` when `STORAGE_PATH` is writable, disabled otherwise. | +| `STORAGE_PATH` | `/data/storage` | Mount point for PVC backend. Also scratch dir for extraction with S3. | +| `STORAGE_CLASS` | — | K8s only. Gitea CI variable → PVC manifest. Value: `cephfs` (RWX for multi-pod). | +| `S3_ENDPOINT` | — | S3-compatible endpoint (e.g. `http://minio:9000`). Required when `STORAGE_BACKEND=s3`. | +| `S3_BUCKET` | — | Bucket name. Must exist before first start. | +| `S3_ACCESS_KEY` | — | S3 access key ID. | +| `S3_SECRET_KEY` | — | S3 secret access key. | +| `S3_REGION` | `us-east-1` | AWS region. | +| `S3_PREFIX` | — | Optional key prefix for shared buckets (e.g. `switchboard/`). | +| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs. Required for MinIO, Ceph RGW, most self-hosted. | +| `EXTRACTION_MODE` | `inline` | `inline` (in-process, unified image) or `sidecar` (K8s, shared PVC). | +| `EXTRACTION_CONCURRENCY` | `3` | Max concurrent extraction jobs. Caps LibreOffice memory usage. | + +Auto-detection when `STORAGE_BACKEND` is not set: + +``` +STORAGE_PATH writable → pvc (implicit) +STORAGE_PATH missing → file features disabled + admin panel shows "Storage not configured" +``` + +When `STORAGE_BACKEND=pvc` is explicit, fail startup if path is not +writable (fail-safe, same pattern as `ENCRYPTION_KEY` enforcement). + +S3 backend: reads `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, +`S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` from env vars. Uses minio-go v7. +Works with MinIO, Ceph RGW, AWS S3. Same interface as PVC — all handlers are +backend-agnostic. PVC still required as scratch dir for extraction queue. + +### Config Addition + +```go +// In config.go +StorageBackend string // "pvc", "s3", or "" (auto-detect) +StoragePath string // mount point for PVC backend +ExtractionMode string // "inline" or "sidecar" +ExtractionConcurrency int // max concurrent extraction jobs +``` + +### Interface + +```go +// server/storage/storage.go +package storage + +import ( + "context" + "io" +) + +// ObjectStore is the abstraction for blob storage. +// Implementations: PVC (filesystem), S3 (minio-go v7). +type ObjectStore interface { + // Put writes data to the given key. Creates parent dirs as needed. + Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error + + // Get returns a reader for the given key. Caller must close. + // Returns ErrNotFound if key does not exist. + Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) + + // Delete removes the object at key. No error if already gone. + Delete(ctx context.Context, key string) error + + // DeletePrefix removes all objects under the given prefix. + // Used for channel deletion (bulk cleanup). + DeletePrefix(ctx context.Context, prefix string) error + + // Exists checks if an object exists at key without reading it. + Exists(ctx context.Context, key string) (bool, error) + + // Healthy returns nil if the backend is operational. + Healthy(ctx context.Context) error +} + +var ErrNotFound = errors.New("storage: object not found") +``` + +### PVC Implementation + +`server/storage/pvc.go` — ~100 lines. Thin wrapper around `os.*`: + +```go +type PVCStore struct { + basePath string // e.g. "/data/storage" +} + +func NewPVC(basePath string) (*PVCStore, error) { + // Validate basePath is writable (create test file, remove) + // Create top-level subdirs: attachments/ +} +``` + +Key → filesystem path: `filepath.Join(basePath, key)`. The key itself +provides all the directory structure. + +### Filesystem Layout + +``` +/data/storage/ ← STORAGE_PATH mount point +└── attachments/ + └── {channel_id}/ + └── {attachment_id}_{filename} +``` + +**Channel-first** because: +- Channel deletion → `DeletePrefix("attachments/{channel_id}/")` — one call. +- User deletion cascades to owned channels → same cleanup path. +- Group chats / multi-participant channels: all files in one place + regardless of who uploaded them. +- Quota enforcement uses PG (`SUM(size_bytes) WHERE user_id = $1`), + not filesystem walks. + +Future subdirs at the `STORAGE_PATH` level (not this release): +- `knowledge-bases/` — v0.14.0 +- `compaction/` — v0.15.0 +- `exports/` — TBD + +### K8s Manifest (PVC) + +```yaml +# k8s/storage-pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: switchboard-storage + namespace: switchboard +spec: + accessModes: [ReadWriteMany] # CephFS RWX — multi-pod safe + storageClassName: "${STORAGE_CLASS}" + resources: + requests: + storage: 10Gi # configurable per deployment +``` + +Backend deployment addition: + +```yaml +# In k8s/backend.yaml +volumes: + - name: storage + persistentVolumeClaim: + claimName: switchboard-storage +containers: + - name: backend + volumeMounts: + - name: storage + mountPath: /data/storage + env: + - name: STORAGE_BACKEND + value: "pvc" + - name: STORAGE_PATH + value: "/data/storage" +``` + +### Docker Compose + +```yaml +# In docker-compose.yml +services: + switchboard: + volumes: + - ./data/storage:/data/storage + environment: + - STORAGE_PATH=/data/storage +``` + +### Admin Status Endpoint + +`GET /admin/storage/status` + +```json +{ + "backend": "pvc", + "path": "/data/storage", + "healthy": true, + "total_files": 142, + "total_bytes": 52428800, + "configured": true +} +``` + +When storage is not configured, file upload endpoints return 503 with +`{"error": "File storage not configured"}`. Frontend hides upload UI +based on a capabilities flag in the boot payload. + +--- + +## Track 2: Attachments Schema + API + +### Migration 007_attachments.sql + +```sql +-- ── Attachments ──────────────────────────── +CREATE TABLE IF NOT EXISTS attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id), + message_id UUID REFERENCES messages(id) ON DELETE SET NULL, + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(127) NOT NULL, + size_bytes BIGINT NOT NULL, + storage_key TEXT NOT NULL, + extracted_text TEXT, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Access pattern: always join through channel for auth +CREATE INDEX idx_attachments_channel ON attachments(channel_id); +-- Quota pattern: sum by user +CREATE INDEX idx_attachments_user_size ON attachments(user_id); +-- Message association: find attachments for a message +CREATE INDEX idx_attachments_message ON attachments(message_id); +``` + +Notes on columns: +- `message_id` is nullable — attachment is uploaded before the message + is persisted. Updated after message creation. If message is deleted, + attachment persists (belongs to channel, not message). +- `storage_key` is the relative path within `STORAGE_PATH`, e.g. + `attachments/{channel_id}/{attachment_id}_{filename}`. +- `extracted_text` is populated asynchronously for PDFs/DOCX. NULL for + images. Used for context injection into completions. +- `metadata` JSONB holds dimensions (images), page count (PDFs), + extraction status, thumbnails, etc. + +### Store Layer + +```go +// server/store/attachments.go +type AttachmentStore interface { + Create(ctx context.Context, a *models.Attachment) error + GetByID(ctx context.Context, id string) (*models.Attachment, error) + GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) + GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) + SetMessageID(ctx context.Context, id, messageID string) error + Delete(ctx context.Context, id string) error + DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys for cleanup + UserUsageBytes(ctx context.Context, userID string) (int64, error) +} +``` + +### API Endpoints + +``` +POST /api/v1/channels/:id/attachments Upload file +GET /api/v1/attachments/:id Attachment metadata +GET /api/v1/attachments/:id/download Download file content +DELETE /api/v1/attachments/:id Delete attachment +GET /api/v1/channels/:id/attachments List channel attachments +``` + +### Upload Flow + +``` +Client Backend Storage + │ │ │ + ├── POST multipart ────────►│ │ + │ (file + channel_id) │ │ + │ ├── Auth check │ + │ ├── Channel membership ─────│ + │ │ verify │ + │ ├── Size limit check │ + │ ├── MIME type validation │ + │ ├── Generate attachment_id │ + │ ├── PUT ───────────────────►│ + │ │ key: att/{ch}/{id}_{fn} │ + │ ├── INSERT attachments row │ + │ │ │ + │◄── 201 { attachment } ────┤ │ +``` + +The attachment is created *before* the message is sent. The frontend +holds the attachment ID(s), includes them in the completion request, +and the backend links them to the persisted message after creation. + +### Download Flow (Access Control) + +``` +GET /api/v1/attachments/:id/download + + → SELECT a.*, c.id, c.user_id + FROM attachments a + JOIN channels c ON a.channel_id = c.id + WHERE a.id = $1 + + → Verify: requester is channel owner + (future RBAC: OR requester is channel participant + OR requester is team member with channel access + OR requester is admin with audit log) + + → Stream file from ObjectStore with: + Content-Type: {content_type} + Content-Disposition: attachment; filename="{filename}" + Content-Length: {size_bytes} +``` + +**Critical:** Files are NEVER served directly from nginx or any static +file path. Every byte goes through the Go backend with auth checks. +No pre-signed URLs, no direct filesystem exposure. + +### Size & Type Limits + +Configured via global settings (see **Global Settings** section below). +Enforced server-side on upload — frontend mirrors limits for UX but the +backend is authoritative. + +Validation order on upload: +1. `storage_allowed_types` — reject disallowed MIME types +2. `storage_max_file_size` — reject oversized individual files +3. `storage_max_upload_size` — reject if request total exceeds limit +4. `storage_max_attachments_per_message` — reject if too many files +5. `storage_user_quota_bytes` — reject if user quota exceeded (future) + +MIME type is detected server-side (`http.DetectContentType` + extension +validation), not trusted from the client `Content-Type` header. + +--- + +## Track 3: Multimodal Message Assembly + +### Provider Message Polymorphism + +Currently `providers.Message.Content` is a plain string. Vision requires +content arrays with image parts. The change is additive — no existing +callsites break. + +```go +// In server/providers/provider.go + +// ContentPart represents one part of a multimodal message. +type ContentPart struct { + Type string `json:"type"` // "text", "image_url" + Text string `json:"text,omitempty"` // for type="text" + ImageURL *ImageURL `json:"image_url,omitempty"` // for type="image_url" +} + +type ImageURL struct { + URL string `json:"url"` // "data:image/jpeg;base64,..." or URL + Detail string `json:"detail,omitempty"` // "auto", "low", "high" +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + + // Multimodal content. When non-nil, providers use this instead of Content. + ContentParts []ContentPart `json:"content_parts,omitempty"` + + // ... existing tool fields unchanged +} +``` + +**Resolution rule:** if `ContentParts` is non-nil, providers build their +wire format from it. If nil, they use `Content` (string) as today. This +means every existing codepath works unchanged until an attachment is +involved. + +### Provider Wire Format Mapping + +**OpenAI / OpenRouter / Venice** (OpenAI-compatible): + +```go +// In openai.go — message conversion +if len(m.ContentParts) > 0 { + // Content becomes array of objects + oaiMsg.Content = nil // clear string content + oaiMsg.ContentArray = toOpenAIContentParts(m.ContentParts) +} +``` + +OpenAI wire format: +```json +{ + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}} + ] +} +``` + +The `openaiMessage` struct needs a `ContentArray` field with custom JSON +marshaling — when `ContentArray` is set, marshal content as the array; +otherwise marshal as string. This is the standard OpenAI multimodal +format. + +**Anthropic:** + +```go +// In anthropic.go — message conversion +if len(m.ContentParts) > 0 { + antMsg.Content = toAnthropicContentBlocks(m.ContentParts) +} +``` + +Anthropic wire format: +```json +{ + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "..."}} + ] +} +``` + +The `anthropicContentBlock` struct gets new fields: + +```go +type anthropicContentBlock struct { + // ... existing fields ... + // Image support + Source *anthropicImageSource `json:"source,omitempty"` +} + +type anthropicImageSource struct { + Type string `json:"type"` // "base64" + MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc. + Data string `json:"data"` // base64-encoded image +} +``` + +### Image Delivery: Base64 Inline + +For v0.12.0, all images are sent as base64-encoded data inline in the +API request. No pre-signed URLs. + +**Rationale:** The target audience is government/enterprise, often in +air-gapped or restricted networks. Pre-signed URLs require the provider's +API servers to reach the storage endpoint — impossible in disconnected +environments. Base64 works everywhere. + +**Trade-off:** Larger request payloads. Acceptable for v0.12.0 — images +are typically <10MB each and context windows are large. URL-based +delivery can be an optimization in a future release for cloud deployments. + +### Completion Handler Changes + +```go +// In completion.go — after loading conversation, before building provReq + +// If message has attachments, build multimodal content +if len(req.AttachmentIDs) > 0 { + parts := []providers.ContentPart{ + {Type: "text", Text: req.Content}, + } + for _, attID := range req.AttachmentIDs { + att, err := h.store.Attachments().GetByID(c, attID) + // ... verify att.channel_id matches ... + + if isImageType(att.ContentType) { + // Verify vision capability + if !caps.Vision { + c.JSON(400, gin.H{"error": "Selected model does not support image input"}) + return + } + + // Read from storage, base64 encode + reader, _, _, _ := h.objectStore.Get(c, att.StorageKey) + data, _ := io.ReadAll(reader) + reader.Close() + b64 := base64.StdEncoding.EncodeToString(data) + dataURL := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64) + + parts = append(parts, providers.ContentPart{ + Type: "image_url", + ImageURL: &providers.ImageURL{URL: dataURL, Detail: "auto"}, + }) + } else if att.ExtractedText != nil && *att.ExtractedText != "" { + // Document: inject extracted text as context + parts = append(parts, providers.ContentPart{ + Type: "text", + Text: fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText), + }) + } else { + // Extraction not yet complete or failed — include filename only + status := att.Metadata["extraction_status"] + parts = append(parts, providers.ContentPart{ + Type: "text", + Text: fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status), + }) + } + } + + // Use ContentParts instead of Content string + userMsg := providers.Message{ + Role: "user", + ContentParts: parts, + } + messages = append(messages, userMsg) +} else { + // Existing text-only path — unchanged + messages = append(messages, providers.Message{ + Role: "user", + Content: req.Content, + }) +} +``` + +**Frontend blocks send only during active upload** — the send button +stays disabled while any staged attachment is in `uploading` state +(bytes still in flight). Once the file is on disk and the attachment +row exists in PG, send is enabled regardless of extraction status. + +The completion handler checks `extraction_status` at request time: +- `complete` → inject extracted text as context +- anything else → inject filename-only placeholder + +This avoids blocking users on slow extractions while still providing +extracted context when available. For images (no extraction), the +attachment is immediately usable. + +### Capability Gating + +Images are only assembled into content parts when the resolved model +has `capabilities.vision == true`. If a user attaches an image to a +non-vision model: + +- Documents (PDF, DOCX, TXT): always allowed — extracted text is + injected as a text content part. No vision needed. +- Images to non-vision model: backend returns 400 with + `{"error": "Selected model does not support image input"}`. +- Frontend disables image upload button when selected model lacks vision. + +### Text Extraction (Documents) + +#### License Constraint + +The project is Apache 2.0. Libraries with AGPL/GPL copyleft (e.g. +unipdf) are incompatible — their license would infect the codebase. +Pure Go PDF libraries under permissive licenses (`ledongthuc/pdf`, +`pdfcpu`) handle simple cases but choke on complex layouts, scanned +PDFs, and encrypted documents. + +#### Approach: Tiered Extraction + +**Tier 1 — Native Go (no external deps, handles ~80% of uploads):** + +| Format | Library | License | +|--------|---------|---------| +| TXT, Markdown, CSV | `io.ReadAll` | — | +| PDF (simple) | `pdfcpu` | Apache 2.0 | +| DOCX | `fumiama/go-docx` | MIT | + +**Tier 2 — LibreOffice Headless (handles everything else):** + +For PDF with complex layouts, ODT, ODS, ODP, RTF, DOC (legacy), XLS, +and any format Tier 1 can't handle — shell out to LibreOffice headless: + +```bash +libreoffice --headless --convert-to txt:Text /tmp/input.odt --outdir /tmp/ +``` + +LibreOffice handles the full Office family: +- OpenDocument: `.odt`, `.ods`, `.odp` +- Microsoft: `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx` +- PDF (including scanned with basic OCR via hunspell) +- RTF, HTML, and dozens more + +#### Extraction Queue (Filesystem-Backed) + +Extraction state lives on the PVC, not in memory. Pod restarts don't +lose queue state. The queue is a directory convention: + +``` +/data/storage/ +├── attachments/ ← completed uploads (served to clients) +└── processing/ ← extraction queue (internal only) + └── {attachment_id}/ + ├── input ← original file (copy or hardlink) + ├── status.json ← queue state + metadata + ├── output.txt ← extracted text (when complete) + └── thumb.jpg ← thumbnail (when complete) +``` + +**status.json:** + +```json +{ + "state": "pending", + "queued_at": "2026-02-25T14:30:00Z", + "started_at": null, + "completed_at": null, + "content_type": "application/pdf", + "filename": "report.pdf", + "error": null +} +``` + +States: `pending` → `processing` → `complete` | `failed` + +**Worker loop (runs in both inline and sidecar mode):** + +``` +1. Scan processing/ for dirs with status.state == "pending" + (ordered by queued_at — FIFO) +2. Respect concurrency cap (semaphore, default 3) +3. Pick job → flip state to "processing" +4. Tier 1: try native Go extraction + ├── success → write output.txt, generate thumb, flip "complete" + └── fail or unsupported → +5. Tier 2: LibreOffice headless + ├── success → write output.txt, generate thumb, flip "complete" + └── fail → flip "failed", write error +6. Copy results to PG: + ├── UPDATE attachments SET extracted_text = ..., + │ metadata = jsonb_set(metadata, '{extraction_status}', '"complete"') + └── Thumbnail storage_key written to metadata +7. Clean up processing/{id}/ dir +``` + +**In inline mode** (Unified image): the worker is a goroutine pool in +the Go backend, scanning the queue directory on a 2-second tick. + +**In sidecar mode** (K8s): the sidecar runs the same loop as a +standalone process. When extraction completes, it writes `output.txt` +and `thumb.jpg` to the processing dir and flips status to `complete`. +The Go backend polls the status file and copies results to PG. + +**Crash recovery:** On startup, scan for `state == "processing"` (stale +from a crash). Reset to `pending` for re-processing. This is why the +queue is on the filesystem — nothing is lost. + +**Concurrency:** Capped by semaphore. Default 3 concurrent extractions. +Configurable via `EXTRACTION_CONCURRENCY` env var. LibreOffice is +memory-hungry on complex docs — the cap prevents OOM on burst uploads. + +**Frontend queue awareness:** + +``` +Attachment status: + uploading → progress bar + pending → "Queued (2 ahead)" ← position from queue scan + processing → "Extracting..." ← spinner + complete → ✓ (ready) + failed → ⚠ (send still enabled, model sees filename only) +``` + +The frontend polls `GET /api/v1/attachments/:id` every 2s for +non-terminal states. The response includes `extraction_status` and +`queue_position` (0 = currently processing, N = N jobs ahead). + +Send is blocked while any attachment is `uploading`. Once all are at +least `pending`, send is enabled — the extraction results will be +available by the time the model processes the request (optimistic), or +the model sees the filename-only fallback (acceptable degradation). + +**Correction to earlier design:** Send is blocked only during `uploading` +phase. Once the file is on disk and queued, the user can send. The +completion handler checks extraction status at request time and uses +whatever is available. + +#### Extraction Metadata in PG + +Attachment `metadata` JSONB tracks status (mirrored from queue): + +```json +{ + "extraction_status": "complete", + "extraction_error": null, + "queue_position": 0, + "page_count": 12, + "dimensions": {"width": 1920, "height": 1080}, + "thumbnail_key": "attachments/{channel_id}/{id}_thumb.jpg", + "thumb_dimensions": {"width": 280, "height": 147} +} +``` + +States: `pending` → `processing` → `complete` | `failed` | `unavailable` + +#### LibreOffice Deployment Strategy + +**Two deployment modes, matching existing Docker architecture:** + +| Deployment | LibreOffice location | Reason | +|-----------|---------------------|--------| +| **Unified** (docker-compose, dev) | Baked into image | Single container, simplicity wins | +| **K8s** (backend + frontend split) | Sidecar container | Separate scaling, lean backend image, independent version pinning | + +**Unified Dockerfile addition:** + +```dockerfile +# In Dockerfile (unified) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libreoffice-core libreoffice-writer libreoffice-calc \ + && rm -rf /var/lib/apt/lists/* +``` + +**K8s sidecar (backend.yaml addition):** + +```yaml +# In k8s/backend.yaml +containers: + - name: backend + image: switchboard-backend:0.12.0 + volumeMounts: + - name: storage + mountPath: /data/storage + env: + - name: STORAGE_BACKEND + value: "pvc" + - name: STORAGE_PATH + value: "/data/storage" + - name: EXTRACTION_MODE + value: "sidecar" # "inline" for unified, "sidecar" for k8s + + - name: extractor + image: switchboard-extractor:0.12.0 # minimal image: alpine + libreoffice + volumeMounts: + - name: storage + mountPath: /data/storage + resources: + requests: + memory: "256Mi" + limits: + memory: "1Gi" # LibreOffice can spike on complex docs + +volumes: + - name: storage + persistentVolumeClaim: + claimName: switchboard-storage +``` + +The sidecar image is small and purpose-built: Alpine + LibreOffice + +a watcher script. No Go code, no API surface. Upgrades to LibreOffice +are a tag bump on the sidecar image — backend image untouched. + +**Communication:** Filesystem-only via the shared PVC. No gRPC, no +HTTP between containers. See Extraction Queue below. + +#### Allowed Types (Updated) + +```json +{ + "storage_allowed_types": [ + "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", + "application/pdf", + "text/plain", "text/markdown", "text/csv", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/rtf", + "application/msword", + "application/vnd.ms-excel" + ] +} +``` + +### Thumbnail Generation + +Thumbnails are generated **asynchronously** alongside text extraction +as part of the same post-upload processing pipeline. + +#### Image Thumbnails + +Use `golang.org/x/image/draw` (BSD, stdlib-adjacent) for resizing: + +```go +// Resize to max 280px on longest edge, preserve aspect ratio +// Output: JPEG at 80% quality +// Stored at: attachments/{channel_id}/{attachment_id}_thumb.jpg +``` + +No external dependency. Pure Go. Fast enough for on-upload generation. + +#### Document Thumbnails + +When LibreOffice is available, generate a first-page preview: + +```bash +# Convert first page to PDF, then to image +libreoffice --headless --convert-to pdf /tmp/input.docx --outdir /tmp/ +# Then use Go image libraries to render first page +# Or: convert to PNG directly via LibreOffice draw filter +libreoffice --headless --convert-to png /tmp/input.docx --outdir /tmp/ +``` + +For PDFs specifically, `pdfcpu` can extract page images under Apache 2.0. + +When LibreOffice is not available, documents get a generic file-type +icon (handled in CSS, no thumbnail row needed). + +#### Storage + +Thumbnails stored alongside originals with `_thumb` suffix: + +``` +/data/storage/attachments/{channel_id}/ +├── {id}_report.pdf ← original +├── {id}_report.pdf_thumb.jpg ← thumbnail +├── {id}_photo.jpg ← original +└── {id}_photo.jpg_thumb.jpg ← thumbnail +``` + +Thumbnail URL returned in attachment metadata: + +```json +{ + "thumbnail_key": "attachments/{channel_id}/{id}_photo.jpg_thumb.jpg", + "dimensions": {"width": 1920, "height": 1080}, + "thumb_dimensions": {"width": 280, "height": 147} +} +``` + +Download endpoint: `GET /api/v1/attachments/:id/thumbnail` — same +channel-scoped auth check as the full download. + +--- + +## Track 4: Frontend + +### Input Area Changes + +The `
` gets an attachment button and a preview +strip: + +``` +┌──────────────────────────────────────────────┐ +│ ┌──────────┐ ┌──────────┐ │ ← attachment preview strip +│ │ image.png│ │ report.pdf│ │ (visible only when files +│ │ ✕ │ │ ✕ │ │ are staged) +│ └──────────┘ └──────────┘ │ +├──────────────────────────────────────────────┤ +│ 📎 │ Send a message... │ ■ │ ▶ │ ← existing input + new clip btn +└──────────────────────────────────────────────┘ +``` + +### Upload Interactions + +| Trigger | Behavior | +|---------|----------| +| 📎 button click | File picker dialog (filtered by allowed types) | +| Paste (Ctrl+V) binary | Auto-attach: screenshot/image → image attachment ({uuid}.{ext}) | +| Paste (Ctrl+V) large text | Auto-attach: text > threshold → text file ({uuid}.txt) | +| Paste (Ctrl+V) small text | Normal paste into textarea (below threshold) | +| Drag-and-drop on chat area | Auto-attach file(s) as attachments (max 5 per message) | + +#### Smart Paste (Auto-Attach) + +Paste events are intercepted and routed automatically — no prompts, no +user decisions. The clipboard content determines the behavior: + +**Detection logic:** + +``` +Clipboard paste event + │ + ├── Contains files/images (binary)? + │ └── YES → auto-attach each item + │ ├── Image (screenshot, copied image) → upload as image attachment + │ ├── File (dragged from OS) → upload as file attachment + │ └── Filename: {uuid}.{ext} (ext from MIME type) + │ + ├── Contains text? + │ ├── Length ≤ threshold → normal paste into textarea + │ └── Length > threshold → auto-attach as text file + │ ├── Upload as attachment (content_type: text/plain) + │ ├── Filename: {uuid}.txt + │ └── Textarea stays empty (or keeps existing draft) + │ + └── Empty → no-op +``` + +**Binary detection:** The `paste` event's `clipboardData.items` array +carries type information. Items with `type.startsWith('image/')` or +any non-text type are binary — always auto-attached. Text items +(`text/plain`, `text/html`) are checked against the threshold. + +**UUID filenames:** All auto-attached pastes get UUID-based filenames. +No timestamps, no `pasted-text.txt` collision issues. The original +content type and paste source are captured in attachment metadata: + +```json +{ + "source": "clipboard", + "original_type": "image/png", + "paste_length": 4200 +} +``` + +**Threshold:** `storage_paste_to_file_chars` global setting (default: +2000 characters, admin-configurable). Setting to 0 disables text +auto-attach (binary pastes still auto-attach regardless). + +**UX flow for a screenshot paste:** +1. User hits Ctrl+V with a screenshot on clipboard +2. Image appears instantly in the attachment preview strip (uploading...) +3. Upload completes → thumbnail shown with checkmark +4. User types their question, hits send +5. Image goes to vision model as base64 + +**UX flow for a large log paste:** +1. User copies 5,000 chars of server logs, hits Ctrl+V +2. Text file chip appears in attachment preview strip: `📄 {uuid}.txt (4.9 KB)` +3. Extraction runs (trivial for plain text — near-instant) +4. User types "what's wrong with these logs?", hits send +5. Log content injected as extracted text context + +No prompts, no dialogs, no decisions. Paste just works. + +### State Management + +Staged attachments live in `App.stagedAttachments[]` — an array of +`{id, filename, contentType, sizeBytes, previewUrl, extractionStatus}` +populated after the upload API returns. On send, attachment IDs are +included in the completion request body. On cancel/clear, staged +attachments are deleted via the API. + +**Upload lifecycle per attachment:** + +``` +uploading → uploaded/queued → extracting → ready | failed +``` + +- `uploading`: progress bar visible, **send disabled**. +- `uploaded/queued`: file on disk, extraction queued. Queue position + shown: "Queued (2 ahead)". **Send enabled** — extraction will + complete before or during model processing, or the model gets + the filename-only fallback. +- `extracting`: spinner on thumbnail. Send remains enabled. +- `ready`: extraction complete (or image — no extraction needed). + Green checkmark. +- `failed`: extraction failed. Warning icon on thumbnail. Send still + enabled — model sees `[Attached file: X (extraction failed)]`. + +**Key principle:** Send is blocked **only during active upload** (bytes +in flight). Once the file is on disk and the attachment row exists, +the user can send. This prevents the frustrating case where a user +types their message while a 50-page PDF extracts for 30 seconds. + +**Polling:** Frontend polls `GET /api/v1/attachments/:id` every 2s for +any attachment with `extractionStatus == "pending" | "processing"`. +Stops when all settle. WebSocket notification is a future optimization. +Polling updates the queue position indicator and thumbnail status icon. + +### Message Rendering + +Attachments on sent/received messages render inline: + +- **Images:** Thumbnail (from `_thumb.jpg`) with click-to-expand + (lightbox or side panel). Rendered in the message bubble below/above + the text. +- **Documents:** File chip with icon, filename, size, and download link. + `📄 report.pdf (2.3 MB) ⬇` +- **Pasted text files:** Same as documents — `📄 {uuid}.txt (4.9 KB) ⬇`. + Metadata `source: "clipboard"` available for future UX refinement. + +Attachment data comes from `messages.metadata.attachment_ids` → +batch-fetched on conversation load via +`GET /channels/:id/attachments`. + +### Capability-Aware UI + +The frontend already knows model capabilities from the model selector. + +**Images are always auto-attached regardless of current model** — the +user might switch models before sending. When the selected model has +`vision: false`: + +- Image attachments show a subtle indicator: `👁️‍🗨️` icon dimmed with + tooltip "Current model doesn't support images" +- File picker filters to documents only, but paste/drop still accepts + images (user can switch models) +- The 📎 button tooltip says "Attach document" instead of "Attach file" +- On send with image + non-vision model: backend returns 400 with + `"Selected model does not support image input — switch to a vision + model or remove image attachments"` +- Document uploads (PDF, DOCX, ODT, etc.) always work — text + extraction doesn't require vision + +When storage is not configured (boot payload flag), the 📎 button is +hidden entirely and all paste/drop auto-attach handlers are bypassed. + +--- + +## Privacy & Access Control Model + +### Principle + +**Attachments have no access identity of their own. They inherit access +from their parent channel.** The channel membership check is the single +gate for all attachment operations. + +### Access Matrix (v0.12.0 — Current Auth Model) + +| Channel type | Who can upload | Who can download | Delete | +|---|---|---|---| +| Personal DM | Channel owner | Channel owner | Owner | +| (Future) Team channel | Team members with access | Team members with access | Uploader or team admin | +| (Future) Group chat | Channel participants | Channel participants | Uploader or channel owner | +| (Future) Workflow | Assigned participants | Assigned participants | Stage rules | + +### Enforcement Points + +Every attachment endpoint performs channel-scoped authorization: + +```go +// Pseudocode — every handler follows this pattern +func (h *Handler) downloadAttachment(c *gin.Context) { + att := h.store.Attachments().GetByID(c, attID) + channel := h.store.Channels().GetByID(c, att.ChannelID) + + // THE GATE: is the requester authorized for this channel? + if channel.UserID != requesterID { + c.JSON(403, gin.H{"error": "access denied"}) + return + } + + // Future RBAC (v0.20.0): replace the above with: + // if !h.rbac.CanAccess(requesterID, channel.ID, "attachment:read") { + // c.JSON(403, ...) + // } + + h.objectStore.Get(c, att.StorageKey) // → stream to client +} +``` + +### Threat Mitigations + +| Threat | Mitigation | +|--------|-----------| +| ID guessing / enumeration | Every download joins through channel + verifies requester membership. UUIDs are non-sequential. | +| Direct filesystem access | Files never served by nginx. All bytes go through Go backend with auth. No static file routes for storage path. | +| Cross-team data leak | Channel membership is the boundary. HR channel attachments are only accessible to HR channel participants. | +| Admin overreach | v0.12.0: admin can access (consistent with existing admin trust model). Audit-logged. v0.20.0: RBAC with team-scoped admin. | +| Metadata leak | Channel attachment list endpoint also requires channel membership. No global search across attachments. | +| Storage key prediction | Keys include UUID attachment_id — not guessable from channel_id + filename alone. | +| Upload as attack vector | MIME type validated against allowlist. File size enforced. Content-Type set from server-side detection, not client header. | + +### Consistency with Existing Scoped Resources + +The attachment access pattern is identical to the pattern used for all +scoped data in the system: + +``` +Resource Scope Access Gate +────────────────────────────────────────────────── +Notes (personal) user_id user_id = requester +Notes (team) team_id requester ∈ team members +Attachments channel_id requester ∈ channel (owner/participant) +Usage data user/team scoped queries +API keys user/org vault (UEK) / env-derived key +``` + +The row knows its scope. The handler enforces the boundary. The query +filters by it. + +### Future RBAC Hooks (v0.20.0) + +The v0.12.0 access checks are written as explicit `if` checks that +can be trivially replaced by a centralized RBAC call: + +```go +// v0.12.0 +if channel.UserID != requesterID { deny } + +// v0.20.0 +if !rbac.Can(requesterID, channel.ID, permission) { deny } +``` + +The schema, storage layer, and API surface don't change. Only the +authorization check function gets swapped. + +--- + +## Implementation Order + +``` +Phase 1: Storage Backend + ├── Config additions (STORAGE_BACKEND, STORAGE_PATH, EXTRACTION_MODE) + ├── ObjectStore interface + ├── PVC implementation (~100 lines) + ├── Startup validation (writable check, health) + ├── K8s manifest (PVC + volume mount + STORAGE_CLASS) + ├── Docker-compose volume mount + ├── Admin status endpoint + └── Boot payload: storage_configured flag + +Phase 2: Attachments CRUD + Extraction Pipeline + ├── Migration 007_attachments.sql + ├── models.Attachment struct + ├── AttachmentStore (PG CRUD) + ├── handlers/attachments.go (upload, download, delete, list, thumbnail) + ├── Channel-scoped access checks on all endpoints + ├── MIME validation + size enforcement (server-side detection) + ├── Filesystem cleanup on channel delete (CASCADE + DeletePrefix) + ├── Extraction queue (filesystem-backed): + │ ├── processing/ directory convention + status.json + │ ├── Worker loop with semaphore (default 3 concurrent) + │ ├── Tier 1: native Go (pdfcpu, go-docx, io.ReadAll) + │ ├── Tier 2: LibreOffice headless (inline or sidecar) + │ ├── Crash recovery (reset stale "processing" → "pending") + │ └── PG metadata mirror (extraction_status, queue_position) + ├── Thumbnail generation (images: Go resize, docs: LibreOffice) + ├── Unified Dockerfile: add libreoffice-core + ├── Sidecar Dockerfile: alpine + libreoffice + watcher + └── K8s backend.yaml: sidecar container definition + +Phase 3: Multimodal Message Assembly + ├── ContentPart + ImageURL types in providers/provider.go + ├── OpenAI message conversion (content array marshaling) + ├── Anthropic message conversion (image source blocks) + ├── OpenRouter / Venice (inherit OpenAI path) + ├── Completion handler: attachment → base64 → content parts + ├── Completion handler: extraction_status check at request time + ├── Capability gating (vision check before image assembly) + ├── Document text injection (extracted_text → text content part) + └── Usage logging for multimodal requests + +Phase 4: Frontend + ├── Attachment button (📎) in input area + ├── File picker with type filtering (capability-aware) + ├── Smart paste handler: + │ ├── Binary detection (images, files → auto-attach) + │ ├── Large text detection (> threshold → auto-attach as {uuid}.txt) + │ ├── Small text → normal textarea paste + │ └── UUID filename generation + ├── Drag-and-drop on chat area + ├── Staged attachment preview strip with status indicators: + │ ├── Upload progress bar + │ ├── Queue position ("Queued (2 ahead)") + │ ├── Extraction spinner + │ ├── Ready checkmark / failed warning + │ └── Send blocked only during active upload + ├── Extraction status polling (2s interval, stops at terminal state) + ├── Attachment rendering in messages (thumbnails + doc chips) + ├── Image lightbox / side panel expand + ├── Capability-aware UI (hide/filter by vision support) + ├── Storage-not-configured state (hide upload UI) + └── Admin Storage panel (status, orphan count, cleanup button) + +Phase 5: Stragglers + ├── vault rekey CLI command + ├── Admin encryption status indicator + └── Per-chat model/preset persistence (server-side) + +Phase 6: Admin Cleanup + ├── POST /admin/storage/cleanup endpoint + ├── Orphan detection query (message_id IS NULL, age > 24h) + ├── Admin Storage panel: orphan count + reclaimable space card + └── "Run Cleanup Now" button +``` + +### Testing + +- Integration tests: upload → send message with attachment → verify + multimodal completion request → verify download auth +- Access control tests: upload to channel A, attempt download from + user who owns channel B → 403 +- Storage backend tests: put/get/delete/deletePrefix round-trip +- Extraction tests: PDF/DOCX/TXT → extracted_text populated +- Frontend tests: staged attachments state, capability gating + +--- + +## Migration Summary + +| Migration | Tables/Columns | +|-----------|---------------| +| 007_attachments.sql | `attachments` table with indexes | + +No changes to existing tables. The `messages.metadata` JSONB carries +`attachment_ids` without schema changes. The `channels.settings` JSONB +carries `last_selector_id` without schema changes. + +--- + +## Decisions Made + +1. **Text extraction** — Tiered: native Go (pdfcpu Apache 2.0, go-docx + MIT) for common cases, LibreOffice headless for everything else + including ODF formats. No AGPL dependencies. + +2. **Thumbnails** — Yes. Generated async post-upload. Images via Go + stdlib (`golang.org/x/image/draw`). Documents via LibreOffice + first-page render. Stored alongside originals with `_thumb` suffix. + +3. **Max attachments per message** — 5 files (admin-configurable). + +4. **Extraction timing** — Async with filesystem-backed queue. Upload + returns immediately, extraction runs in background via worker loop + with concurrency cap. Frontend polls for status. Send blocked only + during active upload (bytes in flight), not during extraction. + +5. **Smart paste** — Automatic, no prompts. Binary clipboard items + (screenshots, images) always auto-attach. Text exceeding threshold + (default 2000 chars) auto-attaches as `{uuid}.txt`. Below threshold + pastes normally into textarea. UUID filenames, MIME detection from + clipboard data. Zero user decisions. + +## Global Settings (New) + +| Key | Default | Description | +|-----|---------|-------------| +| `storage_max_file_size` | `10485760` (10MB) | Per-file upload limit | +| `storage_max_upload_size` | `52428800` (50MB) | Per-request total limit | +| `storage_allowed_types` | *(see Allowed Types)* | MIME type allowlist | +| `storage_max_attachments_per_message` | `5` | Max files per message | +| `storage_paste_to_file_chars` | `2000` | Paste-to-file threshold (0=disabled) | +| `storage_user_quota_bytes` | `0` | Per-user quota (0=unlimited, future enforcement) | + +## Decisions Made (Continued) + +6. **Sidecar base image** — Debian slim. Rock-solid LibreOffice packages, + ~80MB larger than Alpine but no flaky package issues. + +7. **Queue persistence** — Dual: filesystem `status.json` is source of + truth (crash recovery, `ls` debuggability), mirrored to PG metadata + for API serving. Worth the bookkeeping for operational transparency. + +## Open Questions + +*(None remaining — all design decisions resolved.)* + +## Orphan Cleanup (Admin Action) + +Attachments uploaded but never associated with a message (user closes +tab, browser crash, abandoned upload) accumulate as orphans. + +**v0.12.0: manual admin action with "Run Now" button.** + +``` +POST /admin/storage/cleanup + → Scan attachments WHERE message_id IS NULL + AND created_at < NOW() - interval '24 hours' + → Delete PG rows + storage files + processing dirs + → Return { deleted: 3, freed_bytes: 15728640 } +``` + +**Admin Storage panel card:** + +``` +┌─ Orphan Files ──────────────────────────────┐ +│ 3 orphaned attachments (15 MB reclaimable) │ +│ │ +│ Files not linked to any message for >24hrs │ +│ │ +│ [Run Cleanup Now] │ +└─────────────────────────────────────────────┘ +``` + +The 24-hour grace period prevents cleaning up files that are simply +in the middle of a long compose session. + +**Future (v0.22.0 task runner):** This becomes a scheduled task running +daily. The admin button stays as an on-demand override. The endpoint +is identical — the scheduler just calls it on a cron. diff --git a/README.md b/README.md index ad002f8..4b367da 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,15 @@ All configuration via environment variables. See `server/.env.example` for the f | `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password | | `ENCRYPTION_KEY` | ` ` | AES key for API key encryption (required if encrypted keys exist) | | `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` | +| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. Auto-detects PVC if not set. | +| `STORAGE_PATH` | `/data/storage` | PVC mount point (also scratch dir for S3 extraction) | +| `S3_ENDPOINT` | ` ` | S3 endpoint (e.g. `http://minio:9000`, required for S3) | +| `S3_BUCKET` | ` ` | S3 bucket name (must exist, required for S3) | +| `S3_ACCESS_KEY` | ` ` | S3 access key ID | +| `S3_SECRET_KEY` | ` ` | S3 secret access key | +| `S3_REGION` | `us-east-1` | S3 region | +| `S3_PREFIX` | ` ` | Optional key prefix within bucket | +| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs (required for MinIO, Ceph) | ## Deployment diff --git a/ROADMAP.md b/ROADMAP.md index 2e51f82..64a9f18 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,19 +43,23 @@ v0.14.0 Knowledge Bases (embedding role + file storage + pgvector) │ v0.15.0 Compaction (utility role + background job) │ -v0.16.0 @mention Routing + Multi-model +v0.15.1 Context Recall Tools (attachment_recall, conversation_search) │ -v0.17.0 Extension Surfaces + Modes +v0.16.0 Conversation Memory (cross-conversation user facts) │ -v0.18.0 Smart Routing (policy on model roles) +v0.17.0 @mention Routing + Multi-model │ -v0.19.0 Multi-Participant Channels + Presence +v0.18.0 Extension Surfaces + Modes │ -v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC +v0.19.0 Smart Routing (policy on model roles) + │ +v0.20.0 Multi-Participant Channels + Presence + │ +v0.21.0 Auth Strategy (mTLS/OIDC) + Full RBAC │ ┌───────┴──────────────┐ │ │ -v0.21.0 Workflow v0.22.0 Tasks / +v0.22.0 Workflow v0.23.0 Tasks / Engine Autonomous Agents (team-owned, (service channels, staged processes, scheduler, unattended @@ -254,7 +258,7 @@ after backfill. If `ENCRYPTION_KEY` is not set, migration refuses to run derivation. No additional UX. On login, derive PDK → unwrap UEK → cache in session. User never knows the vault exists. -**mTLS / OIDC** (v0.20.0) — authentication is external (cert/token). +**mTLS / OIDC** (v0.21.0) — authentication is external (cert/token). Vault passphrase is conditionally required: ``` @@ -336,7 +340,7 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err - [x] UEK eviction on logout (memory zeroing) - [ ] UEK re-wrap on password change _(deferred — Settings handler)_ - [ ] UEK destruction on admin password reset with confirmation _(deferred)_ -- [ ] Vault passphrase prompt for mTLS/OIDC _(deferred — v0.20.0 dependency)_ +- [ ] Vault passphrase prompt for mTLS/OIDC _(deferred — v0.21.0 dependency)_ **Provider key lifecycle** - [x] Encrypt on provider create/update (all 6 write paths: admin global, personal, team) @@ -344,8 +348,9 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err - [x] `ErrVaultLocked` handling (prompt user to unlock) **Admin tooling** -- [ ] `switchboard vault rekey` CLI command _(deferred)_ -- [ ] Admin UI: encryption status indicator _(deferred)_ +- [x] `switchboard vault rekey` CLI command _(v0.12.0)_ +- [x] `switchboard vault status` CLI command _(v0.12.0)_ +- [x] Admin UI: encryption status indicator _(v0.12.0 — Settings tab)_ - [x] Admin UI: password reset warning (v0.10.0 — vault destruction dialog) **CI/CD** @@ -579,17 +584,21 @@ The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec. File input into chat — table stakes for serious use. **Storage Backend** -- [ ] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API) -- [ ] Local PVC fallback (single-node / dev) -- [ ] Admin config: backend selection, endpoint, credentials, size limits -- [ ] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0) +- [x] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API) +- [x] Local PVC backend (single-node / dev) +- [x] Admin config: backend health status, orphan cleanup panel +- [x] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0) **Chat Integration** -- [ ] `attachments` table (metadata in PG, blobs in object store) -- [ ] Image/file upload in chat messages -- [ ] Multimodal message assembly for vision-capable models -- [ ] Text extraction on upload (PDF, DOCX, TXT → tsvector) -- [ ] Paste-to-upload (clipboard image support) +- [x] `attachments` table (metadata in PG, blobs in object store) +- [x] Image/file upload in chat messages (📎 button, drag-and-drop) +- [x] Multimodal message assembly for vision-capable models +- [x] Text extraction pipeline (PDF, DOCX, TXT — inline + sidecar modes) +- [x] Paste-to-upload (clipboard images + large text auto-attach) +- [x] Staged attachment strip with extraction status polling +- [x] Auth-aware rendering (blob URLs with Bearer token) +- [x] Image lightbox viewer +- [x] Vision capability hints on image attachments - [ ] Document generation output storage (extension → attachment → download) --- @@ -633,7 +642,56 @@ Depends on: utility model role (v0.10.0). --- -## v0.16.0 — @mention Routing + Multi-model +## v0.15.1 — Context Recall Tools + +Depends on: file handling (v0.12.0), tool framework (v0.11.0). + +Attachments are injected into context once (at send time) and are not +replayed on subsequent turns — the model's own response acts as a natural +summary. After compaction (v0.15.0) or in long conversations, the model +may need to re-read source material. These tools bridge that gap. + +**`attachment_recall` tool** +- [ ] `attachment_recall(attachment_id)` — re-reads attachment content from storage +- [ ] Images: re-inject as base64 content part (vision gating still applies) +- [ ] Documents: return extracted text +- [ ] Security: scoped to current channel's attachments only +- [ ] Extend `ExecutionContext` with optional storage backend reference +- [ ] Tool definition includes attachment list hint so model knows what's available + +**`conversation_search` tool** +- [ ] Keyword search over compacted/archived messages in current channel +- [ ] Returns matching message excerpts with timestamps +- [ ] Useful post-compaction when summary dropped details the user asks about + +**Token estimator improvements** +- [ ] Include attachment token estimates in context counter +- [ ] Images: use provider-specific sizing rules (e.g. tile-based for OpenAI) +- [ ] Documents: count extracted text tokens +- [ ] Staged attachments reflected in input token counter before send + +--- + +## v0.16.0 — Conversation Memory + +Depends on: compaction (v0.15.0), knowledge bases (v0.14.0). + +Long-term memory across conversations. Unlike KB (static documents) or +compaction (within-conversation), this captures user-level facts and +preferences that persist across channels. + +- [ ] `user_memory` table: `user_id`, `key`, `value`, `source_channel_id`, `confidence`, `created_at`, `updated_at` +- [ ] `memory_save` tool: LLM can explicitly store a fact ("user prefers Python", "project deadline is March 15") +- [ ] `memory_recall` tool: LLM queries stored facts relevant to current context +- [ ] Auto-extraction: background job identifies memorable facts from conversations (opt-in) +- [ ] Memory injection: relevant facts injected into system prompt at completion time +- [ ] User controls: view, edit, delete memories in Settings +- [ ] Admin controls: enable/disable, retention policies, per-team scoping +- [ ] Privacy: memories never cross team boundaries + +--- + +## v0.17.0 — @mention Routing + Multi-model The channel schema already supports multiple models. This adds routing. @@ -645,7 +703,7 @@ The channel schema already supports multiple models. This adds routing. --- -## v0.17.0 — Extension Surfaces + Modes +## v0.18.0 — Extension Surfaces + Modes Depends on: extension foundation (v0.11.0). See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes). @@ -659,7 +717,7 @@ See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes). --- -## v0.18.0 — Smart Routing +## v0.19.0 — Smart Routing Depends on: model roles (v0.10.0), usage tracking (v0.10.0). Rules-based routing engine — policy, not ML. @@ -672,13 +730,13 @@ Rules-based routing engine — policy, not ML. --- -## v0.19.0 — Multi-Participant Channels + Presence +## v0.20.0 — Multi-Participant Channels + Presence The channel foundation for workflows and real-time collaboration. Today channels are single-user direct chats. This release makes channels a shared space that multiple actors can inhabit. -Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exists). +Depends on: extension surfaces (v0.18.0), WebSocket infrastructure (already exists). **Channel Participants** - [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at` @@ -699,7 +757,7 @@ Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exis --- -## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC +## v0.21.0 — Auth Strategy (mTLS/OIDC) + Full RBAC Enterprise auth modes on top of the teams foundation. Depends on: vault passphrase support from v0.9.3 (conditional unlock for @@ -723,7 +781,7 @@ personal providers under external auth). --- -## v0.21.0 — Workflow Engine +## v0.22.0 — Workflow Engine Team-owned, stage-based process execution. The channel is the runtime, personas drive each stage, and the existing tool/notes infrastructure @@ -731,7 +789,7 @@ handles structured data collection. See [ARCHITECTURE.md — Workflow Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for the conceptual model. -Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0). +Depends on: multi-participant channels (v0.20.0), anonymous identity (v0.21.0). **Workflow Definitions** - [ ] `workflows` table: `team_id` (owner), `name`, `description`, `is_active`, `entry_mode` (public_link, team_internal, api) @@ -771,10 +829,10 @@ Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0). --- -## v0.22.0 — Tasks / Autonomous Agents +## v0.23.0 — Tasks / Autonomous Agents Unattended execution — workflows without a human in the loop. -Depends on: workflow engine (v0.21.0). +Depends on: workflow engine (v0.22.0). - [ ] Scheduler + task runner (cron-like triggers for workflow instantiation) - [ ] `task_create` tool (AI can spawn sub-workflows) @@ -812,13 +870,7 @@ based on need. - Backup/restore CronJob manifests **UX / Multi-Seat** -- Per-chat model/preset persistence (server-side). Currently a localStorage - bandaid (v0.10.2) that doesn't roam across devices. Proper fix: store - `last_selector_id` in `channels.settings` JSONB on each send. On chat - selection, resolve from `channel.settings.last_selector_id` → match against - available models/presets → fall back to `channel.model` → global default. - Eliminates device-local state entirely. Needs `updateChannel` PATCH on - completion success (single extra column merge, no migration). +- ~~Per-chat model/preset persistence (server-side)~~ ✅ _(v0.12.0 — `channels.settings.last_selector_id` JSONB merge, localStorage write-through cache)_ **Platform** - Rate limiting per user/team/tier (token budgets) diff --git a/VERSION b/VERSION index d9df1bb..ac454c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.0 +0.12.0 diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..c08d2d6 --- /dev/null +++ b/admin.go @@ -0,0 +1,668 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "git.gobha.me/xcaliber/chat-switchboard/crypto" + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +type AdminHandler struct { + stores store.Stores + vault *crypto.KeyResolver + uekCache *crypto.UEKCache +} + +func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler { + return &AdminHandler{stores: s, vault: vault, uekCache: uekCache} +} + +// ── User Management ───────────────────────── + +func (h *AdminHandler) ListUsers(c *gin.Context) { + opts := store.DefaultListOptions() + + // Accept both limit/offset and page/per_page conventions + if limit, _ := strconv.Atoi(c.Query("limit")); limit > 0 { + opts.Limit = limit + } + if offset, _ := strconv.Atoi(c.Query("offset")); offset > 0 { + opts.Offset = offset + } + if perPage, _ := strconv.Atoi(c.Query("per_page")); perPage > 0 { + opts.Limit = perPage + } + if page, _ := strconv.Atoi(c.Query("page")); page > 1 { + opts.Offset = (page - 1) * opts.Limit + } + + users, total, err := h.stores.Users.List(c.Request.Context(), opts) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) + return + } + + c.JSON(http.StatusOK, gin.H{"users": users, "total": total}) +} + +func (h *AdminHandler) CreateUser(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) + role := models.UserRoleUser + if req.Role == models.UserRoleAdmin { + role = models.UserRoleAdmin + } + + user := &models.User{ + Username: strings.ToLower(req.Username), + Email: strings.ToLower(req.Email), + PasswordHash: string(hash), + Role: role, + IsActive: true, + } + + if err := h.stores.Users.Create(c.Request.Context(), user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) + return + } + + h.auditLog(c, "user.create", "user", user.ID, nil) + c.JSON(http.StatusCreated, user) +} + +func (h *AdminHandler) UpdateUserRole(c *gin.Context) { + id := c.Param("id") + var req struct { + Role string `json:"role" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"}) + return + } + + h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role}) + c.JSON(http.StatusOK, gin.H{"message": "role updated"}) +} + +func (h *AdminHandler) ToggleUserActive(c *gin.Context) { + id := c.Param("id") + var req struct { + IsActive bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.stores.Users.SetActive(c.Request.Context(), id, req.IsActive); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"}) + return + } + + h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive}) + c.JSON(http.StatusOK, gin.H{"message": "user status updated"}) +} + +func (h *AdminHandler) ResetPassword(c *gin.Context) { + id := c.Param("id") + var req struct { + Password string `json:"password" binding:"required,min=8"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) + if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"password_hash": string(hash)}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"}) + return + } + + // Destroy vault — old UEK is unrecoverable with new password. + // A fresh vault will be initialized on the user's next login. + h.destroyVault(c, id) + + h.auditLog(c, "user.password_reset", "user", id, nil) + c.JSON(http.StatusOK, gin.H{"message": "password reset"}) +} + +// destroyVault nullifies a user's encrypted UEK and deletes their personal +// provider keys. Called when an admin resets a user's password — the old +// UEK cannot be recovered without the old password. +func (h *AdminHandler) destroyVault(c *gin.Context, userID string) { + if h.uekCache == nil { + return + } + + // Null out vault columns — user gets a fresh vault on next login + _, err := database.DB.Exec(` + UPDATE users + SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false + WHERE id = $1 + `, userID) + if err != nil { + log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err) + } + + // Evict from session cache (if user is currently logged in) + h.uekCache.Evict(userID) + + // Delete personal provider configs — their keys are undecryptable now + result, err := database.DB.Exec(` + DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1 + `, userID) + if err != nil { + log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err) + } else if rows, _ := result.RowsAffected(); rows > 0 { + log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID) + } + + h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{ + "reason": "admin_password_reset", + }) +} + +func (h *AdminHandler) DeleteUser(c *gin.Context) { + id := c.Param("id") + if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) + return + } + h.auditLog(c, "user.delete", "user", id, nil) + c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) +} + +// ── Global Settings ───────────────────────── + +func (h *AdminHandler) ListGlobalSettings(c *gin.Context) { + settings, err := h.stores.GlobalConfig.GetAll(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) + return + } + // Also include policies + policies, _ := h.stores.Policies.GetAll(c.Request.Context()) + c.JSON(http.StatusOK, gin.H{"settings": settings, "policies": policies}) +} + +func (h *AdminHandler) GetGlobalSetting(c *gin.Context) { + key := c.Param("key") + val, err := h.stores.GlobalConfig.Get(c.Request.Context(), key) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"key": key, "value": val}) +} + +func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) { + key := c.Param("key") + userID, _ := c.Get("user_id") + uid := userID.(string) + + var req struct { + Value json.RawMessage `json:"value"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Determine if this is a policy (string value) or a global config (JSON value) + var strVal string + if err := json.Unmarshal(req.Value, &strVal); err == nil { + // String value → try as policy first + if _, ok := models.PolicyDefaults[key]; ok { + if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"}) + return + } + h.auditLog(c, "policy.update", "policy", key, gin.H{"value": strVal}) + c.JSON(http.StatusOK, gin.H{"message": "policy updated"}) + return + } + } + + // JSON value → global config + var jsonVal models.JSONMap + json.Unmarshal(req.Value, &jsonVal) + if err := h.stores.GlobalConfig.Set(c.Request.Context(), key, jsonVal, uid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"}) + return + } + + h.auditLog(c, "settings.update", "global_settings", key, nil) + c.JSON(http.StatusOK, gin.H{"message": "setting updated"}) +} + +func (h *AdminHandler) PublicSettings(c *gin.Context) { + // Banner config, branding, etc. — safe subset for non-admin users + banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner") + branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding") + systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt") + policies, _ := h.stores.Policies.GetAll(c.Request.Context()) + + // Only tell the user whether admin prompt exists — don't expose content + hasAdminPrompt := false + if content, ok := systemPrompt["content"].(string); ok && content != "" { + hasAdminPrompt = true + } + + c.JSON(http.StatusOK, gin.H{ + "banner": banner, + "branding": branding, + "has_admin_prompt": hasAdminPrompt, + "storage_configured": storageConfigured, + "policies": gin.H{ + "allow_registration": policies["allow_registration"], + "allow_user_byok": policies["allow_user_byok"], + "allow_user_personas": policies["allow_user_personas"], + }, + }) +} + +// ── Vault Status ──────────────────────────── + +func (h *AdminHandler) VaultStatus(c *gin.Context) { + hasKey := h.vault != nil && h.vault.HasEnvKey() + keyStr := "" + if hasKey { + keyStr = "set" // non-empty signals to VaultStatus that key is configured + } + status, err := crypto.VaultStatus(database.DB, keyStr) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"}) + return + } + c.JSON(http.StatusOK, status) +} + +// ── Provider Configs (Global) ─────────────── + +func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { + cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) + return + } + + // Redact API keys but expose has_key flag for UI + type configWithKey struct { + models.ProviderConfig + HasKey bool `json:"has_key"` + } + out := make([]configWithKey, len(cfgs)) + for i, cfg := range cfgs { + out[i] = configWithKey{ + ProviderConfig: cfg, + HasKey: cfg.HasKey(), + } + } + c.JSON(http.StatusOK, gin.H{"configs": out}) +} + +func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { + // Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-" + // so ShouldBindJSON would silently drop the api_key field. + var req struct { + Name string `json:"name" binding:"required"` + Provider string `json:"provider" binding:"required"` + Endpoint string `json:"endpoint" binding:"required"` + APIKey string `json:"api_key"` + ModelDefault string `json:"model_default,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` + Headers map[string]interface{} `json:"headers,omitempty"` + Settings map[string]interface{} `json:"settings,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if _, err := providers.Get(req.Provider); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "unsupported provider: " + req.Provider, + "supported_providers": providers.List(), + }) + return + } + + cfg := &models.ProviderConfig{ + Name: req.Name, + Provider: req.Provider, + Endpoint: req.Endpoint, + ModelDefault: req.ModelDefault, + Config: models.JSONMap(req.Config), + Headers: models.JSONMap(req.Headers), + Settings: models.JSONMap(req.Settings), + Scope: models.ScopeGlobal, + KeyScope: models.ScopeGlobal, + IsActive: true, + IsPrivate: req.IsPrivate, + } + + // Encrypt the API key + if req.APIKey != "" { + if h.vault != nil { + enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) + return + } + cfg.APIKeyEnc = enc + cfg.KeyNonce = nonce + } else { + // No vault configured — store raw bytes (unencrypted fallback) + cfg.APIKeyEnc = []byte(req.APIKey) + } + } + + if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) + return + } + + h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider}) + c.JSON(http.StatusCreated, gin.H{ + "id": cfg.ID, + "scope": cfg.Scope, + "name": cfg.Name, + "provider": cfg.Provider, + "endpoint": cfg.Endpoint, + "model_default": cfg.ModelDefault, + "config": cfg.Config, + "headers": cfg.Headers, + "settings": cfg.Settings, + "is_active": cfg.IsActive, + "is_private": cfg.IsPrivate, + "has_key": cfg.HasKey(), + "created_at": cfg.CreatedAt, + "updated_at": cfg.UpdatedAt, + }) +} + +func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) { + id := c.Param("id") + + // Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-" + var req struct { + models.ProviderConfigPatch + APIKey *string `json:"api_key,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + patch := req.ProviderConfigPatch + // Transfer api_key → encrypted fields + if req.APIKey != nil && *req.APIKey != "" { + if h.vault != nil { + enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) + return + } + patch.APIKeyEnc = enc + patch.KeyNonce = nonce + } else { + patch.APIKeyEnc = []byte(*req.APIKey) + } + } + + if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"}) + return + } + + h.auditLog(c, "provider.update", "provider_config", id, nil) + c.JSON(http.StatusOK, gin.H{"message": "config updated"}) +} + +func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { + id := c.Param("id") + // Delete associated catalog entries first + h.stores.Catalog.DeleteForProvider(c.Request.Context(), id) + if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"}) + return + } + h.auditLog(c, "provider.delete", "provider_config", id, nil) + c.JSON(http.StatusOK, gin.H{"message": "config deleted"}) +} + +// ── Model Catalog ─────────────────────────── + +func (h *AdminHandler) ListModelConfigs(c *gin.Context) { + entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) + return + } + c.JSON(http.StatusOK, gin.H{"models": entries}) +} + +func (h *AdminHandler) FetchModels(c *gin.Context) { + var req struct { + ProviderConfigID string `json:"provider_config_id"` + } + c.ShouldBindJSON(&req) + + // If no specific provider, fetch from ALL global providers + if req.ProviderConfigID == "" { + configs, err := h.stores.Providers.ListGlobal(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"}) + return + } + if len(configs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"}) + return + } + + totalAdded, totalUpdated, totalFetched := 0, 0, 0 + var errs []string + for _, cfg := range configs { + if !cfg.IsActive { + continue + } + added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg) + if err != nil { + errs = append(errs, cfg.Provider+": "+err.Error()) + continue + } + totalAdded += added + totalUpdated += updated + totalFetched += fetched + } + + result := gin.H{ + "message": "models synced", + "added": totalAdded, + "updated": totalUpdated, + "total": totalFetched, + } + if len(errs) > 0 { + result["errors"] = errs + } + c.JSON(http.StatusOK, result) + return + } + + // Single provider fetch + cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"}) + return + } + + added, updated, fetched, err := h.fetchModelsForProvider(c, cfg) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "models synced", + "added": added, + "updated": updated, + "total": fetched, + }) +} + +// fetchModelsForProvider fetches and syncs models for a single provider config. +func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) { + // Decrypt the API key for the provider API call + apiKey := "" + if len(cfg.APIKeyEnc) > 0 { + if h.vault != nil { + apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "") + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err) + } + } else { + // No vault — key stored as raw bytes (unencrypted fallback) + apiKey = string(cfg.APIKeyEnc) + } + } + + result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey) + if err != nil { + return 0, 0, 0, err + } + + h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{ + "added": result.Added, "updated": result.Updated, "total": result.Total, + }) + + return result.Added, result.Updated, result.Total, nil +} + +func (h *AdminHandler) UpdateModelConfig(c *gin.Context) { + id := c.Param("id") + var req struct { + Visibility *string `json:"visibility"` + DisplayName *string `json:"display_name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Visibility != nil { + if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"message": "model updated"}) +} + +func (h *AdminHandler) BulkUpdateModels(c *gin.Context) { + var req struct { + ProviderConfigID string `json:"provider_config_id"` + Visibility string `json:"visibility" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var err error + if req.ProviderConfigID != "" { + err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility) + } else { + err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"}) +} + +func (h *AdminHandler) DeleteModelConfig(c *gin.Context) { + id := c.Param("id") + if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "model deleted"}) +} + +// ── Stats ─────────────────────────────────── + +func (h *AdminHandler) GetStats(c *gin.Context) { + ctx := c.Request.Context() + stats := gin.H{} + + var userCount, channelCount, messageCount int + database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount) + database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount) + database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount) + + stats["users"] = userCount + stats["channels"] = channelCount + stats["messages"] = messageCount + + c.JSON(http.StatusOK, stats) +} + +// ── Helpers ───────────────────────────────── +// NOTE: ListAuditLog and ListAuditActions are in audit.go + +func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID string, metadata interface{}) { + userID, _ := c.Get("user_id") + uid, _ := userID.(string) + + var meta models.JSONMap + if metadata != nil { + b, _ := json.Marshal(metadata) + json.Unmarshal(b, &meta) + } + + entry := &models.AuditEntry{ + ActorID: &uid, + Action: action, + ResourceType: resourceType, + ResourceID: resourceID, + Metadata: meta, + IPAddress: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + } + + if err := h.stores.Audit.Log(context.Background(), entry); err != nil { + log.Printf("audit log error: %v", err) + } +} diff --git a/api.js b/api.js new file mode 100644 index 0000000..d56393f --- /dev/null +++ b/api.js @@ -0,0 +1,636 @@ +// ========================================== +// Chat Switchboard – API Client +// ========================================== +// Backend-only mode. Handles auth tokens and +// all HTTP calls. No offline fallback. +// +// BASE_PATH: injected via index.html + + + + Chat Switchboard + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
Chat Switchboard
+
+

One interface.
Every AI model.

+

Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.

+
+
Streaming responses
+
Multi-provider routing
+
🔑 Bring your own API keys
+
🏠 Self-hosted
+
✈️ Air-gap ready
+
🧠 Thinking blocks
+
+
v%%APP_VERSION%%
+
+
+
+
+
+

Welcome back

+

Sign in to continue to your workspace

+
+
+ + +
+
+
+
+
+ +
+
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + +
+
+
+ + + esc +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/k8s/backend.yaml b/k8s/backend.yaml index 1a348c4..30818c2 100644 --- a/k8s/backend.yaml +++ b/k8s/backend.yaml @@ -7,6 +7,24 @@ # switchboard-admin - username, password, email (optional) # switchboard-encryption - ENCRYPTION_KEY (required for v0.9.4+) # +# PVC: +# switchboard-storage - Extraction scratch (always needed) +# Requires ReadWriteMany (RWX) — see k8s/storage-pvc.yaml +# With S3 backend, PVC is small (extraction status only, not blobs) +# +# Variables (Gitea CI): +# STORAGE_CLASS - StorageClass for PVC (e.g. "cephfs") +# STORAGE_SIZE - PVC size (e.g. "10Gi" for PVC, "1Gi" for S3) +# STORAGE_BACKEND - "pvc" or "s3" (default: "pvc") +# EXTRACTION_CONCURRENCY - Max concurrent extractions (default "3") +# EXTRACTOR_IMAGE - Sidecar image (LibreOffice headless) +# +# S3 secrets (Gitea Secrets — only when STORAGE_BACKEND=s3): +# S3_ENDPOINT - e.g. "http://minio:9000" or Ceph RGW URL +# S3_BUCKET - bucket name (must exist before deploy) +# S3_ACCESS_KEY - S3 access key +# S3_SECRET_KEY - S3 secret key +# # Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars # are set, the backend upserts an admin user. Change the secret and # restart the pod to reset the admin password. @@ -96,6 +114,57 @@ spec: name: switchboard-encryption key: ENCRYPTION_KEY optional: true + # File storage (v0.12.0+) + - name: STORAGE_BACKEND + value: "${STORAGE_BACKEND}" + - name: STORAGE_PATH + value: "/data/storage" + - name: EXTRACTION_MODE + value: "sidecar" + - name: EXTRACTION_CONCURRENCY + value: "${EXTRACTION_CONCURRENCY}" + # S3 storage (optional — only used when STORAGE_BACKEND=s3) + - name: S3_ENDPOINT + valueFrom: + secretKeyRef: + name: switchboard-s3 + key: S3_ENDPOINT + optional: true + - name: S3_BUCKET + valueFrom: + secretKeyRef: + name: switchboard-s3 + key: S3_BUCKET + optional: true + - name: S3_ACCESS_KEY + valueFrom: + secretKeyRef: + name: switchboard-s3 + key: S3_ACCESS_KEY + optional: true + - name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: switchboard-s3 + key: S3_SECRET_KEY + optional: true + - name: S3_REGION + valueFrom: + secretKeyRef: + name: switchboard-s3 + key: S3_REGION + optional: true + - name: S3_PREFIX + valueFrom: + secretKeyRef: + name: switchboard-s3 + key: S3_PREFIX + optional: true + - name: S3_FORCE_PATH_STYLE + value: "true" + volumeMounts: + - name: storage + mountPath: /data/storage resources: requests: memory: "${BE_MEMORY_REQUEST}" @@ -119,6 +188,34 @@ spec: periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 + + # Extractor sidecar (v0.12.0 Phase 2): + # Uncomment when switchboard-extractor image is built. + # See DESIGN-0.12.0.md — Phase 2: Extraction Pipeline. + # + # - name: extractor + # image: ${EXTRACTOR_IMAGE}:${IMAGE_TAG} + # imagePullPolicy: Always + # env: + # - name: STORAGE_PATH + # value: "/data/storage" + # - name: EXTRACTION_CONCURRENCY + # value: "${EXTRACTION_CONCURRENCY}" + # volumeMounts: + # - name: storage + # mountPath: /data/storage + # resources: + # requests: + # memory: "256Mi" + # cpu: "100m" + # limits: + # memory: "1Gi" + # cpu: "1000m" + + volumes: + - name: storage + persistentVolumeClaim: + claimName: switchboard-storage${DEPLOY_SUFFIX} --- apiVersion: v1 kind: Service diff --git a/k8s/storage-pvc.yaml b/k8s/storage-pvc.yaml new file mode 100644 index 0000000..87836cf --- /dev/null +++ b/k8s/storage-pvc.yaml @@ -0,0 +1,32 @@ +# k8s/storage-pvc.yaml +# ============================================ +# Chat Switchboard - Storage PVC +# ============================================ +# Persistent storage for file attachments, extraction queue, +# and future knowledge base / compaction data. +# +# Requires ReadWriteMany (RWX) for multi-pod backend deployments. +# CephFS is recommended; NFS also works. +# +# Variables: +# NAMESPACE - deployment namespace +# STORAGE_CLASS - StorageClass name (e.g. "cephfs") +# STORAGE_SIZE - PVC size (e.g. "10Gi") +# ENVIRONMENT - deployment environment label +# ============================================ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: switchboard-storage${DEPLOY_SUFFIX} + namespace: ${NAMESPACE} + labels: + app: switchboard + component: storage + env: ${ENVIRONMENT} +spec: + accessModes: + - ReadWriteMany + storageClassName: "${STORAGE_CLASS}" + resources: + requests: + storage: ${STORAGE_SIZE} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2631767 --- /dev/null +++ b/main.go @@ -0,0 +1,508 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/crypto" + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/extraction" + "git.gobha.me/xcaliber/chat-switchboard/handlers" + "git.gobha.me/xcaliber/chat-switchboard/middleware" + "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/roles" + "git.gobha.me/xcaliber/chat-switchboard/storage" + "git.gobha.me/xcaliber/chat-switchboard/store" + postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" + _ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init() +) + +func main() { + // ── Subcommand dispatch ────────────────── + // Usage: switchboard vault rekey + if len(os.Args) > 2 && os.Args[1] == "vault" { + runVaultCommand(os.Args[2]) + return + } + if len(os.Args) > 1 && os.Args[1] == "version" { + fmt.Println("switchboard", Version) + return + } + + // ── Server startup ────────────────────── + cfg := config.Load() + + // Register LLM providers + providers.Init() + + var stores store.Stores + uekCache := crypto.NewUEKCache() + var keyResolver *crypto.KeyResolver + var objStore storage.ObjectStore + + if err := database.Connect(cfg); err != nil { + log.Printf("⚠ Database unavailable: %v", err) + log.Println(" Running in unmanaged mode (no persistence)") + } else { + // Schema check: init if fresh, upgrade if behind, proceed if current + if err := database.Migrate(); err != nil { + log.Fatalf("❌ Schema migration failed: %v", err) + } + + // Vault: enforce encryption key + backfill plaintext keys + if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil { + log.Fatalf("❌ Vault check failed: %v", err) + } + if cfg.EncryptionKey != "" { + if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil { + log.Fatalf("❌ Vault backfill failed: %v", err) + } + } + + // Derive env key for key resolver (nil if not set) + var envKey []byte + if cfg.EncryptionKey != "" { + var err error + envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey) + if err != nil { + log.Fatalf("❌ Failed to derive encryption key: %v", err) + } + log.Println(" 🔐 API key encryption active") + } + keyResolver = crypto.NewKeyResolver(envKey, uekCache) + + // Initialize store layer + stores = postgres.NewStores(database.DB) + + // Bootstrap admin from env (K8s secret) — upserts on every restart + handlers.BootstrapAdmin(cfg, stores) + + // Seed additional users from env (dev/test only, skipped in production) + handlers.SeedUsers(cfg, stores) + + // Seed builtin extensions from disk (idempotent, version-aware) + handlers.SeedBuiltinExtensions(stores, "extensions/builtin") + } + defer database.Close() + + // ── File Storage ───────────────────────── + // Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND + // overrides auto-detection. nil objStore = storage features disabled. + if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath); err != nil { + if cfg.StorageBackend != "" { + // Explicit backend requested but failed — fatal + log.Fatalf("❌ Storage init failed: %v", err) + } + log.Printf("⚠ Storage init failed: %v", err) + } else { + objStore = s + } + handlers.SetStorageConfigured(objStore != nil) + + // ── Extraction Queue ──────────────────── + // Filesystem-based queue for document text extraction (PDF, DOCX, etc.) + // Nil if storage is disabled. + var extQueue *extraction.Queue + if objStore != nil && cfg.StoragePath != "" { + q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency) + if err != nil { + log.Printf("⚠ Extraction queue init failed: %v", err) + } else { + extQueue = q + // Recover items stuck in "processing" from previous crash + if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil { + log.Printf("⚠ Extraction recovery failed: %v", err) + } else if recovered > 0 { + log.Printf(" 📋 Recovered %d stale extraction items", recovered) + } + } + } + + // Role resolver for model role dispatch (needs stores + vault) + roleResolver := roles.NewResolver(stores, keyResolver) + + r := gin.Default() + r.Use(middleware.CORS()) + + // ── Base path group ────────────────────── + base := r.Group(cfg.BasePath) + + // ── EventBus + WebSocket Hub ───────────── + bus := events.NewBus() + hub := events.NewHub(bus) + + // Health check (k8s probes hit this directly) + base.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + "version": Version, + "database": database.IsConnected(), + "schema_version": database.SchemaVersion(), + }) + }) + + // WebSocket endpoint + base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) + + // ── Auth routes (rate limited) ────────────── + auth := handlers.NewAuthHandler(cfg, stores, uekCache) + authLimiter := middleware.NewRateLimiter(1, 5) + + api := base.Group("/api/v1") + { + // Health (routable through ingress) + api.GET("/health", func(c *gin.Context) { + info := gin.H{ + "status": "ok", + "version": Version, + "schema_version": database.SchemaVersion(), + "database": database.IsConnected(), + "providers": providers.List(), + } + if database.IsConnected() { + info["registration_enabled"] = handlers.IsRegistrationEnabled(stores) + } + c.JSON(200, info) + }) + + authGroup := api.Group("/auth") + authGroup.Use(authLimiter.Limit()) + { + authGroup.POST("/register", auth.Register) + authGroup.POST("/login", auth.Login) + authGroup.POST("/refresh", auth.Refresh) + authGroup.POST("/logout", auth.Logout) + } + + // ── Public extension assets ──────────────── + // Script tags can't send Authorization headers, so asset serving must be public. + // Extension JS is the same for all users — no user-specific data. + extH := handlers.NewExtensionHandler(stores) + api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset) + + // ── Protected routes ──────────────────── + protected := api.Group("") + protected.Use(middleware.Auth(cfg)) + { + // Channels + channels := handlers.NewChannelHandler() + protected.GET("/channels", channels.ListChannels) + protected.POST("/channels", channels.CreateChannel) + protected.GET("/channels/:id", channels.GetChannel) + protected.PUT("/channels/:id", channels.UpdateChannel) + protected.DELETE("/channels/:id", channels.DeleteChannel) + + // Messages + msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) + protected.GET("/channels/:id/messages", msgs.ListMessages) + protected.POST("/channels/:id/messages", msgs.CreateMessage) + + // Message tree (forking) + protected.GET("/channels/:id/path", msgs.GetActivePath) + protected.PUT("/channels/:id/cursor", msgs.UpdateCursor) + protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage) + protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate) + protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) + + // Chat Completions + comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore) + protected.POST("/chat/completions", comp.Complete) + + // Summarize & Continue + summarize := handlers.NewSummarizeHandler(stores, roleResolver) + protected.POST("/channels/:id/summarize", summarize.Summarize) + + // Provider Configs (user-facing — replaces /api-configs) + provCfg := handlers.NewProviderConfigHandler(stores, keyResolver) + protected.GET("/api-configs", provCfg.ListConfigs) // backward compat + protected.POST("/api-configs", provCfg.CreateConfig) + protected.GET("/api-configs/:id", provCfg.GetConfig) + protected.PUT("/api-configs/:id", provCfg.UpdateConfig) + protected.DELETE("/api-configs/:id", provCfg.DeleteConfig) + protected.GET("/api-configs/:id/models", provCfg.ListModels) + protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) + + // Models (unified resolver — replaces scattered endpoints) + modelH := handlers.NewModelHandler(stores) + protected.GET("/models/enabled", modelH.ListEnabledModels) + protected.GET("/models", modelH.ListEnabledModels) // alias + + // Model Preferences + modelPrefs := handlers.NewModelPrefsHandler(stores) + protected.GET("/models/preferences", modelPrefs.GetPreferences) + protected.PUT("/models/preferences", modelPrefs.SetPreference) + protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) + + // User Settings & Profile + settings := handlers.NewSettingsHandler(uekCache) + protected.GET("/profile", settings.GetProfile) + protected.PUT("/profile", settings.UpdateProfile) + protected.POST("/profile/password", settings.ChangePassword) + protected.POST("/profile/avatar", settings.UploadAvatar) + protected.DELETE("/profile/avatar", settings.DeleteAvatar) + protected.GET("/settings", settings.GetSettings) + protected.PUT("/settings", settings.UpdateSettings) + + // Usage (personal) + usage := handlers.NewUsageHandler(stores) + protected.GET("/usage", usage.PersonalUsage) + + // Personas (replaces /presets) + personas := handlers.NewPersonaHandler(stores) + protected.GET("/presets", personas.ListUserPersonas) // backward compat + protected.POST("/presets", personas.CreateUserPersona) + protected.PUT("/presets/:id", personas.UpdateUserPersona) + protected.DELETE("/presets/:id", personas.DeleteUserPersona) + protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) + protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) + + // Notes + notes := handlers.NewNoteHandler() + protected.GET("/notes", notes.List) + protected.POST("/notes", notes.Create) + protected.GET("/notes/search", notes.Search) + protected.GET("/notes/folders", notes.ListFolders) + protected.POST("/notes/bulk-delete", notes.BulkDelete) + protected.GET("/notes/:id", notes.Get) + protected.PUT("/notes/:id", notes.Update) + protected.DELETE("/notes/:id", notes.Delete) + + // Attachments (file upload/download) + attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) + protected.POST("/channels/:id/attachments", attachH.Upload) + protected.GET("/channels/:id/attachments", attachH.ListByChannel) + protected.GET("/attachments/:id", attachH.GetMetadata) + protected.GET("/attachments/:id/download", attachH.Download) + protected.DELETE("/attachments/:id", attachH.DeleteAttachment) + + // Hook: clean up storage files when channels are deleted + handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage) + + // Teams (user: my teams) + teams := handlers.NewTeamHandler(keyResolver) + protected.GET("/teams/mine", teams.MyTeams) + + // Team admin self-service + teamScoped := protected.Group("/teams/:teamId") + teamScoped.Use(middleware.RequireTeamAdmin()) + { + teamScoped.GET("/members", teams.ListMembers) + teamScoped.POST("/members", teams.AddMember) + teamScoped.PUT("/members/:memberId", teams.UpdateMember) + teamScoped.DELETE("/members/:memberId", teams.RemoveMember) + teamScoped.GET("/models", teams.ListAvailableModels) + + // Team providers + teamScoped.GET("/providers", teams.ListTeamProviders) + teamScoped.POST("/providers", teams.CreateTeamProvider) + teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider) + teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) + teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) + + // Team audit log (team admins only — RequireTeamAdmin on group) + teamScoped.GET("/audit", teams.ListTeamAuditLog) + teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) + + // Team usage (team admins only — usage against team-owned providers) + teamUsage := handlers.NewUsageHandler(stores) + teamScoped.GET("/usage", teamUsage.TeamUsage) + + // Team personas + teamPersonas := handlers.NewPersonaHandler(stores) + teamScoped.GET("/presets", teamPersonas.ListTeamPersonas) + teamScoped.POST("/presets", teamPersonas.CreateTeamPersona) + teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona) + + // Team role overrides + teamRoles := handlers.NewRolesHandler(stores, roleResolver) + teamScoped.GET("/roles", teamRoles.ListTeamRoles) + teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole) + teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole) + } + + // Public global settings (non-admin users can read safe subset) + adm := handlers.NewAdminHandler(stores, keyResolver, uekCache) + protected.GET("/settings/public", adm.PublicSettings) + + // Extensions (user-facing) + protected.GET("/extensions", extH.ListUserExtensions) + protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings) + protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest) + protected.GET("/extensions/tools", extH.ListBrowserToolSchemas) + } + + // ── Admin routes ──────────────────────── + admin := api.Group("/admin") + admin.Use(middleware.Auth(cfg)) + admin.Use(middleware.RequireAdmin()) + { + adm := handlers.NewAdminHandler(stores, keyResolver, uekCache) + + // User management + admin.GET("/users", adm.ListUsers) + admin.POST("/users", adm.CreateUser) + admin.PUT("/users/:id/role", adm.UpdateUserRole) + admin.PUT("/users/:id/active", adm.ToggleUserActive) + admin.POST("/users/:id/reset-password", adm.ResetPassword) + admin.DELETE("/users/:id", adm.DeleteUser) + + // Global settings + admin.GET("/settings", adm.ListGlobalSettings) + admin.GET("/settings/:key", adm.GetGlobalSetting) + admin.PUT("/settings/:key", adm.UpdateGlobalSetting) + + // Stats + admin.GET("/stats", adm.GetStats) + + // Global Provider Configs + admin.GET("/configs", adm.ListGlobalConfigs) + admin.POST("/configs", adm.CreateGlobalConfig) + admin.PUT("/configs/:id", adm.UpdateGlobalConfig) + admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) + + // Model Catalog + admin.GET("/models", adm.ListModelConfigs) + admin.POST("/models/fetch", adm.FetchModels) + admin.PUT("/models/bulk", adm.BulkUpdateModels) + admin.PUT("/models/:id", adm.UpdateModelConfig) + admin.DELETE("/models/:id", adm.DeleteModelConfig) + + // Personas (admin global) + personaAdm := handlers.NewPersonaHandler(stores) + admin.GET("/presets", personaAdm.ListAdminPersonas) + admin.POST("/presets", personaAdm.CreateAdminPersona) + admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona) + admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona) + admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) + admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) + + // Teams (admin) + teamAdm := handlers.NewTeamHandler(keyResolver) + admin.GET("/teams", teamAdm.ListTeams) + admin.POST("/teams", teamAdm.CreateTeam) + admin.GET("/teams/:id", teamAdm.GetTeam) + admin.PUT("/teams/:id", teamAdm.UpdateTeam) + admin.DELETE("/teams/:id", teamAdm.DeleteTeam) + admin.GET("/teams/:id/members", teamAdm.ListMembers) + admin.POST("/teams/:id/members", teamAdm.AddMember) + admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember) + admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember) + + // Audit log + admin.GET("/audit", adm.ListAuditLog) + admin.GET("/audit/actions", adm.ListAuditActions) + + // Model Roles + rolesH := handlers.NewRolesHandler(stores, roleResolver) + admin.GET("/roles", rolesH.ListRoles) + admin.GET("/roles/:role", rolesH.GetRole) + admin.PUT("/roles/:role", rolesH.UpdateRole) + admin.POST("/roles/:role/test", rolesH.TestRole) + + // Usage & Pricing + usageH := handlers.NewUsageHandler(stores) + admin.GET("/usage", usageH.AdminUsage) + admin.GET("/usage/users/:id", usageH.AdminUserUsage) + admin.GET("/usage/teams/:id", usageH.AdminTeamUsage) + admin.GET("/pricing", usageH.ListPricing) + admin.PUT("/pricing", usageH.UpsertPricing) + admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing) + + // Storage status + storageH := handlers.NewStorageHandler(objStore) + admin.GET("/storage/status", storageH.Status) + + // Vault + admin.GET("/vault/status", adm.VaultStatus) + + // Storage management (orphan cleanup) + attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue) + admin.GET("/storage/orphans", attachAdm.OrphanCount) + admin.POST("/storage/cleanup", attachAdm.CleanupOrphans) + admin.GET("/storage/extraction", attachAdm.ExtractionStatus) + + // Extensions (admin) + extAdm := handlers.NewExtensionHandler(stores) + admin.GET("/extensions", extAdm.AdminListExtensions) + admin.POST("/extensions", extAdm.AdminInstallExtension) + admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension) + admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension) + } + } + + bp := cfg.BasePath + if bp == "" { + bp = "/" + } + log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) + log.Printf(" Base path: %s", bp) + log.Printf(" Schema: %s", database.SchemaVersion()) + log.Printf(" Providers: %v", providers.List()) + if objStore != nil { + log.Printf(" Storage: %s", objStore.Backend()) + if extQueue != nil { + log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency) + } else { + log.Printf(" Extraction: disabled") + } + } else { + log.Printf(" Storage: disabled") + } + log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) + if err := r.Run(":" + cfg.Port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} + +// ── Vault CLI Commands ────────────────────── + +func runVaultCommand(subcmd string) { + cfg := config.Load() + + switch strings.ToLower(subcmd) { + case "rekey": + if cfg.DatabaseURL == "" { + log.Fatal("DATABASE_URL is required for vault operations") + } + if err := database.Connect(cfg); err != nil { + log.Fatalf("❌ Database connection failed: %v", err) + } + defer database.Close() + + oldKey := os.Getenv("ENCRYPTION_KEY") + newKey := os.Getenv("NEW_ENCRYPTION_KEY") + + if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil { + log.Fatalf("❌ Vault rekey failed: %v", err) + } + + case "status": + if cfg.DatabaseURL == "" { + log.Fatal("DATABASE_URL is required for vault operations") + } + if err := database.Connect(cfg); err != nil { + log.Fatalf("❌ Database connection failed: %v", err) + } + defer database.Close() + + status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey) + if err != nil { + log.Fatalf("❌ Failed to get vault status: %v", err) + } + fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet) + fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys) + fmt.Printf("Vault users (active): %d\n", status.VaultUsers) + + default: + fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd) + fmt.Fprintf(os.Stderr, "Usage: switchboard vault \n") + os.Exit(1) + } +} diff --git a/rekey.go b/rekey.go new file mode 100644 index 0000000..9e75aa8 --- /dev/null +++ b/rekey.go @@ -0,0 +1,115 @@ +package crypto + +import ( + "database/sql" + "fmt" + "log" +) + +// Rekey re-encrypts all global/team API keys from oldKey to newKey. +// Personal BYOK keys are unaffected (they're keyed to the user's UEK, +// not the environment-derived key). +// +// Runs in a single transaction — all-or-nothing. On failure, no keys +// are modified. +// +// Usage: +// +// switchboard vault rekey +// ENCRYPTION_KEY = current/old key +// NEW_ENCRYPTION_KEY = new key to re-encrypt with +func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error { + if oldEnvKey == "" { + return fmt.Errorf("ENCRYPTION_KEY (current key) is required") + } + if newEnvKey == "" { + return fmt.Errorf("NEW_ENCRYPTION_KEY is required") + } + if oldEnvKey == newEnvKey { + return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do") + } + + oldKey, err := DeriveKeyFromEnv(oldEnvKey) + if err != nil { + return fmt.Errorf("failed to derive old key: %w", err) + } + + newKey, err := DeriveKeyFromEnv(newEnvKey) + if err != nil { + return fmt.Errorf("failed to derive new key: %w", err) + } + + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() // no-op after commit + + // Find all global/team keys that are encrypted + rows, err := tx.Query(` + SELECT id, api_key_enc, key_nonce + FROM provider_configs + WHERE key_scope IN ('global', 'team') + AND api_key_enc IS NOT NULL + `) + if err != nil { + return fmt.Errorf("failed to query encrypted keys: %w", err) + } + + type rekeyRow struct { + id string + ciphertext []byte + nonce []byte + } + + var toRekey []rekeyRow + for rows.Next() { + var r rekeyRow + if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil { + rows.Close() + return fmt.Errorf("failed to scan row: %w", err) + } + toRekey = append(toRekey, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("row iteration error: %w", err) + } + + if len(toRekey) == 0 { + log.Println("No global/team encrypted keys found — nothing to rekey.") + return nil + } + + log.Printf("Rekeying %d provider config(s)...", len(toRekey)) + + // Decrypt with old key, re-encrypt with new key, update in place + for _, r := range toRekey { + plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey) + if err != nil { + return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err) + } + + newCiphertext, newNonce, err := Encrypt(plaintext, newKey) + if err != nil { + return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err) + } + + _, err = tx.Exec(` + UPDATE provider_configs + SET api_key_enc = $1, key_nonce = $2 + WHERE id = $3 + `, newCiphertext, newNonce, r.id) + if err != nil { + return fmt.Errorf("failed to update config %s: %w", r.id, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit rekey transaction: %w", err) + } + + log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey)) + log.Println("Update ENCRYPTION_KEY to the new value and restart the server.") + return nil +} diff --git a/server/.env.example b/server/.env.example index 11087d7..06a3ade 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,4 +1,4 @@ -# Chat Switchboard v0.9 - Server Environment Variables +# Chat Switchboard v0.12 - Server Environment Variables # Copy this file to .env and fill in the values # ── Server ─────────────────────────────────── @@ -44,3 +44,22 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 # ── Logging ────────────────────────────────── LOG_LEVEL=info + +# ── File Storage ───────────────────────────── +# Backend: "pvc" (local filesystem), "s3" (S3-compatible), or empty (auto-detect) +STORAGE_BACKEND= +STORAGE_PATH=/data/storage + +# S3 settings (only when STORAGE_BACKEND=s3) +# Works with MinIO, Ceph RGW, AWS S3. +# S3_ENDPOINT=http://minio:9000 +# S3_BUCKET=switchboard-storage +# S3_ACCESS_KEY= +# S3_SECRET_KEY= +# S3_REGION=us-east-1 +# S3_PREFIX= # optional key prefix for shared buckets +# S3_FORCE_PATH_STYLE=true # required for MinIO, Ceph RGW + +# ── Extraction ─────────────────────────────── +# EXTRACTION_MODE=inline # "inline" or "sidecar" +# EXTRACTION_CONCURRENCY=3 diff --git a/server/config/config.go b/server/config/config.go index 65f20a1..bbd0fb1 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "strconv" "github.com/joho/godotenv" ) @@ -26,6 +27,28 @@ type Config struct { // Used to derive AES-256 key for global/team provider API keys. // Personal keys use per-user UEK (derived from password). EncryptionKey string + + // File storage (v0.12.0+) + // STORAGE_BACKEND: "pvc" or "s3". Empty = auto-detect (pvc if path writable). + // STORAGE_PATH: mount point for PVC backend (default /data/storage). + StorageBackend string + StoragePath string + + // S3-compatible storage (v0.12.0+) + // Works with AWS S3, MinIO, Ceph RGW, or any S3-compatible API. + S3Endpoint string // custom endpoint URL (required for MinIO/Ceph, optional for AWS) + S3Bucket string // bucket name (required when STORAGE_BACKEND=s3) + S3Region string // AWS region (default "us-east-1") + S3AccessKey string // access key ID + S3SecretKey string // secret access key + S3Prefix string // optional key prefix within bucket (e.g. "switchboard/") + S3ForcePathStyle bool // use path-style URLs (required for MinIO, most self-hosted) + + // Extraction pipeline (v0.12.0+) + // EXTRACTION_MODE: "inline" (in-process) or "sidecar" (shared PVC watcher). + // EXTRACTION_CONCURRENCY: max concurrent extraction jobs (default 3). + ExtractionMode string + ExtractionConcurrency int } // Load reads configuration from environment variables. @@ -45,6 +68,18 @@ func Load() *Config { AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""), SeedUsers: getEnv("SEED_USERS", ""), EncryptionKey: getEnv("ENCRYPTION_KEY", ""), + + StorageBackend: getEnv("STORAGE_BACKEND", ""), + StoragePath: getEnv("STORAGE_PATH", "/data/storage"), + S3Endpoint: getEnv("S3_ENDPOINT", ""), + S3Bucket: getEnv("S3_BUCKET", ""), + S3Region: getEnv("S3_REGION", "us-east-1"), + S3AccessKey: getEnv("S3_ACCESS_KEY", ""), + S3SecretKey: getEnv("S3_SECRET_KEY", ""), + S3Prefix: getEnv("S3_PREFIX", ""), + S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true", + ExtractionMode: getEnv("EXTRACTION_MODE", "inline"), + ExtractionConcurrency: getEnvInt("EXTRACTION_CONCURRENCY", 3), } } @@ -71,3 +106,12 @@ func getEnv(key, fallback string) string { } return fallback } + +func getEnvInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback +} diff --git a/server/crypto/rekey.go b/server/crypto/rekey.go new file mode 100644 index 0000000..9e75aa8 --- /dev/null +++ b/server/crypto/rekey.go @@ -0,0 +1,115 @@ +package crypto + +import ( + "database/sql" + "fmt" + "log" +) + +// Rekey re-encrypts all global/team API keys from oldKey to newKey. +// Personal BYOK keys are unaffected (they're keyed to the user's UEK, +// not the environment-derived key). +// +// Runs in a single transaction — all-or-nothing. On failure, no keys +// are modified. +// +// Usage: +// +// switchboard vault rekey +// ENCRYPTION_KEY = current/old key +// NEW_ENCRYPTION_KEY = new key to re-encrypt with +func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error { + if oldEnvKey == "" { + return fmt.Errorf("ENCRYPTION_KEY (current key) is required") + } + if newEnvKey == "" { + return fmt.Errorf("NEW_ENCRYPTION_KEY is required") + } + if oldEnvKey == newEnvKey { + return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do") + } + + oldKey, err := DeriveKeyFromEnv(oldEnvKey) + if err != nil { + return fmt.Errorf("failed to derive old key: %w", err) + } + + newKey, err := DeriveKeyFromEnv(newEnvKey) + if err != nil { + return fmt.Errorf("failed to derive new key: %w", err) + } + + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() // no-op after commit + + // Find all global/team keys that are encrypted + rows, err := tx.Query(` + SELECT id, api_key_enc, key_nonce + FROM provider_configs + WHERE key_scope IN ('global', 'team') + AND api_key_enc IS NOT NULL + `) + if err != nil { + return fmt.Errorf("failed to query encrypted keys: %w", err) + } + + type rekeyRow struct { + id string + ciphertext []byte + nonce []byte + } + + var toRekey []rekeyRow + for rows.Next() { + var r rekeyRow + if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil { + rows.Close() + return fmt.Errorf("failed to scan row: %w", err) + } + toRekey = append(toRekey, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("row iteration error: %w", err) + } + + if len(toRekey) == 0 { + log.Println("No global/team encrypted keys found — nothing to rekey.") + return nil + } + + log.Printf("Rekeying %d provider config(s)...", len(toRekey)) + + // Decrypt with old key, re-encrypt with new key, update in place + for _, r := range toRekey { + plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey) + if err != nil { + return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err) + } + + newCiphertext, newNonce, err := Encrypt(plaintext, newKey) + if err != nil { + return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err) + } + + _, err = tx.Exec(` + UPDATE provider_configs + SET api_key_enc = $1, key_nonce = $2 + WHERE id = $3 + `, newCiphertext, newNonce, r.id) + if err != nil { + return fmt.Errorf("failed to update config %s: %w", r.id, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit rekey transaction: %w", err) + } + + log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey)) + log.Println("Update ENCRYPTION_KEY to the new value and restart the server.") + return nil +} diff --git a/server/crypto/status.go b/server/crypto/status.go new file mode 100644 index 0000000..9211bdf --- /dev/null +++ b/server/crypto/status.go @@ -0,0 +1,46 @@ +package crypto + +import ( + "database/sql" + "fmt" +) + +// VaultStatusInfo holds vault health information for admin display. +type VaultStatusInfo struct { + EncryptionKeySet bool `json:"encryption_key_set"` + EncryptedKeys int `json:"encrypted_keys"` // provider_configs with api_key_enc + VaultUsers int `json:"vault_users"` // users with vault_set = true +} + +// VaultStatus gathers vault health metrics from the database. +// Used by both the CLI (`switchboard vault status`) and the admin API endpoint. +func VaultStatus(db *sql.DB, encryptionKey string) (*VaultStatusInfo, error) { + if db == nil { + return nil, fmt.Errorf("database not available") + } + + info := &VaultStatusInfo{ + EncryptionKeySet: encryptionKey != "", + } + + // Count encrypted provider keys (global + team + personal) + err := db.QueryRow(` + SELECT COUNT(*) FROM provider_configs + WHERE api_key_enc IS NOT NULL + `).Scan(&info.EncryptedKeys) + if err != nil { + return nil, fmt.Errorf("count encrypted keys: %w", err) + } + + // Count users with active vaults + err = db.QueryRow(` + SELECT COUNT(*) FROM users + WHERE vault_set = true + `).Scan(&info.VaultUsers) + if err != nil { + // vault_set column might not exist on very old installs + info.VaultUsers = 0 + } + + return info, nil +} diff --git a/server/database/migrations/007_attachments.sql b/server/database/migrations/007_attachments.sql new file mode 100644 index 0000000..b13227a --- /dev/null +++ b/server/database/migrations/007_attachments.sql @@ -0,0 +1,32 @@ +-- 007_attachments.sql +-- File attachments for chat messages (v0.12.0) +-- +-- Blobs live in object storage (PVC / S3). This table holds metadata. +-- Access control: always join through channels — attachments inherit +-- channel membership as their access boundary. + +CREATE TABLE IF NOT EXISTS attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id), + message_id UUID REFERENCES messages(id) ON DELETE SET NULL, + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(127) NOT NULL, + size_bytes BIGINT NOT NULL, + storage_key TEXT NOT NULL, + extracted_text TEXT, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Primary access pattern: find attachments for a channel (auth check joins here) +CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id); + +-- Quota calculation: SUM(size_bytes) WHERE user_id = $1 +CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id); + +-- Find attachments for a specific message +CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id) WHERE message_id IS NOT NULL; + +-- Orphan cleanup: find unlinked attachments older than threshold +CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE message_id IS NULL; diff --git a/server/extraction/queue.go b/server/extraction/queue.go new file mode 100644 index 0000000..f031d98 --- /dev/null +++ b/server/extraction/queue.go @@ -0,0 +1,294 @@ +package extraction + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +// ── Status Constants ─────────────────────── + +const ( + StatusPending = "pending" + StatusProcessing = "processing" + StatusComplete = "complete" + StatusFailed = "failed" + StatusSkipped = "skipped" // not extractable (images, etc.) +) + +// ── Queue Item ───────────────────────────── + +// QueueItem is the status.json written to the processing directory. +// The sidecar extractor watches this directory for pending items. +type QueueItem struct { + AttachmentID string `json:"attachment_id"` + StorageKey string `json:"storage_key"` + ContentType string `json:"content_type"` + Filename string `json:"filename"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + OutputKey string `json:"output_key,omitempty"` // path to extracted text + QueuedAt string `json:"queued_at"` + StartedAt string `json:"started_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` +} + +// ── Queue Manager ────────────────────────── + +// Queue manages the filesystem-based extraction queue. +// Processing state lives in {storagePath}/processing/{attachment_id}/status.json. +// The sidecar extractor watches for pending items and updates status. +type Queue struct { + storagePath string + concurrency int + sem chan struct{} // semaphore for concurrent extraction limit + mu sync.Mutex +} + +// NewQueue creates a new extraction queue manager. +// storagePath is the PVC mount (e.g., /data/storage). +// concurrency limits parallel extractions (default 3). +func NewQueue(storagePath string, concurrency int) (*Queue, error) { + if concurrency <= 0 { + concurrency = 3 + } + + procDir := filepath.Join(storagePath, "processing") + if err := os.MkdirAll(procDir, 0750); err != nil { + return nil, fmt.Errorf("extraction: create processing dir: %w", err) + } + + return &Queue{ + storagePath: storagePath, + concurrency: concurrency, + sem: make(chan struct{}, concurrency), + }, nil +} + +// Enqueue adds an attachment to the extraction queue. +// Creates {storagePath}/processing/{id}/status.json with status "pending". +func (q *Queue) Enqueue(attachmentID, storageKey, contentType, filename string) error { + q.mu.Lock() + defer q.mu.Unlock() + + itemDir := filepath.Join(q.storagePath, "processing", attachmentID) + if err := os.MkdirAll(itemDir, 0750); err != nil { + return fmt.Errorf("extraction: create item dir: %w", err) + } + + item := QueueItem{ + AttachmentID: attachmentID, + StorageKey: storageKey, + ContentType: contentType, + Filename: filename, + Status: StatusPending, + QueuedAt: time.Now().UTC().Format(time.RFC3339), + } + + return q.writeStatus(attachmentID, &item) +} + +// GetStatus reads the current extraction status for an attachment. +func (q *Queue) GetStatus(attachmentID string) (*QueueItem, error) { + statusPath := filepath.Join(q.storagePath, "processing", attachmentID, "status.json") + data, err := os.ReadFile(statusPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // not queued + } + return nil, fmt.Errorf("extraction: read status: %w", err) + } + + var item QueueItem + if err := json.Unmarshal(data, &item); err != nil { + return nil, fmt.Errorf("extraction: parse status: %w", err) + } + return &item, nil +} + +// MarkComplete updates status to complete and records the output key. +func (q *Queue) MarkComplete(attachmentID, outputKey string) error { + q.mu.Lock() + defer q.mu.Unlock() + + item, err := q.GetStatus(attachmentID) + if err != nil || item == nil { + return fmt.Errorf("extraction: item not found: %s", attachmentID) + } + + item.Status = StatusComplete + item.OutputKey = outputKey + item.CompletedAt = time.Now().UTC().Format(time.RFC3339) + return q.writeStatus(attachmentID, item) +} + +// MarkFailed updates status to failed with an error message. +func (q *Queue) MarkFailed(attachmentID, errMsg string) error { + q.mu.Lock() + defer q.mu.Unlock() + + item, err := q.GetStatus(attachmentID) + if err != nil || item == nil { + return fmt.Errorf("extraction: item not found: %s", attachmentID) + } + + item.Status = StatusFailed + item.Error = errMsg + item.CompletedAt = time.Now().UTC().Format(time.RFC3339) + return q.writeStatus(attachmentID, item) +} + +// Cleanup removes the processing directory for a completed/failed item. +func (q *Queue) Cleanup(attachmentID string) error { + itemDir := filepath.Join(q.storagePath, "processing", attachmentID) + return os.RemoveAll(itemDir) +} + +// RecoverStale scans for items stuck in "processing" state (crash recovery). +// Items older than maxAge are reset to "pending" for re-processing. +func (q *Queue) RecoverStale(maxAge time.Duration) (int, error) { + procDir := filepath.Join(q.storagePath, "processing") + entries, err := os.ReadDir(procDir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + + recovered := 0 + cutoff := time.Now().Add(-maxAge) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + item, err := q.GetStatus(entry.Name()) + if err != nil || item == nil { + continue + } + + if item.Status != StatusProcessing { + continue + } + + // Check if started_at is older than maxAge + if item.StartedAt != "" { + startedAt, err := time.Parse(time.RFC3339, item.StartedAt) + if err == nil && startedAt.Before(cutoff) { + q.mu.Lock() + item.Status = StatusPending + item.StartedAt = "" + item.Error = "recovered from stale processing state" + q.writeStatus(entry.Name(), item) + q.mu.Unlock() + recovered++ + log.Printf("extraction: recovered stale item %s", entry.Name()) + } + } + } + + return recovered, nil +} + +// ListPending returns all items with status "pending". +func (q *Queue) ListPending() ([]QueueItem, error) { + return q.listByStatus(StatusPending) +} + +// ListAll returns all items in the processing directory. +func (q *Queue) ListAll() ([]QueueItem, error) { + procDir := filepath.Join(q.storagePath, "processing") + entries, err := os.ReadDir(procDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var items []QueueItem + for _, entry := range entries { + if !entry.IsDir() { + continue + } + item, err := q.GetStatus(entry.Name()) + if err != nil || item == nil { + continue + } + items = append(items, *item) + } + return items, nil +} + +// ── Internal Helpers ─────────────────────── + +func (q *Queue) writeStatus(attachmentID string, item *QueueItem) error { + statusPath := filepath.Join(q.storagePath, "processing", attachmentID, "status.json") + data, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + + // Atomic write: temp + rename + tmpPath := statusPath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0640); err != nil { + return err + } + return os.Rename(tmpPath, statusPath) +} + +func (q *Queue) listByStatus(status string) ([]QueueItem, error) { + all, err := q.ListAll() + if err != nil { + return nil, err + } + + var filtered []QueueItem + for _, item := range all { + if item.Status == status { + filtered = append(filtered, item) + } + } + return filtered, nil +} + +// ── Extractable Check ────────────────────── + +// IsExtractable returns true if the content type supports text extraction. +// Images and plain text do not need extraction (handled inline by upload handler). +var extractableTypes = map[string]bool{ + "application/pdf": true, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, + "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, + "application/msword": true, + "application/vnd.ms-excel": true, + "application/vnd.oasis.opendocument.text": true, + "application/vnd.oasis.opendocument.spreadsheet": true, + "application/vnd.oasis.opendocument.presentation": true, + "application/rtf": true, +} + +// IsExtractable returns true if the given content type requires sidecar extraction. +func IsExtractable(contentType string) bool { + return extractableTypes[contentType] +} + +// ── Null Queue ───────────────────────────── + +// NullQueue is a no-op implementation for when extraction is disabled. +type NullQueue struct{} + +func (NullQueue) Enqueue(_, _, _, _ string) error { return nil } +func (NullQueue) GetStatus(_ string) (*QueueItem, error) { return nil, nil } +func (NullQueue) MarkComplete(_, _ string) error { return nil } +func (NullQueue) MarkFailed(_, _ string) error { return nil } +func (NullQueue) Cleanup(_ string) error { return nil } +func (NullQueue) RecoverStale(_ time.Duration) (int, error) { return 0, nil } +func (NullQueue) ListPending() ([]QueueItem, error) { return nil, nil } +func (NullQueue) ListAll() ([]QueueItem, error) { return nil, nil } \ No newline at end of file diff --git a/server/extraction/queue_test.go b/server/extraction/queue_test.go new file mode 100644 index 0000000..e73c185 --- /dev/null +++ b/server/extraction/queue_test.go @@ -0,0 +1,240 @@ +package extraction + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func tempQueue(t *testing.T) *Queue { + t.Helper() + dir := t.TempDir() + q, err := NewQueue(dir, 3) + if err != nil { + t.Fatalf("NewQueue: %v", err) + } + return q +} + +func TestEnqueue_CreatesStatusFile(t *testing.T) { + q := tempQueue(t) + + err := q.Enqueue("att-123", "attachments/ch/att-123_doc.pdf", "application/pdf", "doc.pdf") + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + + item, err := q.GetStatus("att-123") + if err != nil { + t.Fatalf("GetStatus: %v", err) + } + if item == nil { + t.Fatal("expected non-nil item") + } + if item.Status != StatusPending { + t.Errorf("status = %q, want %q", item.Status, StatusPending) + } + if item.AttachmentID != "att-123" { + t.Errorf("attachment_id = %q, want att-123", item.AttachmentID) + } + if item.ContentType != "application/pdf" { + t.Errorf("content_type = %q, want application/pdf", item.ContentType) + } +} + +func TestGetStatus_NotQueued(t *testing.T) { + q := tempQueue(t) + + item, err := q.GetStatus("nonexistent") + if err != nil { + t.Fatalf("GetStatus: %v", err) + } + if item != nil { + t.Error("expected nil for non-queued item") + } +} + +func TestMarkComplete(t *testing.T) { + q := tempQueue(t) + q.Enqueue("att-456", "key", "application/pdf", "test.pdf") + + err := q.MarkComplete("att-456", "processing/att-456/extracted.txt") + if err != nil { + t.Fatalf("MarkComplete: %v", err) + } + + item, _ := q.GetStatus("att-456") + if item.Status != StatusComplete { + t.Errorf("status = %q, want %q", item.Status, StatusComplete) + } + if item.OutputKey != "processing/att-456/extracted.txt" { + t.Errorf("output_key = %q", item.OutputKey) + } + if item.CompletedAt == "" { + t.Error("completed_at should be set") + } +} + +func TestMarkFailed(t *testing.T) { + q := tempQueue(t) + q.Enqueue("att-789", "key", "application/pdf", "test.pdf") + + err := q.MarkFailed("att-789", "libreoffice crashed") + if err != nil { + t.Fatalf("MarkFailed: %v", err) + } + + item, _ := q.GetStatus("att-789") + if item.Status != StatusFailed { + t.Errorf("status = %q, want %q", item.Status, StatusFailed) + } + if item.Error != "libreoffice crashed" { + t.Errorf("error = %q", item.Error) + } +} + +func TestCleanup_RemovesDirectory(t *testing.T) { + q := tempQueue(t) + q.Enqueue("att-cleanup", "key", "application/pdf", "test.pdf") + + // Verify directory exists + dir := filepath.Join(q.storagePath, "processing", "att-cleanup") + if _, err := os.Stat(dir); err != nil { + t.Fatal("expected processing dir to exist before cleanup") + } + + if err := q.Cleanup("att-cleanup"); err != nil { + t.Fatalf("Cleanup: %v", err) + } + + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Error("expected processing dir to be removed after cleanup") + } +} + +func TestListPending(t *testing.T) { + q := tempQueue(t) + q.Enqueue("att-a", "key-a", "application/pdf", "a.pdf") + q.Enqueue("att-b", "key-b", "application/pdf", "b.pdf") + q.MarkComplete("att-b", "out") + + pending, err := q.ListPending() + if err != nil { + t.Fatalf("ListPending: %v", err) + } + if len(pending) != 1 { + t.Fatalf("expected 1 pending, got %d", len(pending)) + } + if pending[0].AttachmentID != "att-a" { + t.Errorf("pending[0].AttachmentID = %q, want att-a", pending[0].AttachmentID) + } +} + +func TestListAll(t *testing.T) { + q := tempQueue(t) + q.Enqueue("att-1", "key-1", "application/pdf", "1.pdf") + q.Enqueue("att-2", "key-2", "application/pdf", "2.pdf") + q.Enqueue("att-3", "key-3", "application/pdf", "3.pdf") + + all, err := q.ListAll() + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(all) != 3 { + t.Errorf("expected 3 items, got %d", len(all)) + } +} + +func TestRecoverStale(t *testing.T) { + q := tempQueue(t) + + // Create item and manually set it to "processing" with old timestamp + q.Enqueue("att-stale", "key", "application/pdf", "stale.pdf") + item, _ := q.GetStatus("att-stale") + item.Status = StatusProcessing + item.StartedAt = time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339) + q.mu.Lock() + q.writeStatus("att-stale", item) + q.mu.Unlock() + + // Recover items stale for > 1 hour + recovered, err := q.RecoverStale(1 * time.Hour) + if err != nil { + t.Fatalf("RecoverStale: %v", err) + } + if recovered != 1 { + t.Errorf("recovered = %d, want 1", recovered) + } + + // Verify it's back to pending + item, _ = q.GetStatus("att-stale") + if item.Status != StatusPending { + t.Errorf("status = %q, want %q", item.Status, StatusPending) + } +} + +func TestRecoverStale_SkipsRecent(t *testing.T) { + q := tempQueue(t) + + // Create item "processing" but recent (not stale) + q.Enqueue("att-recent", "key", "application/pdf", "recent.pdf") + item, _ := q.GetStatus("att-recent") + item.Status = StatusProcessing + item.StartedAt = time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339) + q.mu.Lock() + q.writeStatus("att-recent", item) + q.mu.Unlock() + + recovered, err := q.RecoverStale(1 * time.Hour) + if err != nil { + t.Fatalf("RecoverStale: %v", err) + } + if recovered != 0 { + t.Errorf("recovered = %d, want 0 (item is recent)", recovered) + } +} + +func TestIsExtractable(t *testing.T) { + tests := []struct { + contentType string + want bool + }{ + {"application/pdf", true}, + {"application/vnd.openxmlformats-officedocument.wordprocessingml.document", true}, + {"image/png", false}, + {"text/plain", false}, + {"image/jpeg", false}, + {"application/rtf", true}, + } + + for _, tc := range tests { + got := IsExtractable(tc.contentType) + if got != tc.want { + t.Errorf("IsExtractable(%q) = %v, want %v", tc.contentType, got, tc.want) + } + } +} + +func TestAtomicWrite(t *testing.T) { + q := tempQueue(t) + + // Enqueue and verify the file is valid JSON + q.Enqueue("att-atomic", "key", "application/pdf", "test.pdf") + + statusPath := filepath.Join(q.storagePath, "processing", "att-atomic", "status.json") + data, err := os.ReadFile(statusPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + // Verify no .tmp file left behind + tmpPath := statusPath + ".tmp" + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + t.Error("temp file should not exist after atomic write") + } + + if len(data) == 0 { + t.Error("status.json should not be empty") + } +} diff --git a/server/go.mod b/server/go.mod index 6c9313e..91a38df 100644 --- a/server/go.mod +++ b/server/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + github.com/minio/minio-go/v7 v7.0.82 golang.org/x/crypto v0.14.0 ) diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 60b7a42..c08d2d6 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -277,9 +277,10 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "banner": banner, - "branding": branding, - "has_admin_prompt": hasAdminPrompt, + "banner": banner, + "branding": branding, + "has_admin_prompt": hasAdminPrompt, + "storage_configured": storageConfigured, "policies": gin.H{ "allow_registration": policies["allow_registration"], "allow_user_byok": policies["allow_user_byok"], @@ -288,6 +289,22 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) { }) } +// ── Vault Status ──────────────────────────── + +func (h *AdminHandler) VaultStatus(c *gin.Context) { + hasKey := h.vault != nil && h.vault.HasEnvKey() + keyStr := "" + if hasKey { + keyStr = "set" // non-empty signals to VaultStatus that key is configured + } + status, err := crypto.VaultStatus(database.DB, keyStr) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"}) + return + } + c.JSON(http.StatusOK, status) +} + // ── Provider Configs (Global) ─────────────── func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { diff --git a/server/handlers/attachments.go b/server/handlers/attachments.go new file mode 100644 index 0000000..ce76c91 --- /dev/null +++ b/server/handlers/attachments.go @@ -0,0 +1,476 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/extraction" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/storage" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Default Limits ───────────────────────── +// Overridable via global_settings keys. +const ( + defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB + defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file) + defaultMaxAttachmentsPerMsg = 5 // future: multi-file per message + defaultOrphanMaxAge = 24 * time.Hour +) + +// allowedMIMETypes is the default allowlist. Admin can override via global_settings. +var allowedMIMETypes = map[string]bool{ + // Images + "image/jpeg": true, "image/png": true, "image/gif": true, + "image/webp": true, "image/svg+xml": true, + // Documents + "application/pdf": true, + "text/plain": true, "text/markdown": true, "text/csv": true, + // Microsoft Office + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, + "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, + "application/msword": true, "application/vnd.ms-excel": true, + // OpenDocument + "application/vnd.oasis.opendocument.text": true, + "application/vnd.oasis.opendocument.spreadsheet": true, + "application/vnd.oasis.opendocument.presentation": true, + // Other + "application/rtf": true, +} + +// ── Handler ──────────────────────────────── + +type AttachmentHandler struct { + stores store.Stores + objStore storage.ObjectStore + extQueue *extraction.Queue // nil if extraction disabled +} + +func NewAttachmentHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *AttachmentHandler { + return &AttachmentHandler{stores: stores, objStore: objStore, extQueue: extQueue} +} + +// ── Upload ───────────────────────────────── +// POST /api/v1/channels/:id/attachments +// Multipart form: file field "file", returns attachment metadata. +func (h *AttachmentHandler) Upload(c *gin.Context) { + if h.objStore == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) + return + } + + userID := getUserID(c) + channelID := c.Param("id") + + // Verify channel ownership + if !h.verifyChannelAccess(c, channelID, userID) { + return + } + + // Parse multipart + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) + return + } + defer file.Close() + + // Size check + if header.Size > defaultMaxFileSize { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("file too large (max %d MB)", defaultMaxFileSize/(1024*1024)), + }) + return + } + + // MIME detection: read first 512 bytes for sniffing, then reset + buf := make([]byte, 512) + n, _ := file.Read(buf) + detectedType := http.DetectContentType(buf[:n]) + + // Reset reader to start + if _, err := file.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"}) + return + } + + // Normalize MIME type (strip params like charset) + contentType := detectedType + if idx := strings.Index(contentType, ";"); idx > 0 { + contentType = strings.TrimSpace(contentType[:idx]) + } + + // For types that DetectContentType can't distinguish (returns application/octet-stream), + // fall back to extension-based detection + if contentType == "application/octet-stream" { + ext := strings.ToLower(filepath.Ext(header.Filename)) + if mapped, ok := extToMIME[ext]; ok { + contentType = mapped + } + } + + // Allowlist check + if !allowedMIMETypes[contentType] { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("file type %q not allowed", contentType), + }) + return + } + + // Build storage key: attachments/{channel_id}/{attachment_id}_{filename} + // We generate the ID first via a temp UUID, then use it in the key. + att := &models.Attachment{ + ChannelID: channelID, + UserID: userID, + Filename: sanitizeFilename(header.Filename), + ContentType: contentType, + SizeBytes: header.Size, + Metadata: models.JSONMap{ + "extraction_status": "pending", + }, + } + + // Create PG row first to get the UUID + // storage_key is set after we have the ID + att.StorageKey = "placeholder" + if err := h.stores.Attachments.Create(c.Request.Context(), att); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create attachment record"}) + return + } + + // Now build the real storage key and update + att.StorageKey = fmt.Sprintf("attachments/%s/%s_%s", channelID, att.ID, att.Filename) + + // Write to object store + if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil { + // Rollback PG row on storage failure + h.stores.Attachments.Delete(c.Request.Context(), att.ID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"}) + return + } + + // Update storage_key in PG + database.DB.ExecContext(c.Request.Context(), + `UPDATE attachments SET storage_key = $1 WHERE id = $2`, + att.StorageKey, att.ID) + + // For images, mark extraction as not needed (complete immediately) + if strings.HasPrefix(contentType, "image/") { + h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{ + "extraction_status": "complete", + }) + att.Metadata["extraction_status"] = "complete" + } + + // For text/plain, extract inline (trivial — just read the file) + if contentType == "text/plain" || contentType == "text/markdown" || contentType == "text/csv" { + if _, err := file.Seek(0, io.SeekStart); err == nil { + if textBytes, err := io.ReadAll(file); err == nil { + text := string(textBytes) + h.stores.Attachments.SetExtractedText(c.Request.Context(), att.ID, text) + h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{ + "extraction_status": "complete", + }) + att.Metadata["extraction_status"] = "complete" + } + } + } + + // For documents requiring extraction (PDF, DOCX, etc.), enqueue for sidecar + if extraction.IsExtractable(contentType) && h.extQueue != nil { + if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil { + log.Printf("extraction enqueue failed for %s: %v", att.ID, err) + // Non-fatal: file is uploaded, just won't have extracted text + } + } + + c.JSON(http.StatusCreated, att) +} + +// ── Download ─────────────────────────────── +// GET /api/v1/attachments/:id/download +// Streams file content with auth check via channel membership. +func (h *AttachmentHandler) Download(c *gin.Context) { + if h.objStore == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) + return + } + + userID := getUserID(c) + attID := c.Param("id") + + att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"}) + return + } + + // Channel-scoped access check + if !h.verifyChannelAccess(c, att.ChannelID, userID) { + return + } + + reader, size, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) + return + } + defer reader.Close() + + c.Header("Content-Type", att.ContentType) + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename)) + c.Header("Content-Length", fmt.Sprintf("%d", size)) + c.Status(http.StatusOK) + io.Copy(c.Writer, reader) +} + +// ── Get Metadata ─────────────────────────── +// GET /api/v1/attachments/:id +func (h *AttachmentHandler) GetMetadata(c *gin.Context) { + userID := getUserID(c) + attID := c.Param("id") + + att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"}) + return + } + + if !h.verifyChannelAccess(c, att.ChannelID, userID) { + return + } + + c.JSON(http.StatusOK, att) +} + +// ── List Channel Attachments ─────────────── +// GET /api/v1/channels/:id/attachments +func (h *AttachmentHandler) ListByChannel(c *gin.Context) { + userID := getUserID(c) + channelID := c.Param("id") + + if !h.verifyChannelAccess(c, channelID, userID) { + return + } + + attachments, err := h.stores.Attachments.GetByChannel(c.Request.Context(), channelID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list attachments"}) + return + } + if attachments == nil { + attachments = []models.Attachment{} + } + + c.JSON(http.StatusOK, gin.H{"attachments": attachments}) +} + +// ── Delete ───────────────────────────────── +// DELETE /api/v1/attachments/:id +func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) { + userID := getUserID(c) + attID := c.Param("id") + + att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"}) + return + } + + if !h.verifyChannelAccess(c, att.ChannelID, userID) { + return + } + + // Delete from PG (returns the row for storage cleanup) + deleted, err := h.stores.Attachments.Delete(c.Request.Context(), attID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete attachment"}) + return + } + + // Clean up storage (async-safe: fire and forget with background context) + if h.objStore != nil && deleted != nil { + storageKey := deleted.StorageKey + go func() { + ctx := context.Background() + if err := h.objStore.Delete(ctx, storageKey); err != nil { + log.Printf("storage cleanup failed for %s: %v", storageKey, err) + } + // Also clean up thumbnail if it exists + h.objStore.Delete(ctx, storageKey+"_thumb.jpg") + }() + } + + c.JSON(http.StatusOK, gin.H{"message": "attachment deleted"}) +} + +// ── Admin: Orphan Cleanup ────────────────── +// POST /admin/storage/cleanup +func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) { + orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"}) + return + } + + deleted := 0 + var freedBytes int64 + + for _, att := range orphans { + if _, err := h.stores.Attachments.Delete(c.Request.Context(), att.ID); err != nil { + log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err) + continue + } + if h.objStore != nil { + h.objStore.Delete(c.Request.Context(), att.StorageKey) + h.objStore.Delete(c.Request.Context(), att.StorageKey+"_thumb.jpg") + } + deleted++ + freedBytes += att.SizeBytes + } + + c.JSON(http.StatusOK, gin.H{ + "deleted": deleted, + "freed_bytes": freedBytes, + "scanned": len(orphans), + }) +} + +// ── Admin: Orphan Count ──────────────────── +// GET /admin/storage/orphans (for the admin panel card) +func (h *AttachmentHandler) OrphanCount(c *gin.Context) { + orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"}) + return + } + + var totalBytes int64 + for _, att := range orphans { + totalBytes += att.SizeBytes + } + + c.JSON(http.StatusOK, gin.H{ + "count": len(orphans), + "reclaimable_bytes": totalBytes, + }) +} + +// ── Admin: Extraction Queue Status ───────── +// GET /admin/storage/extraction +func (h *AttachmentHandler) ExtractionStatus(c *gin.Context) { + if h.extQueue == nil { + c.JSON(http.StatusOK, gin.H{ + "enabled": false, + "items": []interface{}{}, + }) + return + } + + items, err := h.extQueue.ListAll() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list extraction queue"}) + return + } + if items == nil { + items = []extraction.QueueItem{} + } + + // Count by status + counts := map[string]int{} + for _, item := range items { + counts[item.Status]++ + } + + c.JSON(http.StatusOK, gin.H{ + "enabled": true, + "total": len(items), + "counts": counts, + "items": items, + }) +} + +// ── Channel Delete Hook ──────────────────── +// Called by ChannelHandler.DeleteChannel to clean up storage. +func (h *AttachmentHandler) CleanupChannelStorage(channelID string) { + if h.objStore == nil { + return + } + // CASCADE already deleted PG rows. Clean up filesystem. + prefix := fmt.Sprintf("attachments/%s", channelID) + if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil { + log.Printf("storage cleanup for channel %s failed: %v", channelID, err) + } +} + +// ── Helpers ──────────────────────────────── + +// verifyChannelAccess checks that the requesting user owns the channel. +// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission). +func (h *AttachmentHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool { + var ownerID string + err := database.DB.QueryRowContext(c.Request.Context(), + `SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) + return false + } + if ownerID != userID { + // Check if user is admin (admins can access any channel) + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return false + } + } + return true +} + +// sanitizeFilename cleans a filename for safe storage. +func sanitizeFilename(name string) string { + // Take only the base name (strip path separators) + name = filepath.Base(name) + // Replace problematic characters + replacer := strings.NewReplacer( + "/", "_", "\\", "_", "..", "_", "\x00", "", + ) + name = replacer.Replace(name) + if name == "" || name == "." { + name = "unnamed" + } + // Truncate to 200 chars (leave room for UUID prefix in storage key) + if len(name) > 200 { + ext := filepath.Ext(name) + name = name[:200-len(ext)] + ext + } + return name +} + +// extToMIME maps file extensions to MIME types for cases where +// http.DetectContentType returns application/octet-stream. +var extToMIME = map[string]string{ + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".doc": "application/msword", + ".xls": "application/vnd.ms-excel", + ".odt": "application/vnd.oasis.opendocument.text", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".rtf": "application/rtf", + ".md": "text/markdown", + ".csv": "text/csv", + ".svg": "image/svg+xml", +} diff --git a/server/handlers/channels.go b/server/handlers/channels.go index 83b41ca..844afd8 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -2,6 +2,7 @@ package handlers import ( "database/sql" + "encoding/json" "math" "net/http" "strconv" @@ -27,33 +28,35 @@ type createChannelRequest struct { } type updateChannelRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Model *string `json:"model,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - APIConfigID *string `json:"provider_config_id,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` - IsPinned *bool `json:"is_pinned,omitempty"` - Folder *string `json:"folder,omitempty"` - Tags []string `json:"tags,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Model *string `json:"model,omitempty"` + SystemPrompt *string `json:"system_prompt,omitempty"` + APIConfigID *string `json:"provider_config_id,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` + IsPinned *bool `json:"is_pinned,omitempty"` + Folder *string `json:"folder,omitempty"` + Tags []string `json:"tags,omitempty"` + Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings } type channelResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Title string `json:"title"` - Type string `json:"type"` - Description *string `json:"description"` - Model *string `json:"model"` - APIConfigID *string `json:"provider_config_id"` - SystemPrompt *string `json:"system_prompt"` - IsArchived bool `json:"is_archived"` - IsPinned bool `json:"is_pinned"` - Folder *string `json:"folder"` - Tags []string `json:"tags"` - MessageCount int `json:"message_count"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + Type string `json:"type"` + Description *string `json:"description"` + Model *string `json:"model"` + APIConfigID *string `json:"provider_config_id"` + SystemPrompt *string `json:"system_prompt"` + IsArchived bool `json:"is_archived"` + IsPinned bool `json:"is_pinned"` + Folder *string `json:"folder"` + Tags []string `json:"tags"` + Settings json.RawMessage `json:"settings,omitempty"` + MessageCount int `json:"message_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type paginatedResponse struct { @@ -72,6 +75,16 @@ func NewChannelHandler() *ChannelHandler { return &ChannelHandler{} } +// channelDeleteHook is called after a channel is successfully deleted. +// Set at startup by SetChannelDeleteHook to clean up storage files. +var channelDeleteHook func(channelID string) + +// SetChannelDeleteHook registers a callback invoked after channel deletion. +// Used to clean up attachment files on the storage backend. +func SetChannelDeleteHook(fn func(channelID string)) { + channelDeleteHook = fn +} + // ── Helpers ───────────────────────────────── // getUserID extracts the authenticated user's ID from context. @@ -141,7 +154,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { // Fetch channels with message count query := ` SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at FROM channels c @@ -186,7 +199,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { err := rows.Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, - pq.Array(&tags), + pq.Array(&tags), &ch.Settings, &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) if err != nil { @@ -236,13 +249,13 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt, - is_archived, is_pinned, folder, tags, created_at, updated_at + is_archived, is_pinned, folder, tags, settings, created_at, updated_at `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID, req.Folder, pq.Array(req.Tags), ).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, - pq.Array(&tags), &ch.CreatedAt, &ch.UpdatedAt, + pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) @@ -284,7 +297,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) { var tags []string err := database.DB.QueryRow(` SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at FROM channels c @@ -295,7 +308,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) { `, channelID, userID).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, - pq.Array(&tags), + pq.Array(&tags), &ch.Settings, &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) @@ -383,6 +396,12 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) { if req.Tags != nil { addClause("tags", pq.Array(req.Tags)) } + if req.Settings != nil { + // JSONB merge: new settings keys overwrite existing, unmentioned keys preserved + setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb") + args = append(args, []byte(*req.Settings)) + argN++ + } if len(setClauses) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) @@ -430,5 +449,10 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) { return } + // Clean up storage files (CASCADE already removed PG attachment rows) + if channelDeleteHook != nil { + go channelDeleteHook(channelID) + } + c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) } diff --git a/server/handlers/completion.go b/server/handlers/completion.go index fcd80ec..f5cc35e 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -3,49 +3,54 @@ package handlers import ( "context" "database/sql" + "encoding/base64" "encoding/json" "fmt" + "io" "log" "net/http" "strings" "github.com/gin-gonic/gin" + capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" + "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/crypto" + "git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/store" - capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/tools" ) // ── Request Types ─────────────────────────── type completionRequest struct { - ChannelID string `json:"channel_id"` // preferred; validated manually below - ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id - Content string `json:"content" binding:"required"` - Model string `json:"model,omitempty"` - PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config - APIConfigID string `json:"provider_config_id,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Stream *bool `json:"stream,omitempty"` + ChannelID string `json:"channel_id"` // preferred; validated manually below + ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id + Content string `json:"content" binding:"required"` + Model string `json:"model,omitempty"` + PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config + APIConfigID string `json:"provider_config_id,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stream *bool `json:"stream,omitempty"` + AttachmentIDs []string `json:"attachment_ids,omitempty"` // staged attachment UUIDs to include in request } // CompletionHandler proxies LLM requests through the backend. type CompletionHandler struct { - vault *crypto.KeyResolver - stores store.Stores - hub *events.Hub // WebSocket hub for browser tool bridge + vault *crypto.KeyResolver + stores store.Stores + hub *events.Hub // WebSocket hub for browser tool bridge + objStore storage.ObjectStore // file storage for attachment content (nil = disabled) } // NewCompletionHandler creates a new handler. -func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler { - return &CompletionHandler{vault: vault, stores: stores, hub: hub} +func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *CompletionHandler { + return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore} } // ── Chat Completion ───────────────────────── @@ -138,26 +143,53 @@ func (h *CompletionHandler) Complete(c *gin.Context) { return } - // Add the new user message - messages = append(messages, providers.Message{ + // Resolve capabilities early — needed for vision gating below + caps := h.getModelCapabilities(c, model, configID) + + // Build user message — multimodal if attachments are present + userMsg := providers.Message{ Role: "user", Content: req.Content, - }) + } - // Persist user message - if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil); err != nil { + if len(req.AttachmentIDs) > 0 && h.objStore != nil { + parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.AttachmentIDs, caps) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(parts) > 0 { + // Multimodal (images present): use ContentParts + userMsg.ContentParts = parts + } else if augContent != req.Content { + // Doc-only: use augmented text content + userMsg.Content = augContent + } + } + + messages = append(messages, userMsg) + + // Persist user message (text-only for storage — multimodal parts are ephemeral) + msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil) + if err != nil { log.Printf("Failed to persist user message: %v", err) } + // Link attachments to the persisted message + if msgID != "" && len(req.AttachmentIDs) > 0 { + for _, attID := range req.AttachmentIDs { + if err := h.stores.Attachments.SetMessageID(c.Request.Context(), attID, msgID); err != nil { + log.Printf("Failed to link attachment %s to message %s: %v", attID, msgID, err) + } + } + } + // Build provider request provReq := providers.CompletionRequest{ Model: model, Messages: messages, } - // Resolve capabilities for this model — auto-set defaults - caps := h.getModelCapabilities(c, model, configID) - if req.MaxTokens > 0 { provReq.MaxTokens = req.MaxTokens } else { @@ -405,6 +437,128 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi return ResolveModelCaps(c, model, apiConfigID) } +// ── Multimodal Assembly ───────────────────── +// Builds content parts from attachments for the user message. +// +// Returns: +// - parts: ContentParts array (non-nil only when images are present) +// - augContent: enriched text content with document context (for doc-only case) +// - validIDs: attachment IDs that were successfully processed +// - error: if any attachment is invalid or vision is needed but missing +// +// Rules: +// - Images → base64 data URI (requires vision capability) +// - Documents with extracted_text → text injection +// - Documents without extraction → filename placeholder +// - Text is always the first part + +func (h *CompletionHandler) buildMultimodalParts( + c *gin.Context, + channelID, textContent string, + attachmentIDs []string, + caps models.ModelCapabilities, +) ([]providers.ContentPart, string, []string, error) { + + parts := []providers.ContentPart{ + {Type: "text", Text: textContent}, + } + var docTexts []string + var validIDs []string + hasImage := false + + for _, attID := range attachmentIDs { + att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID) + if err != nil { + return nil, "", nil, fmt.Errorf("attachment %s not found", attID) + } + + // Security: verify attachment belongs to this channel + if att.ChannelID != channelID { + return nil, "", nil, fmt.Errorf("attachment %s does not belong to this channel", attID) + } + + if isImageContentType(att.ContentType) { + // Vision gating: reject images if model lacks vision + if !caps.Vision { + return nil, "", nil, fmt.Errorf("model does not support image input; remove image attachments or choose a vision-capable model") + } + + // Read image from storage, base64 encode, build data URI + reader, _, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey) + if err != nil { + log.Printf("Failed to read attachment %s from storage: %v", attID, err) + parts = append(parts, providers.ContentPart{ + Type: "text", + Text: fmt.Sprintf("[Image: %s — failed to read from storage]", att.Filename), + }) + validIDs = append(validIDs, attID) + continue + } + data, err := io.ReadAll(reader) + reader.Close() + if err != nil { + log.Printf("Failed to read attachment %s bytes: %v", attID, err) + validIDs = append(validIDs, attID) + continue + } + + b64 := base64.StdEncoding.EncodeToString(data) + dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64) + + parts = append(parts, providers.ContentPart{ + Type: "image_url", + ImageURL: &providers.ImageURL{ + URL: dataURI, + Detail: "auto", + }, + }) + hasImage = true + + } else if att.ExtractedText != nil && *att.ExtractedText != "" { + // Document with extracted text → inject as context + docText := fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText) + parts = append(parts, providers.ContentPart{ + Type: "text", + Text: docText, + }) + docTexts = append(docTexts, docText) + + } else { + // Document without extraction (pending, failed, or not extractable) + status := "pending" + if s, ok := att.Metadata["extraction_status"].(string); ok { + status = s + } + placeholder := fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status) + parts = append(parts, providers.ContentPart{ + Type: "text", + Text: placeholder, + }) + docTexts = append(docTexts, placeholder) + } + + validIDs = append(validIDs, attID) + } + + // If images present → use ContentParts (multimodal array) + if hasImage { + return parts, textContent, validIDs, nil + } + + // Doc-only: merge document context into a single text string (more efficient) + augmented := textContent + for _, dt := range docTexts { + augmented += "\n\n" + dt + } + return nil, augmented, validIDs, nil +} + +// isImageContentType returns true for MIME types that should be sent as +// base64 image content parts rather than text extraction. +func isImageContentType(ct string) bool { + return strings.HasPrefix(ct, "image/") +} + // ── Config Resolution ─────────────────────── // Priority: request.provider_config_id → chat.provider_config_id → user's first active config diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index a93be95..4b0d08b 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -17,7 +17,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/store/postgres" + postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" ) // ── Test Harness ──────────────────────────── @@ -132,9 +132,19 @@ func setupHarness(t *testing.T) *testHarness { channels := NewChannelHandler() protected.GET("/channels", channels.ListChannels) protected.POST("/channels", channels.CreateChannel) + protected.GET("/channels/:id", channels.GetChannel) + protected.DELETE("/channels/:id", channels.DeleteChannel) + + // Attachments (nil storage = upload returns 503, but metadata works) + attachH := NewAttachmentHandler(stores, nil, nil) + protected.POST("/channels/:id/attachments", attachH.Upload) + protected.GET("/channels/:id/attachments", attachH.ListByChannel) + protected.GET("/attachments/:id", attachH.GetMetadata) + protected.GET("/attachments/:id/download", attachH.Download) + protected.DELETE("/attachments/:id", attachH.DeleteAttachment) // Completions - completions := NewCompletionHandler(nil, stores, nil) + completions := NewCompletionHandler(nil, stores, nil, nil) protected.POST("/chat/completions", completions.Complete) // Admin routes diff --git a/server/handlers/messages.go b/server/handlers/messages.go index ef877a9..0a2a8a6 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -15,6 +15,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/tools" ) @@ -58,14 +59,15 @@ type cursorRequest struct { // MessageHandler holds dependencies for message endpoints. type MessageHandler struct { - vault *crypto.KeyResolver - stores store.Stores - hub *events.Hub + vault *crypto.KeyResolver + stores store.Stores + hub *events.Hub + objStore storage.ObjectStore } // NewMessageHandler creates a new message handler. -func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler { - return &MessageHandler{vault: vault, stores: stores, hub: hub} +func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *MessageHandler { + return &MessageHandler{vault: vault, stores: stores, hub: hub, objStore: objStore} } // ── List Messages (flat, all branches) ────── @@ -360,7 +362,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { // ── Resolve model + provider ── - comp := NewCompletionHandler(h.vault, h.stores, h.hub) + comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore) var presetSystemPrompt string model := req.Model diff --git a/server/handlers/storage.go b/server/handlers/storage.go new file mode 100644 index 0000000..2b156b4 --- /dev/null +++ b/server/handlers/storage.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/storage" +) + +// storageConfigured is a package-level flag set during init. +// Read by PublicSettings to include in the boot payload. +var storageConfigured bool + +// SetStorageConfigured sets the package-level flag indicating whether +// file storage is available. Called from main.go after storage init. +func SetStorageConfigured(configured bool) { + storageConfigured = configured +} + +// StorageHandler handles file storage admin endpoints. +type StorageHandler struct { + store storage.ObjectStore +} + +// NewStorageHandler creates a StorageHandler. +// store may be nil if storage is not configured. +func NewStorageHandler(store storage.ObjectStore) *StorageHandler { + return &StorageHandler{store: store} +} + +// Configured returns true if a storage backend is available. +func (h *StorageHandler) Configured() bool { + return h.store != nil +} + +// Status returns storage backend status and statistics. +// +// GET /admin/storage/status +func (h *StorageHandler) Status(c *gin.Context) { + if h.store == nil { + c.JSON(http.StatusOK, gin.H{ + "backend": "none", + "configured": false, + "healthy": false, + }) + return + } + + stats, err := h.store.Stats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to collect storage stats", + "backend": h.store.Backend(), + "configured": true, + "healthy": false, + }) + return + } + + c.JSON(http.StatusOK, stats) +} diff --git a/server/main.go b/server/main.go index 219c891..219b9d1 100644 --- a/server/main.go +++ b/server/main.go @@ -1,7 +1,11 @@ package main import ( + "fmt" "log" + "os" + "strings" + "time" "github.com/gin-gonic/gin" @@ -9,16 +13,30 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/extraction" "git.gobha.me/xcaliber/chat-switchboard/handlers" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/roles" + "git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" _ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init() ) func main() { + // ── Subcommand dispatch ────────────────── + // Usage: switchboard vault rekey + if len(os.Args) > 2 && os.Args[1] == "vault" { + runVaultCommand(os.Args[2]) + return + } + if len(os.Args) > 1 && os.Args[1] == "version" { + fmt.Println("switchboard", Version) + return + } + + // ── Server startup ────────────────────── cfg := config.Load() // Register LLM providers @@ -27,6 +45,7 @@ func main() { var stores store.Stores uekCache := crypto.NewUEKCache() var keyResolver *crypto.KeyResolver + var objStore storage.ObjectStore if err := database.Connect(cfg); err != nil { log.Printf("⚠ Database unavailable: %v", err) @@ -73,6 +92,51 @@ func main() { } defer database.Close() + // ── File Storage ───────────────────────── + // Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND + // overrides auto-detection. nil objStore = storage features disabled. + var s3Cfg *storage.S3Config + if cfg.S3Bucket != "" { + s3Cfg = &storage.S3Config{ + Endpoint: cfg.S3Endpoint, + Bucket: cfg.S3Bucket, + Region: cfg.S3Region, + AccessKey: cfg.S3AccessKey, + SecretKey: cfg.S3SecretKey, + Prefix: cfg.S3Prefix, + ForcePathStyle: cfg.S3ForcePathStyle, + } + } + if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath, s3Cfg); err != nil { + if cfg.StorageBackend != "" { + // Explicit backend requested but failed — fatal + log.Fatalf("❌ Storage init failed: %v", err) + } + log.Printf("⚠ Storage init failed: %v", err) + } else { + objStore = s + } + handlers.SetStorageConfigured(objStore != nil) + + // ── Extraction Queue ──────────────────── + // Filesystem-based queue for document text extraction (PDF, DOCX, etc.) + // Nil if storage is disabled. + var extQueue *extraction.Queue + if objStore != nil && cfg.StoragePath != "" { + q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency) + if err != nil { + log.Printf("⚠ Extraction queue init failed: %v", err) + } else { + extQueue = q + // Recover items stuck in "processing" from previous crash + if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil { + log.Printf("⚠ Extraction recovery failed: %v", err) + } else if recovered > 0 { + log.Printf(" 📋 Recovered %d stale extraction items", recovered) + } + } + } + // Role resolver for model role dispatch (needs stores + vault) roleResolver := roles.NewResolver(stores, keyResolver) @@ -148,7 +212,7 @@ func main() { protected.DELETE("/channels/:id", channels.DeleteChannel) // Messages - msgs := handlers.NewMessageHandler(keyResolver, stores, hub) + msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) protected.GET("/channels/:id/messages", msgs.ListMessages) protected.POST("/channels/:id/messages", msgs.CreateMessage) @@ -160,7 +224,7 @@ func main() { protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) // Chat Completions - comp := handlers.NewCompletionHandler(keyResolver, stores, hub) + comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore) protected.POST("/chat/completions", comp.Complete) // Summarize & Continue @@ -222,6 +286,17 @@ func main() { protected.PUT("/notes/:id", notes.Update) protected.DELETE("/notes/:id", notes.Delete) + // Attachments (file upload/download) + attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) + protected.POST("/channels/:id/attachments", attachH.Upload) + protected.GET("/channels/:id/attachments", attachH.ListByChannel) + protected.GET("/attachments/:id", attachH.GetMetadata) + protected.GET("/attachments/:id/download", attachH.Download) + protected.DELETE("/attachments/:id", attachH.DeleteAttachment) + + // Hook: clean up storage files when channels are deleted + handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage) + // Teams (user: my teams) teams := handlers.NewTeamHandler(keyResolver) protected.GET("/teams/mine", teams.MyTeams) @@ -352,6 +427,19 @@ func main() { admin.PUT("/pricing", usageH.UpsertPricing) admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing) + // Storage status + storageH := handlers.NewStorageHandler(objStore) + admin.GET("/storage/status", storageH.Status) + + // Vault + admin.GET("/vault/status", adm.VaultStatus) + + // Storage management (orphan cleanup) + attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue) + admin.GET("/storage/orphans", attachAdm.OrphanCount) + admin.POST("/storage/cleanup", attachAdm.CleanupOrphans) + admin.GET("/storage/extraction", attachAdm.ExtractionStatus) + // Extensions (admin) extAdm := handlers.NewExtensionHandler(stores) admin.GET("/extensions", extAdm.AdminListExtensions) @@ -369,8 +457,64 @@ func main() { log.Printf(" Base path: %s", bp) log.Printf(" Schema: %s", database.SchemaVersion()) log.Printf(" Providers: %v", providers.List()) + if objStore != nil { + log.Printf(" Storage: %s", objStore.Backend()) + if extQueue != nil { + log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency) + } else { + log.Printf(" Extraction: disabled") + } + } else { + log.Printf(" Storage: disabled") + } log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("Failed to start server: %v", err) } } + +// ── Vault CLI Commands ────────────────────── + +func runVaultCommand(subcmd string) { + cfg := config.Load() + + switch strings.ToLower(subcmd) { + case "rekey": + if cfg.DatabaseURL == "" { + log.Fatal("DATABASE_URL is required for vault operations") + } + if err := database.Connect(cfg); err != nil { + log.Fatalf("❌ Database connection failed: %v", err) + } + defer database.Close() + + oldKey := os.Getenv("ENCRYPTION_KEY") + newKey := os.Getenv("NEW_ENCRYPTION_KEY") + + if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil { + log.Fatalf("❌ Vault rekey failed: %v", err) + } + + case "status": + if cfg.DatabaseURL == "" { + log.Fatal("DATABASE_URL is required for vault operations") + } + if err := database.Connect(cfg); err != nil { + log.Fatalf("❌ Database connection failed: %v", err) + } + defer database.Close() + + status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey) + if err != nil { + log.Fatalf("❌ Failed to get vault status: %v", err) + } + fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet) + fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys) + fmt.Printf("Vault users (active): %d\n", status.VaultUsers) + + default: + fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd) + fmt.Fprintf(os.Stderr, "Usage: switchboard vault \n") + os.Exit(1) + } +} diff --git a/server/models/models.go b/server/models/models.go index 3ee19f6..80a718b 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -389,6 +389,24 @@ type Note struct { TeamID *string `json:"team_id,omitempty" db:"team_id"` } +// ========================================= +// ATTACHMENTS +// ========================================= + +type Attachment struct { + ID string `json:"id" db:"id"` + ChannelID string `json:"channel_id" db:"channel_id"` + UserID string `json:"user_id" db:"user_id"` + MessageID *string `json:"message_id,omitempty" db:"message_id"` + Filename string `json:"filename" db:"filename"` + ContentType string `json:"content_type" db:"content_type"` + SizeBytes int64 `json:"size_bytes" db:"size_bytes"` + StorageKey string `json:"-" db:"storage_key"` // never expose filesystem path + ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"` + Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + // ========================================= // AUDIT LOG // ========================================= diff --git a/server/providers/anthropic.go b/server/providers/anthropic.go index ecc7d1a..d56e429 100644 --- a/server/providers/anthropic.go +++ b/server/providers/anthropic.go @@ -263,6 +263,40 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r Content: m.Content, }, } + } else if len(m.ContentParts) > 0 && m.Role == "user" { + // Multimodal user message: convert ContentParts to Anthropic blocks + for _, p := range m.ContentParts { + switch p.Type { + case "image_url": + if p.ImageURL != nil { + // Parse data URI: "data:image/jpeg;base64,{data}" + mediaType, b64Data := parseDataURI(p.ImageURL.URL) + if b64Data != "" { + antMsg.Content = append(antMsg.Content, anthropicContentBlock{ + Type: "image", + Source: &anthropicImageSource{ + Type: "base64", + MediaType: mediaType, + Data: b64Data, + }, + }) + } + } + default: // "text", "document" + if p.Text != "" { + antMsg.Content = append(antMsg.Content, anthropicContentBlock{ + Type: "text", + Text: p.Text, + }) + } + } + } + if len(antMsg.Content) == 0 { + // Fallback: shouldn't happen, but safety net + antMsg.Content = []anthropicContentBlock{ + {Type: "text", Text: m.Content}, + } + } } else { // Regular text message antMsg.Content = []anthropicContentBlock{ @@ -354,6 +388,8 @@ type anthropicContentBlock struct { Type string `json:"type"` // text Text string `json:"text,omitempty"` + // image (type="image") + Source *anthropicImageSource `json:"source,omitempty"` // tool_use ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -363,6 +399,15 @@ type anthropicContentBlock struct { Content string `json:"content,omitempty"` } +// anthropicImageSource is the Anthropic-native image format. +// Anthropic uses {"type":"base64","media_type":"image/jpeg","data":"..."} +// rather than OpenAI's data URI approach. +type anthropicImageSource struct { + Type string `json:"type"` // "base64" + MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc. + Data string `json:"data"` // raw base64 (no data: prefix) +} + type anthropicMessage struct { Role string `json:"role"` Content []anthropicContentBlock `json:"content"` @@ -425,3 +470,33 @@ type anthropicStreamEvent struct { Name string `json:"name"` } `json:"content_block,omitempty"` } + +// parseDataURI splits a data URI into media type and base64 data. +// Input: "data:image/jpeg;base64,/9j/4AAQ..." +// Output: "image/jpeg", "/9j/4AAQ..." +// Returns empty strings if the URI is malformed. +func parseDataURI(uri string) (mediaType, data string) { + // Strip "data:" prefix + if !strings.HasPrefix(uri, "data:") { + return "", "" + } + rest := uri[5:] + + // Split on comma (separates header from data) + commaIdx := strings.Index(rest, ",") + if commaIdx < 0 { + return "", "" + } + + header := rest[:commaIdx] + data = rest[commaIdx+1:] + + // Extract media type from header (before ";base64") + if semiIdx := strings.Index(header, ";"); semiIdx >= 0 { + mediaType = header[:semiIdx] + } else { + mediaType = header + } + + return mediaType, data +} diff --git a/server/providers/multimodal_test.go b/server/providers/multimodal_test.go new file mode 100644 index 0000000..60b69b3 --- /dev/null +++ b/server/providers/multimodal_test.go @@ -0,0 +1,202 @@ +package providers + +import ( + "encoding/json" + "testing" +) + +// ── ContentPart Tests ────────────────────── + +func TestContentPart_TextOnly(t *testing.T) { + msg := Message{ + Role: "user", + Content: "hello", + ContentParts: []ContentPart{ + {Type: "text", Text: "hello"}, + }, + } + if len(msg.ContentParts) != 1 { + t.Fatalf("expected 1 part, got %d", len(msg.ContentParts)) + } + if msg.ContentParts[0].Type != "text" { + t.Errorf("type = %q, want text", msg.ContentParts[0].Type) + } +} + +func TestContentPart_WithImage(t *testing.T) { + msg := Message{ + Role: "user", + Content: "describe this", + ContentParts: []ContentPart{ + {Type: "text", Text: "describe this"}, + {Type: "image_url", ImageURL: &ImageURL{URL: "data:image/png;base64,abc123", Detail: "auto"}}, + }, + } + if len(msg.ContentParts) != 2 { + t.Fatalf("expected 2 parts, got %d", len(msg.ContentParts)) + } + if msg.ContentParts[1].ImageURL == nil { + t.Fatal("expected non-nil ImageURL") + } + if msg.ContentParts[1].ImageURL.URL != "data:image/png;base64,abc123" { + t.Errorf("URL = %q", msg.ContentParts[1].ImageURL.URL) + } +} + +// ── OpenAI Multimodal Serialization ──────── + +func TestOpenAI_TextOnlyContent_String(t *testing.T) { + // When Content is a string, it should serialize as a JSON string + msg := openaiMessage{ + Role: "user", + Content: "hello world", + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var raw map[string]interface{} + json.Unmarshal(data, &raw) + + content := raw["content"] + if s, ok := content.(string); !ok || s != "hello world" { + t.Errorf("content = %v (%T), want string 'hello world'", content, content) + } +} + +func TestOpenAI_MultimodalContent_Array(t *testing.T) { + // When Content is an array, it should serialize as a JSON array + msg := openaiMessage{ + Role: "user", + Content: []openaiContentPart{ + {Type: "text", Text: "describe this"}, + {Type: "image_url", ImageURL: &openaiImageURL{URL: "data:image/png;base64,abc", Detail: "auto"}}, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var raw map[string]interface{} + json.Unmarshal(data, &raw) + + content := raw["content"] + arr, ok := content.([]interface{}) + if !ok { + t.Fatalf("content is %T, want array", content) + } + if len(arr) != 2 { + t.Fatalf("content has %d elements, want 2", len(arr)) + } + + // Verify first part is text + part0 := arr[0].(map[string]interface{}) + if part0["type"] != "text" { + t.Errorf("part[0].type = %v, want text", part0["type"]) + } + + // Verify second part is image_url + part1 := arr[1].(map[string]interface{}) + if part1["type"] != "image_url" { + t.Errorf("part[1].type = %v, want image_url", part1["type"]) + } +} + +// ── Anthropic Image Source ───────────────── + +func TestAnthropic_ImageSourceBlock(t *testing.T) { + block := anthropicContentBlock{ + Type: "image", + Source: &anthropicImageSource{ + Type: "base64", + MediaType: "image/jpeg", + Data: "abc123", + }, + } + data, err := json.Marshal(block) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var raw map[string]interface{} + json.Unmarshal(data, &raw) + + if raw["type"] != "image" { + t.Errorf("type = %v", raw["type"]) + } + source := raw["source"].(map[string]interface{}) + if source["type"] != "base64" { + t.Errorf("source.type = %v", source["type"]) + } + if source["media_type"] != "image/jpeg" { + t.Errorf("source.media_type = %v", source["media_type"]) + } + if source["data"] != "abc123" { + t.Errorf("source.data = %v", source["data"]) + } +} + +func TestAnthropic_TextBlockUnchanged(t *testing.T) { + block := anthropicContentBlock{ + Type: "text", + Text: "hello", + } + data, err := json.Marshal(block) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var raw map[string]interface{} + json.Unmarshal(data, &raw) + + if raw["type"] != "text" { + t.Errorf("type = %v", raw["type"]) + } + if raw["text"] != "hello" { + t.Errorf("text = %v", raw["text"]) + } + // Should NOT have source field + if _, ok := raw["source"]; ok { + t.Error("text block should not have source field") + } +} + +// ── parseDataURI ─────────────────────────── + +func TestParseDataURI_Valid(t *testing.T) { + tests := []struct { + uri string + wantType string + wantData string + }{ + {"data:image/jpeg;base64,/9j/4AAQ", "image/jpeg", "/9j/4AAQ"}, + {"data:image/png;base64,abc123", "image/png", "abc123"}, + {"data:image/webp;base64,RIFF", "image/webp", "RIFF"}, + } + for _, tc := range tests { + mediaType, data := parseDataURI(tc.uri) + if mediaType != tc.wantType { + t.Errorf("parseDataURI(%q) mediaType = %q, want %q", tc.uri, mediaType, tc.wantType) + } + if data != tc.wantData { + t.Errorf("parseDataURI(%q) data = %q, want %q", tc.uri, data, tc.wantData) + } + } +} + +func TestParseDataURI_Invalid(t *testing.T) { + tests := []string{ + "", + "not-a-data-uri", + "data:nocomma", + "https://example.com/image.png", + } + for _, uri := range tests { + mediaType, data := parseDataURI(uri) + if mediaType != "" || data != "" { + t.Errorf("parseDataURI(%q) = (%q, %q), want empty", uri, mediaType, data) + } + } +} diff --git a/server/providers/openai.go b/server/providers/openai.go index 3a153d5..9d19ed9 100644 --- a/server/providers/openai.go +++ b/server/providers/openai.go @@ -37,8 +37,14 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, return nil, fmt.Errorf("no choices in response") } + // Extract content string from response (responses are always string, not array) + var contentStr string + if s, ok := resp.Choices[0].Message.Content.(string); ok { + contentStr = s + } + result := &CompletionResponse{ - Content: resp.Choices[0].Message.Content, + Content: contentStr, Model: resp.Model, FinishReason: resp.Choices[0].FinishReason, InputTokens: resp.Usage.PromptTokens, @@ -360,11 +366,42 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req }) } - // Convert messages — handle all roles including tool + // Convert messages — handle all roles including tool and multimodal for _, m := range req.Messages { oaiMsg := openaiMessage{ - Role: m.Role, - Content: m.Content, + Role: m.Role, + } + + // Build content: multimodal parts or plain string + if len(m.ContentParts) > 0 && m.Role == "user" { + // Multimodal: convert to OpenAI content array format + parts := make([]openaiContentPart, 0, len(m.ContentParts)) + for _, p := range m.ContentParts { + switch p.Type { + case "image_url": + if p.ImageURL != nil { + detail := p.ImageURL.Detail + if detail == "" { + detail = "auto" + } + parts = append(parts, openaiContentPart{ + Type: "image_url", + ImageURL: &openaiImageURL{ + URL: p.ImageURL.URL, + Detail: detail, + }, + }) + } + default: // "text", "document" → text + parts = append(parts, openaiContentPart{ + Type: "text", + Text: p.Text, + }) + } + } + oaiMsg.Content = parts + } else { + oaiMsg.Content = m.Content } // Assistant messages with tool calls @@ -425,11 +462,24 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req // ── OpenAI Wire Types ─────────────────────── type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools - ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result - Name string `json:"name,omitempty"` // tool name for role=tool + Role string `json:"role"` + Content interface{} `json:"content,omitempty"` // string or []openaiContentPart + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools + ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result + Name string `json:"name,omitempty"` // tool name for role=tool +} + +// openaiContentPart represents a single element in a multimodal content array. +// OpenAI format: [{"type":"text","text":"..."}, {"type":"image_url","image_url":{"url":"..."}}] +type openaiContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *openaiImageURL `json:"image_url,omitempty"` +} + +type openaiImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` } type openaiToolCallFunction struct { diff --git a/server/providers/provider.go b/server/providers/provider.go index 18d73b1..a8c5a3a 100644 --- a/server/providers/provider.go +++ b/server/providers/provider.go @@ -52,12 +52,36 @@ type Message struct { Role string `json:"role"` // user, assistant, system, tool Content string `json:"content"` + // Multimodal content parts (v0.12.0+). When set, providers use these + // instead of Content for the user message. Content is still set as + // a fallback and for message persistence (extracted text summary). + ContentParts []ContentPart `json:"content_parts,omitempty"` + // Tool calling fields (assistant → tool_calls, tool → tool_call_id) ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages Name string `json:"name,omitempty"` // tool name for role="tool" } +// ContentPart is a single element in a multimodal message. +// Providers convert these to their native wire format. +type ContentPart struct { + Type string `json:"type"` // "text", "image_url", "document" + + // Text content (type="text") + Text string `json:"text,omitempty"` + + // Image content (type="image_url") — base64 data URI + ImageURL *ImageURL `json:"image_url,omitempty"` +} + +// ImageURL holds image data for multimodal requests. +// URL is a data URI: "data:image/jpeg;base64,..." +type ImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` // "auto", "low", "high" +} + // ToolCall represents a function call requested by the LLM. type ToolCall struct { ID string `json:"id"` diff --git a/server/storage/init.go b/server/storage/init.go new file mode 100644 index 0000000..e905c3f --- /dev/null +++ b/server/storage/init.go @@ -0,0 +1,78 @@ +package storage + +import ( + "fmt" + "log" + "os" +) + +// Init creates an ObjectStore based on the configuration. +// +// Auto-detection when backend is empty: +// - If storagePath is writable → PVC (implicit) +// - If storagePath is missing/unwritable → nil (storage disabled) +// +// Explicit backends: +// - "pvc" → PVC, fail if path is not writable +// - "s3" → S3-compatible (MinIO, Ceph RGW, AWS), fail if not reachable +func Init(backend, storagePath string, s3Cfg *S3Config) (ObjectStore, error) { + switch backend { + case "pvc": + // Explicit PVC — fail if not writable + s, err := NewPVC(storagePath) + if err != nil { + return nil, fmt.Errorf("storage: PVC backend at %q: %w", storagePath, err) + } + log.Printf(" 📁 Storage: PVC at %s", storagePath) + return s, nil + + case "s3": + if s3Cfg == nil { + return nil, fmt.Errorf("storage: S3 backend requires S3_BUCKET and credentials") + } + s, err := NewS3(*s3Cfg) + if err != nil { + return nil, fmt.Errorf("storage: S3 backend: %w", err) + } + endpoint := s3Cfg.Endpoint + if endpoint == "" { + endpoint = "AWS" + } + log.Printf(" 📁 Storage: S3 at %s/%s (prefix=%q)", endpoint, s3Cfg.Bucket, s3Cfg.Prefix) + return s, nil + + case "": + // Auto-detect: try PVC if path exists or is creatable + s, err := NewPVC(storagePath) + if err != nil { + // Path not writable — storage disabled, not an error + log.Printf(" 📁 Storage: disabled (path %s not writable)", storagePath) + return nil, nil + } + log.Printf(" 📁 Storage: PVC at %s (auto-detected)", storagePath) + return s, nil + + default: + return nil, fmt.Errorf("storage: unknown backend %q (expected pvc or s3)", backend) + } +} + +// IsPathWritable checks if a directory path exists and is writable. +// Used by auto-detection. Does not create the directory. +func IsPathWritable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + if !info.IsDir() { + return false + } + + // Try writing a probe file + probe := path + "/.writable-probe" + if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil { + return false + } + os.Remove(probe) + return true +} diff --git a/server/storage/pvc.go b/server/storage/pvc.go new file mode 100644 index 0000000..cba59ed --- /dev/null +++ b/server/storage/pvc.go @@ -0,0 +1,278 @@ +package storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +// ── PVC Store ────────────────────────────── + +// PVCStore implements ObjectStore using the local filesystem. +// Used with Kubernetes PersistentVolumeClaims (CephFS, NFS, local PV) +// or plain Docker volume mounts. +type PVCStore struct { + basePath string + + // Content-type cache: key → content type. + // Populated on Put, used by Get. PVC doesn't store metadata + // alongside files, so we keep MIME types in memory. On restart, + // Get falls back to "application/octet-stream" — the DB has the + // authoritative content_type for serving to clients. + mu sync.RWMutex + mimeMap map[string]string +} + +// NewPVC creates a PVCStore rooted at basePath. +// Returns an error if the path does not exist or is not writable. +func NewPVC(basePath string) (*PVCStore, error) { + // Ensure base path exists + if err := os.MkdirAll(basePath, 0o755); err != nil { + return nil, fmt.Errorf("storage/pvc: cannot create base path %q: %w", basePath, err) + } + + // Create well-known subdirectories + for _, sub := range []string{"attachments", "processing"} { + if err := os.MkdirAll(filepath.Join(basePath, sub), 0o755); err != nil { + return nil, fmt.Errorf("storage/pvc: cannot create %s dir: %w", sub, err) + } + } + + s := &PVCStore{ + basePath: basePath, + mimeMap: make(map[string]string), + } + + // Validate writability + if err := s.Healthy(context.Background()); err != nil { + return nil, err + } + + return s, nil +} + +func (s *PVCStore) Backend() string { return "pvc" } + +// Put writes data to the filesystem at the resolved key path. +func (s *PVCStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error { + if err := validateKey(key); err != nil { + return err + } + + absPath := s.resolve(key) + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("storage/pvc: mkdir for %q: %w", key, err) + } + + // Write to temp file first, then rename (atomic on same filesystem) + tmp, err := os.CreateTemp(filepath.Dir(absPath), ".upload-*") + if err != nil { + return fmt.Errorf("storage/pvc: create temp for %q: %w", key, err) + } + tmpName := tmp.Name() + defer func() { + tmp.Close() + // Clean up temp file on failure + if err != nil { + os.Remove(tmpName) + } + }() + + written, err := io.Copy(tmp, r) + if err != nil { + return fmt.Errorf("storage/pvc: write %q: %w", key, err) + } + if err = tmp.Close(); err != nil { + return fmt.Errorf("storage/pvc: close %q: %w", key, err) + } + + // Verify size if provided (0 means unknown) + if size > 0 && written != size { + os.Remove(tmpName) + return fmt.Errorf("storage/pvc: size mismatch for %q: expected %d, got %d", key, size, written) + } + + // Atomic rename + if err = os.Rename(tmpName, absPath); err != nil { + return fmt.Errorf("storage/pvc: rename %q: %w", key, err) + } + + // Cache content type + if contentType != "" { + s.mu.Lock() + s.mimeMap[key] = contentType + s.mu.Unlock() + } + + return nil +} + +// Get returns a reader for the file at key. +func (s *PVCStore) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) { + if err := validateKey(key); err != nil { + return nil, 0, "", err + } + + absPath := s.resolve(key) + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return nil, 0, "", ErrNotFound + } + return nil, 0, "", fmt.Errorf("storage/pvc: stat %q: %w", key, err) + } + + f, err := os.Open(absPath) + if err != nil { + return nil, 0, "", fmt.Errorf("storage/pvc: open %q: %w", key, err) + } + + // Look up cached content type + s.mu.RLock() + ct := s.mimeMap[key] + s.mu.RUnlock() + if ct == "" { + ct = "application/octet-stream" + } + + return f, info.Size(), ct, nil +} + +// Delete removes the file at key. +func (s *PVCStore) Delete(ctx context.Context, key string) error { + if err := validateKey(key); err != nil { + return err + } + + absPath := s.resolve(key) + + err := os.Remove(absPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("storage/pvc: delete %q: %w", key, err) + } + + // Clean up cache + s.mu.Lock() + delete(s.mimeMap, key) + s.mu.Unlock() + + return nil +} + +// DeletePrefix removes all files and directories under the given prefix. +func (s *PVCStore) DeletePrefix(ctx context.Context, prefix string) error { + if err := validateKey(prefix); err != nil { + return err + } + + absPath := s.resolve(prefix) + + // Check if the path exists + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return nil // Nothing to delete + } + + if err := os.RemoveAll(absPath); err != nil { + return fmt.Errorf("storage/pvc: delete prefix %q: %w", prefix, err) + } + + // Clean up cache entries with this prefix + s.mu.Lock() + for k := range s.mimeMap { + if strings.HasPrefix(k, prefix) { + delete(s.mimeMap, k) + } + } + s.mu.Unlock() + + return nil +} + +// Exists checks if a file exists at key. +func (s *PVCStore) Exists(ctx context.Context, key string) (bool, error) { + if err := validateKey(key); err != nil { + return false, err + } + + _, err := os.Stat(s.resolve(key)) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("storage/pvc: exists %q: %w", key, err) +} + +// Healthy verifies the storage path is writable. +func (s *PVCStore) Healthy(ctx context.Context) error { + probe := filepath.Join(s.basePath, ".health-probe") + if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil { + return fmt.Errorf("storage/pvc: not writable: %w", err) + } + os.Remove(probe) + return nil +} + +// Stats returns file count and total size under the base path. +func (s *PVCStore) Stats(ctx context.Context) (*StorageStats, error) { + stats := &StorageStats{ + Backend: "pvc", + Path: s.basePath, + Configured: true, + } + + // Check health + if err := s.Healthy(ctx); err != nil { + stats.Healthy = false + return stats, nil + } + stats.Healthy = true + + // Walk attachments directory for counts + attDir := filepath.Join(s.basePath, "attachments") + err := filepath.Walk(attDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // skip errors (e.g. permission denied) + } + if !info.IsDir() { + stats.TotalFiles++ + stats.TotalBytes += info.Size() + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return stats, fmt.Errorf("storage/pvc: stats walk: %w", err) + } + + return stats, nil +} + +// ── Helpers ──────────────────────────────── + +// resolve converts a storage key to an absolute filesystem path. +func (s *PVCStore) resolve(key string) string { + return filepath.Join(s.basePath, filepath.FromSlash(key)) +} + +// validateKey ensures the key is safe (no path traversal, no absolute paths). +func validateKey(key string) error { + if key == "" { + return fmt.Errorf("storage: empty key") + } + if strings.HasPrefix(key, "/") || strings.HasPrefix(key, "\\") { + return fmt.Errorf("storage: absolute key not allowed: %q", key) + } + cleaned := filepath.Clean(key) + if strings.Contains(cleaned, "..") { + return fmt.Errorf("storage: path traversal not allowed: %q", key) + } + return nil +} diff --git a/server/storage/pvc_test.go b/server/storage/pvc_test.go new file mode 100644 index 0000000..ddbc4b7 --- /dev/null +++ b/server/storage/pvc_test.go @@ -0,0 +1,295 @@ +package storage + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" +) + +func tempStore(t *testing.T) *PVCStore { + t.Helper() + dir := t.TempDir() + s, err := NewPVC(dir) + if err != nil { + t.Fatalf("NewPVC(%q): %v", dir, err) + } + return s +} + +func TestPVC_PutGetRoundTrip(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + data := []byte("hello, storage world") + key := "attachments/channel-1/att-1_test.txt" + + // Put + err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain") + if err != nil { + t.Fatalf("Put: %v", err) + } + + // Get + rc, size, ct, err := s.Get(ctx, key) + if err != nil { + t.Fatalf("Get: %v", err) + } + defer rc.Close() + + if size != int64(len(data)) { + t.Errorf("size = %d, want %d", size, len(data)) + } + if ct != "text/plain" { + t.Errorf("content_type = %q, want %q", ct, "text/plain") + } + + got, _ := io.ReadAll(rc) + if !bytes.Equal(got, data) { + t.Errorf("data = %q, want %q", got, data) + } +} + +func TestPVC_PutAtomicWrite(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + key := "attachments/ch/file.bin" + data := []byte("atomic test data") + + err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "application/octet-stream") + if err != nil { + t.Fatalf("Put: %v", err) + } + + // File should exist at final path, no temp files left + absPath := filepath.Join(s.basePath, "attachments", "ch") + entries, _ := os.ReadDir(absPath) + for _, e := range entries { + if e.Name() != "file.bin" { + t.Errorf("unexpected file in dir: %s", e.Name()) + } + } +} + +func TestPVC_PutSizeMismatch(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + data := []byte("short") + err := s.Put(ctx, "attachments/ch/f.txt", bytes.NewReader(data), 999, "text/plain") + if err == nil { + t.Fatal("expected size mismatch error") + } +} + +func TestPVC_GetNotFound(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + _, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt") + if err != ErrNotFound { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestPVC_Delete(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + key := "attachments/ch/delete-me.txt" + _ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain") + + err := s.Delete(ctx, key) + if err != nil { + t.Fatalf("Delete: %v", err) + } + + exists, _ := s.Exists(ctx, key) + if exists { + t.Error("file still exists after Delete") + } +} + +func TestPVC_DeleteIdempotent(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + // Delete a file that doesn't exist — should not error + err := s.Delete(ctx, "attachments/no-such/file.txt") + if err != nil { + t.Errorf("Delete nonexistent: %v", err) + } +} + +func TestPVC_DeletePrefix(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + // Create several files under a channel prefix + prefix := "attachments/channel-abc/" + for _, name := range []string{"a.txt", "b.png", "c.pdf"} { + key := prefix + name + _ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain") + } + + // Also create a file in a different channel + other := "attachments/channel-other/keep.txt" + _ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain") + + // Delete the channel prefix + err := s.DeletePrefix(ctx, "attachments/channel-abc") + if err != nil { + t.Fatalf("DeletePrefix: %v", err) + } + + // Verify channel files are gone + for _, name := range []string{"a.txt", "b.png", "c.pdf"} { + exists, _ := s.Exists(ctx, prefix+name) + if exists { + t.Errorf("%s still exists after DeletePrefix", name) + } + } + + // Verify other channel is untouched + exists, _ := s.Exists(ctx, other) + if !exists { + t.Error("other channel file was incorrectly deleted") + } +} + +func TestPVC_DeletePrefixNonexistent(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + // Should not error + err := s.DeletePrefix(ctx, "attachments/does-not-exist/") + if err != nil { + t.Errorf("DeletePrefix nonexistent: %v", err) + } +} + +func TestPVC_Exists(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + key := "attachments/ch/exists.txt" + _ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain") + + exists, err := s.Exists(ctx, key) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if !exists { + t.Error("Exists returned false for existing file") + } + + exists, err = s.Exists(ctx, "attachments/ch/nope.txt") + if err != nil { + t.Fatalf("Exists (missing): %v", err) + } + if exists { + t.Error("Exists returned true for missing file") + } +} + +func TestPVC_Healthy(t *testing.T) { + s := tempStore(t) + if err := s.Healthy(context.Background()); err != nil { + t.Errorf("Healthy: %v", err) + } +} + +func TestPVC_Stats(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + // Empty store + stats, err := s.Stats(ctx) + if err != nil { + t.Fatalf("Stats: %v", err) + } + if stats.TotalFiles != 0 || stats.TotalBytes != 0 { + t.Errorf("empty store: files=%d bytes=%d", stats.TotalFiles, stats.TotalBytes) + } + if !stats.Healthy || !stats.Configured { + t.Error("expected healthy + configured") + } + + // Add some files + for i := 0; i < 3; i++ { + key := filepath.Join("attachments", "ch", string(rune('a'+i))+".txt") + data := bytes.Repeat([]byte("x"), 100*(i+1)) + _ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain") + } + + stats, err = s.Stats(ctx) + if err != nil { + t.Fatalf("Stats: %v", err) + } + if stats.TotalFiles != 3 { + t.Errorf("files = %d, want 3", stats.TotalFiles) + } + if stats.TotalBytes != 600 { // 100 + 200 + 300 + t.Errorf("bytes = %d, want 600", stats.TotalBytes) + } +} + +func TestPVC_PathTraversal(t *testing.T) { + s := tempStore(t) + ctx := context.Background() + + bad := []string{ + "../etc/passwd", + "attachments/../../etc/shadow", + "/absolute/path", + "", + } + + for _, key := range bad { + err := s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain") + if err == nil { + t.Errorf("Put(%q) should have failed", key) + } + + _, _, _, err = s.Get(ctx, key) + if err == nil { + t.Errorf("Get(%q) should have failed", key) + } + + err = s.Delete(ctx, key) + if err == nil { + t.Errorf("Delete(%q) should have failed", key) + } + } +} + +func TestPVC_SubdirCreation(t *testing.T) { + s := tempStore(t) + + // Verify well-known subdirs were created + for _, sub := range []string{"attachments", "processing"} { + info, err := os.Stat(filepath.Join(s.basePath, sub)) + if err != nil { + t.Errorf("subdir %q not created: %v", sub, err) + continue + } + if !info.IsDir() { + t.Errorf("%q is not a directory", sub) + } + } +} + +func TestNewPVC_InvalidPath(t *testing.T) { + // Path that can't be created (nested under a file) + tmp := t.TempDir() + filePath := filepath.Join(tmp, "blocker") + os.WriteFile(filePath, []byte("x"), 0o644) + + _, err := NewPVC(filepath.Join(filePath, "subdir")) + if err == nil { + t.Error("expected error for invalid path") + } +} diff --git a/server/storage/s3.go b/server/storage/s3.go new file mode 100644 index 0000000..c4e8096 --- /dev/null +++ b/server/storage/s3.go @@ -0,0 +1,312 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "strings" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// ── S3 Store ────────────────────────────── + +// S3Config holds all configuration for the S3 backend. +type S3Config struct { + Endpoint string // e.g. "minio.example.com:9000" or "s3.amazonaws.com" + Bucket string // bucket name + Region string // AWS region (default "us-east-1") + AccessKey string // access key ID + SecretKey string // secret access key + Prefix string // optional key prefix (e.g. "switchboard/") + ForcePathStyle bool // path-style URLs (required for MinIO, most self-hosted) + UseSSL bool // use HTTPS (derived from endpoint scheme) +} + +// S3Store implements ObjectStore using any S3-compatible API. +// Tested with: MinIO, Ceph RGW, AWS S3. +type S3Store struct { + client *minio.Client + bucket string + prefix string // prepended to all keys (e.g. "switchboard/") + endpoint string // original endpoint for display in Stats +} + +// NewS3 creates an S3Store connected to the given endpoint. +// Returns an error if the bucket does not exist or is not accessible. +func NewS3(cfg S3Config) (*S3Store, error) { + if cfg.Bucket == "" { + return nil, fmt.Errorf("storage/s3: bucket name is required") + } + if cfg.AccessKey == "" || cfg.SecretKey == "" { + return nil, fmt.Errorf("storage/s3: access key and secret key are required") + } + if cfg.Region == "" { + cfg.Region = "us-east-1" + } + + // Parse endpoint: strip scheme if present, detect SSL + endpoint := cfg.Endpoint + useSSL := cfg.UseSSL + if strings.HasPrefix(endpoint, "https://") { + endpoint = strings.TrimPrefix(endpoint, "https://") + useSSL = true + } else if strings.HasPrefix(endpoint, "http://") { + endpoint = strings.TrimPrefix(endpoint, "http://") + useSSL = false + } + // Default to SSL for AWS endpoints + if endpoint == "" || strings.Contains(endpoint, "amazonaws.com") { + useSSL = true + } + + opts := &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: useSSL, + Region: cfg.Region, + } + + // BucketLookup controls path-style vs virtual-hosted-style URLs. + // MinIO, Ceph, and most self-hosted S3 require path-style. + if cfg.ForcePathStyle { + opts.BucketLookup = minio.BucketLookupPath + } else { + opts.BucketLookup = minio.BucketLookupAuto + } + + client, err := minio.New(endpoint, opts) + if err != nil { + return nil, fmt.Errorf("storage/s3: client init: %w", err) + } + + // Normalize prefix: ensure trailing slash if non-empty, no leading slash + prefix := strings.TrimPrefix(cfg.Prefix, "/") + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + s := &S3Store{ + client: client, + bucket: cfg.Bucket, + prefix: prefix, + endpoint: endpoint, + } + + // Validate: check bucket exists and is accessible + ctx := context.Background() + exists, err := client.BucketExists(ctx, cfg.Bucket) + if err != nil { + return nil, fmt.Errorf("storage/s3: cannot reach endpoint: %w", err) + } + if !exists { + return nil, fmt.Errorf("storage/s3: bucket %q does not exist", cfg.Bucket) + } + + return s, nil +} + +func (s *S3Store) Backend() string { return "s3" } + +// Put writes data to S3 at the given key. +func (s *S3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error { + if err := validateKey(key); err != nil { + return err + } + + if contentType == "" { + contentType = "application/octet-stream" + } + + opts := minio.PutObjectOptions{ + ContentType: contentType, + } + + // If size is unknown (0), we need to buffer or use -1. + // minio-go handles -1 with multipart upload. + uploadSize := size + if uploadSize == 0 { + uploadSize = -1 + } + + _, err := s.client.PutObject(ctx, s.bucket, s.fullKey(key), r, uploadSize, opts) + if err != nil { + return fmt.Errorf("storage/s3: put %q: %w", key, err) + } + + return nil +} + +// Get returns a reader for the object at key. +func (s *S3Store) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) { + if err := validateKey(key); err != nil { + return nil, 0, "", err + } + + obj, err := s.client.GetObject(ctx, s.bucket, s.fullKey(key), minio.GetObjectOptions{}) + if err != nil { + return nil, 0, "", fmt.Errorf("storage/s3: get %q: %w", key, err) + } + + // Stat the object to get size and content type. + // GetObject is lazy — the actual HTTP request happens on Read or Stat. + info, err := obj.Stat() + if err != nil { + obj.Close() + if isS3NotFound(err) { + return nil, 0, "", ErrNotFound + } + return nil, 0, "", fmt.Errorf("storage/s3: stat %q: %w", key, err) + } + + ct := info.ContentType + if ct == "" { + ct = "application/octet-stream" + } + + return obj, info.Size, ct, nil +} + +// Delete removes the object at key. +func (s *S3Store) Delete(ctx context.Context, key string) error { + if err := validateKey(key); err != nil { + return err + } + + err := s.client.RemoveObject(ctx, s.bucket, s.fullKey(key), minio.RemoveObjectOptions{}) + if err != nil { + // S3 RemoveObject is already idempotent (no error for missing keys) + return fmt.Errorf("storage/s3: delete %q: %w", key, err) + } + + return nil +} + +// DeletePrefix removes all objects under the given prefix. +func (s *S3Store) DeletePrefix(ctx context.Context, prefix string) error { + if err := validateKey(prefix); err != nil { + return err + } + + fullPrefix := s.fullKey(prefix) + // Ensure trailing slash for directory-like prefix + if !strings.HasSuffix(fullPrefix, "/") { + fullPrefix += "/" + } + + // List all objects under prefix + objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{ + Prefix: fullPrefix, + Recursive: true, + }) + + // Build removal channel + errCh := s.client.RemoveObjects(ctx, s.bucket, toRemoveChannel(objectsCh), minio.RemoveObjectsOptions{}) + + // Drain errors + var firstErr error + for e := range errCh { + if e.Err != nil && firstErr == nil { + firstErr = fmt.Errorf("storage/s3: delete prefix %q: %w", prefix, e.Err) + } + } + + return firstErr +} + +// Exists checks if an object exists at key. +func (s *S3Store) Exists(ctx context.Context, key string) (bool, error) { + if err := validateKey(key); err != nil { + return false, err + } + + _, err := s.client.StatObject(ctx, s.bucket, s.fullKey(key), minio.StatObjectOptions{}) + if err != nil { + if isS3NotFound(err) { + return false, nil + } + return false, fmt.Errorf("storage/s3: exists %q: %w", key, err) + } + return true, nil +} + +// Healthy checks connectivity by performing a HeadBucket. +func (s *S3Store) Healthy(ctx context.Context) error { + exists, err := s.client.BucketExists(ctx, s.bucket) + if err != nil { + return fmt.Errorf("storage/s3: health check failed: %w", err) + } + if !exists { + return fmt.Errorf("storage/s3: bucket %q no longer exists", s.bucket) + } + // Write + read a probe object to verify full access + probeKey := s.fullKey(".health-probe") + _, err = s.client.PutObject(ctx, s.bucket, probeKey, + bytes.NewReader([]byte("ok")), 2, + minio.PutObjectOptions{ContentType: "text/plain"}) + if err != nil { + return fmt.Errorf("storage/s3: not writable: %w", err) + } + s.client.RemoveObject(ctx, s.bucket, probeKey, minio.RemoveObjectOptions{}) + return nil +} + +// Stats returns object count and total size in the bucket (under prefix). +func (s *S3Store) Stats(ctx context.Context) (*StorageStats, error) { + stats := &StorageStats{ + Backend: "s3", + Bucket: s.bucket, + Endpoint: s.endpoint, + Configured: true, + } + + // Check health first + if err := s.Healthy(ctx); err != nil { + stats.Healthy = false + return stats, nil + } + stats.Healthy = true + + // Count objects under the attachments prefix + attPrefix := s.fullKey("attachments/") + objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{ + Prefix: attPrefix, + Recursive: true, + }) + + for obj := range objectsCh { + if obj.Err != nil { + log.Printf("storage/s3: stats list error: %v", obj.Err) + break + } + stats.TotalFiles++ + stats.TotalBytes += obj.Size + } + + return stats, nil +} + +// ── Helpers ──────────────────────────────── + +// fullKey prepends the configured prefix to a storage key. +func (s *S3Store) fullKey(key string) string { + return s.prefix + key +} + +// isS3NotFound checks if an error represents a "not found" condition. +func isS3NotFound(err error) bool { + if err == nil { + return false + } + resp := minio.ToErrorResponse(err) + return resp.Code == "NoSuchKey" || resp.StatusCode == 404 +} + +// toRemoveChannel converts a ListObjects channel to a RemoveObjects input channel. +func toRemoveChannel(objects <-chan minio.ObjectInfo) <-chan minio.ObjectInfo { + // RemoveObjects accepts the same channel type — pass through directly. + // The channel is already producing ObjectInfo values with Key set. + return objects +} diff --git a/server/storage/s3_test.go b/server/storage/s3_test.go new file mode 100644 index 0000000..307455c --- /dev/null +++ b/server/storage/s3_test.go @@ -0,0 +1,326 @@ +package storage + +import ( + "bytes" + "context" + "io" + "os" + "testing" +) + +// ── Unit Tests (always run) ────────────── + +func TestS3_NewS3_Validation(t *testing.T) { + // Missing bucket + _, err := NewS3(S3Config{ + AccessKey: "test", + SecretKey: "test", + }) + if err == nil { + t.Error("expected error for missing bucket") + } + + // Missing credentials + _, err = NewS3(S3Config{ + Bucket: "test-bucket", + }) + if err == nil { + t.Error("expected error for missing credentials") + } + + // Missing access key only + _, err = NewS3(S3Config{ + Bucket: "test-bucket", + SecretKey: "test", + }) + if err == nil { + t.Error("expected error for missing access key") + } +} + +func TestS3_FullKey(t *testing.T) { + tests := []struct { + prefix string + key string + want string + }{ + {"", "attachments/ch/f.txt", "attachments/ch/f.txt"}, + {"switchboard/", "attachments/ch/f.txt", "switchboard/attachments/ch/f.txt"}, + {"prefix", "attachments/ch/f.txt", "prefix/attachments/ch/f.txt"}, // trailing slash added by NewS3 + } + + for _, tt := range tests { + s := &S3Store{prefix: tt.prefix} + // Normalize prefix same as NewS3 does + if s.prefix != "" && s.prefix[len(s.prefix)-1] != '/' { + s.prefix += "/" + } + got := s.fullKey(tt.key) + if got != tt.want { + t.Errorf("fullKey(%q, %q) = %q, want %q", tt.prefix, tt.key, got, tt.want) + } + } +} + +func TestS3_KeyValidation(t *testing.T) { + // validateKey is shared between PVC and S3 — test with S3 context + bad := []string{ + "", + "../etc/passwd", + "/absolute/path", + "attachments/../../etc/shadow", + } + for _, key := range bad { + if err := validateKey(key); err == nil { + t.Errorf("validateKey(%q) should have failed", key) + } + } + + good := []string{ + "attachments/ch/f.txt", + "attachments/channel-1/att-abc_test.pdf", + "processing/abc123/status.json", + } + for _, key := range good { + if err := validateKey(key); err != nil { + t.Errorf("validateKey(%q) failed: %v", key, err) + } + } +} + +// ── Integration Tests (require live S3 endpoint) ────────────── +// +// Set these environment variables to run: +// +// S3_TEST_ENDPOINT=localhost:9000 +// S3_TEST_BUCKET=switchboard-test +// S3_TEST_ACCESS_KEY=minioadmin +// S3_TEST_SECRET_KEY=minioadmin +// +// Example with MinIO: +// +// docker run -d -p 9000:9000 -p 9001:9001 \ +// -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \ +// minio/minio server /data --console-address :9001 +// mc alias set local http://localhost:9000 minioadmin minioadmin +// mc mb local/switchboard-test + +func testS3Store(t *testing.T) *S3Store { + t.Helper() + + endpoint := os.Getenv("S3_TEST_ENDPOINT") + bucket := os.Getenv("S3_TEST_BUCKET") + accessKey := os.Getenv("S3_TEST_ACCESS_KEY") + secretKey := os.Getenv("S3_TEST_SECRET_KEY") + + if endpoint == "" || bucket == "" { + t.Skip("S3_TEST_ENDPOINT and S3_TEST_BUCKET not set — skipping S3 integration tests") + } + + s, err := NewS3(S3Config{ + Endpoint: endpoint, + Bucket: bucket, + Region: "us-east-1", + AccessKey: accessKey, + SecretKey: secretKey, + Prefix: "test-" + t.Name() + "/", + ForcePathStyle: true, + }) + if err != nil { + t.Fatalf("NewS3: %v", err) + } + + // Clean up prefix after test + t.Cleanup(func() { + ctx := context.Background() + _ = s.DeletePrefix(ctx, "attachments") + _ = s.DeletePrefix(ctx, "processing") + }) + + return s +} + +func TestS3_Integration_PutGetRoundTrip(t *testing.T) { + s := testS3Store(t) + ctx := context.Background() + + data := []byte("hello, S3 storage world") + key := "attachments/channel-1/att-1_test.txt" + + err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain") + if err != nil { + t.Fatalf("Put: %v", err) + } + + rc, size, ct, err := s.Get(ctx, key) + if err != nil { + t.Fatalf("Get: %v", err) + } + defer rc.Close() + + if size != int64(len(data)) { + t.Errorf("size = %d, want %d", size, len(data)) + } + if ct != "text/plain" { + t.Errorf("content_type = %q, want %q", ct, "text/plain") + } + + got, _ := io.ReadAll(rc) + if !bytes.Equal(got, data) { + t.Errorf("data = %q, want %q", got, data) + } +} + +func TestS3_Integration_GetNotFound(t *testing.T) { + s := testS3Store(t) + ctx := context.Background() + + _, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt") + if err != ErrNotFound { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestS3_Integration_Delete(t *testing.T) { + s := testS3Store(t) + ctx := context.Background() + + key := "attachments/ch/delete-me.txt" + _ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain") + + err := s.Delete(ctx, key) + if err != nil { + t.Fatalf("Delete: %v", err) + } + + exists, _ := s.Exists(ctx, key) + if exists { + t.Error("file still exists after Delete") + } +} + +func TestS3_Integration_DeleteIdempotent(t *testing.T) { + s := testS3Store(t) + ctx := context.Background() + + // S3 delete is inherently idempotent + err := s.Delete(ctx, "attachments/no-such/file.txt") + if err != nil { + t.Errorf("Delete nonexistent: %v", err) + } +} + +func TestS3_Integration_DeletePrefix(t *testing.T) { + s := testS3Store(t) + ctx := context.Background() + + prefix := "attachments/channel-abc/" + for _, name := range []string{"a.txt", "b.png", "c.pdf"} { + key := prefix + name + _ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain") + } + + other := "attachments/channel-other/keep.txt" + _ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain") + + err := s.DeletePrefix(ctx, "attachments/channel-abc") + if err != nil { + t.Fatalf("DeletePrefix: %v", err) + } + + for _, name := range []string{"a.txt", "b.png", "c.pdf"} { + exists, _ := s.Exists(ctx, prefix+name) + if exists { + t.Errorf("%s still exists after DeletePrefix", name) + } + } + + exists, _ := s.Exists(ctx, other) + if !exists { + t.Error("other channel file was incorrectly deleted") + } +} + +func TestS3_Integration_Exists(t *testing.T) { + s := testS3Store(t) + ctx := context.Background() + + key := "attachments/ch/exists.txt" + _ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain") + + exists, err := s.Exists(ctx, key) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if !exists { + t.Error("Exists returned false for existing file") + } + + exists, err = s.Exists(ctx, "attachments/ch/nope.txt") + if err != nil { + t.Fatalf("Exists (missing): %v", err) + } + if exists { + t.Error("Exists returned true for missing file") + } +} + +func TestS3_Integration_Healthy(t *testing.T) { + s := testS3Store(t) + if err := s.Healthy(context.Background()); err != nil { + t.Errorf("Healthy: %v", err) + } +} + +func TestS3_Integration_Stats(t *testing.T) { + s := testS3Store(t) + ctx := context.Background() + + // Add some files + for i := 0; i < 3; i++ { + key := "attachments/ch/" + string(rune('a'+i)) + ".txt" + data := bytes.Repeat([]byte("x"), 100*(i+1)) + _ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain") + } + + stats, err := s.Stats(ctx) + if err != nil { + t.Fatalf("Stats: %v", err) + } + if stats.TotalFiles != 3 { + t.Errorf("files = %d, want 3", stats.TotalFiles) + } + if stats.TotalBytes != 600 { + t.Errorf("bytes = %d, want 600", stats.TotalBytes) + } + if !stats.Healthy || !stats.Configured { + t.Error("expected healthy + configured") + } + if stats.Backend != "s3" { + t.Errorf("backend = %q, want s3", stats.Backend) + } +} + +func TestS3_Integration_PutUnknownSize(t *testing.T) { + s := testS3Store(t) + ctx := context.Background() + + data := []byte("unknown size upload") + key := "attachments/ch/unknown-size.txt" + + // size=0 means unknown — S3 should handle via multipart + err := s.Put(ctx, key, bytes.NewReader(data), 0, "text/plain") + if err != nil { + t.Fatalf("Put with size=0: %v", err) + } + + rc, size, _, err := s.Get(ctx, key) + if err != nil { + t.Fatalf("Get: %v", err) + } + defer rc.Close() + + if size != int64(len(data)) { + t.Errorf("size = %d, want %d", size, len(data)) + } +} diff --git a/server/storage/storage.go b/server/storage/storage.go new file mode 100644 index 0000000..494fa68 --- /dev/null +++ b/server/storage/storage.go @@ -0,0 +1,73 @@ +package storage + +import ( + "context" + "errors" + "io" +) + +// ── Errors ───────────────────────────────── + +var ( + // ErrNotFound is returned when a requested object does not exist. + ErrNotFound = errors.New("storage: object not found") + + // ErrNotConfigured is returned when storage operations are attempted + // but no backend has been configured or the backend is unhealthy. + ErrNotConfigured = errors.New("storage: backend not configured") +) + +// ── ObjectStore Interface ────────────────── + +// ObjectStore is the abstraction for blob storage. +// Implementations: PVC (filesystem), S3 (minio-go v7). +// +// Keys are slash-delimited paths relative to the storage root: +// +// attachments/{channel_id}/{attachment_id}_{filename} +// +// Implementations must create intermediate directories as needed. +type ObjectStore interface { + // Put writes data to the given key. + // Creates parent directories as needed. Overwrites if exists. + Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error + + // Get returns a reader for the given key. + // Returns the reader, size in bytes, content type, and error. + // Caller must close the returned reader. + // Returns ErrNotFound if key does not exist. + Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) + + // Delete removes the object at key. + // No error if the key does not exist (idempotent). + Delete(ctx context.Context, key string) error + + // DeletePrefix removes all objects under the given prefix. + // Used for channel deletion (bulk cleanup). + // Example: DeletePrefix(ctx, "attachments/{channel_id}/") + DeletePrefix(ctx context.Context, prefix string) error + + // Exists checks if an object exists at key without reading it. + Exists(ctx context.Context, key string) (bool, error) + + // Healthy returns nil if the backend is operational (writable). + Healthy(ctx context.Context) error + + // Stats returns aggregate storage statistics. + Stats(ctx context.Context) (*StorageStats, error) + + // Backend returns the backend type identifier ("pvc", "s3"). + Backend() string +} + +// StorageStats holds aggregate storage metrics. +type StorageStats struct { + Backend string `json:"backend"` + Path string `json:"path,omitempty"` // PVC only + Endpoint string `json:"endpoint,omitempty"` // S3 only + Bucket string `json:"bucket,omitempty"` // S3 only + Healthy bool `json:"healthy"` + Configured bool `json:"configured"` + TotalFiles int64 `json:"total_files"` + TotalBytes int64 `json:"total_bytes"` +} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 2e7ca51..3de9f5c 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -33,6 +33,7 @@ type Stores struct { Usage UsageStore Pricing PricingStore Extensions ExtensionStore + Attachments AttachmentStore } // ========================================= @@ -340,6 +341,24 @@ type ExtensionStore interface { DeleteUserSettings(ctx context.Context, extID, userID string) error } +// ========================================= +// ATTACHMENT STORE +// ========================================= + +type AttachmentStore interface { + Create(ctx context.Context, a *models.Attachment) error + GetByID(ctx context.Context, id string) (*models.Attachment, error) + GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) + GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) + SetMessageID(ctx context.Context, attachmentID, messageID string) error + UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error + SetExtractedText(ctx context.Context, id string, text string) error + Delete(ctx context.Context, id string) (*models.Attachment, error) // returns deleted row for storage cleanup + DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys + UserUsageBytes(ctx context.Context, userID string) (int64, error) + ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) +} + // ========================================= // SHARED TYPES // ========================================= diff --git a/server/store/postgres/attachment.go b/server/store/postgres/attachment.go new file mode 100644 index 0000000..7d1c210 --- /dev/null +++ b/server/store/postgres/attachment.go @@ -0,0 +1,187 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +type AttachmentStore struct{} + +func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} } + +// ── columns shared across queries ────────── +const attachmentCols = `id, channel_id, user_id, message_id, filename, content_type, + size_bytes, storage_key, extracted_text, metadata, created_at` + +// scanAttachment scans a row into an Attachment struct. +func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) { + var a models.Attachment + var messageID sql.NullString + var extractedText sql.NullString + var metadataJSON []byte + + err := row.Scan( + &a.ID, &a.ChannelID, &a.UserID, &messageID, + &a.Filename, &a.ContentType, &a.SizeBytes, + &a.StorageKey, &extractedText, &metadataJSON, &a.CreatedAt, + ) + if err != nil { + return nil, err + } + + a.MessageID = NullableStringPtr(messageID) + if extractedText.Valid { + a.ExtractedText = &extractedText.String + } + if len(metadataJSON) > 0 { + json.Unmarshal(metadataJSON, &a.Metadata) + } + return &a, nil +} + +func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error { + return DB.QueryRowContext(ctx, ` + INSERT INTO attachments (channel_id, user_id, message_id, filename, content_type, + size_bytes, storage_key, extracted_text, metadata) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id, created_at`, + a.ChannelID, a.UserID, models.NullString(a.MessageID), + a.Filename, a.ContentType, a.SizeBytes, + a.StorageKey, models.NullString(a.ExtractedText), + ToJSON(a.Metadata), + ).Scan(&a.ID, &a.CreatedAt) +} + +func (s *AttachmentStore) GetByID(ctx context.Context, id string) (*models.Attachment, error) { + row := DB.QueryRowContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE id = $1`, id) + return scanAttachment(row) +} + +func (s *AttachmentStore) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) { + rows, err := DB.QueryContext(ctx, + `SELECT `+attachmentCols+` FROM attachments WHERE channel_id = $1 ORDER BY created_at`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []models.Attachment + for rows.Next() { + a, err := scanAttachment(rows) + if err != nil { + return nil, err + } + out = append(out, *a) + } + return out, rows.Err() +} + +func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) { + rows, err := DB.QueryContext(ctx, + `SELECT `+attachmentCols+` FROM attachments WHERE message_id = $1 ORDER BY created_at`, messageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []models.Attachment + for rows.Next() { + a, err := scanAttachment(rows) + if err != nil { + return nil, err + } + out = append(out, *a) + } + return out, rows.Err() +} + +func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error { + _, err := DB.ExecContext(ctx, + `UPDATE attachments SET message_id = $1 WHERE id = $2`, + messageID, attachmentID) + return err +} + +func (s *AttachmentStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error { + // Merge into existing metadata using jsonb || operator + metaJSON, err := json.Marshal(metadata) + if err != nil { + return err + } + _, err = DB.ExecContext(ctx, + `UPDATE attachments SET metadata = metadata || $1::jsonb WHERE id = $2`, + metaJSON, id) + return err +} + +func (s *AttachmentStore) SetExtractedText(ctx context.Context, id string, text string) error { + _, err := DB.ExecContext(ctx, + `UPDATE attachments SET extracted_text = $1 WHERE id = $2`, + text, id) + return err +} + +// Delete removes an attachment and returns the deleted row (for storage cleanup). +func (s *AttachmentStore) Delete(ctx context.Context, id string) (*models.Attachment, error) { + row := DB.QueryRowContext(ctx, + `DELETE FROM attachments WHERE id = $1 + RETURNING `+attachmentCols, id) + return scanAttachment(row) +} + +// DeleteByChannel removes all attachments for a channel and returns storage keys. +func (s *AttachmentStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, + `DELETE FROM attachments WHERE channel_id = $1 RETURNING storage_key`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + + var keys []string + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + return keys, err + } + keys = append(keys, key) + } + return keys, rows.Err() +} + +func (s *AttachmentStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) { + var total sql.NullInt64 + err := DB.QueryRowContext(ctx, + `SELECT COALESCE(SUM(size_bytes), 0) FROM attachments WHERE user_id = $1`, + userID).Scan(&total) + if err != nil { + return 0, err + } + return total.Int64, nil +} + +func (s *AttachmentStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) { + cutoff := time.Now().Add(-olderThan) + rows, err := DB.QueryContext(ctx, + `SELECT `+attachmentCols+` FROM attachments + WHERE message_id IS NULL AND created_at < $1 + ORDER BY created_at`, cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []models.Attachment + for rows.Next() { + a, err := scanAttachment(rows) + if err != nil { + return nil, err + } + out = append(out, *a) + } + return out, rows.Err() +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index 68e158f..41d94e7 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -26,5 +26,6 @@ func NewStores(db *sql.DB) store.Stores { Usage: NewUsageStore(), Pricing: NewPricingStore(), Extensions: NewExtensionStore(), + Attachments: NewAttachmentStore(), } } diff --git a/src/css/styles.css b/src/css/styles.css index 660cd2f..8a34859 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -901,6 +901,133 @@ a:hover { text-decoration: underline; } .stop-btn { display: none; color: var(--warning); } .stop-btn.visible { display: flex; } +/* ── Attachments ─────────────────────────── */ + +.attach-btn { color: var(--text-3); padding: 6px 4px 12px 10px; } +.attach-btn:hover { color: var(--text); } + +.attachment-strip { + max-width: 768px; margin: 0 auto; padding: 6px 8px 2px; + display: flex; gap: 6px; flex-wrap: wrap; +} + +.att-chip { + display: flex; align-items: center; gap: 6px; + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius); padding: 4px 6px; + font-size: 12px; max-width: 220px; position: relative; +} +.att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); } +.att-chip.att-error { border-color: color-mix(in srgb, var(--error) 40%, var(--border)); } +.att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); } + +.att-thumb { + width: 32px; height: 32px; object-fit: cover; + border-radius: 3px; flex-shrink: 0; +} +.att-icon { font-size: 18px; flex-shrink: 0; line-height: 1; } + +.att-info { display: flex; flex-direction: column; min-width: 0; } +.att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 500; } +.att-meta { color: var(--text-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.att-remove { + background: none; border: none; color: var(--text-3); cursor: pointer; + padding: 2px 4px; font-size: 13px; line-height: 1; border-radius: 3px; + flex-shrink: 0; margin-left: auto; +} +.att-remove:hover { background: var(--bg-hover); color: var(--text); } + +/* Drag-and-drop overlay on chat area */ +.messages.drag-over { + outline: 2px dashed var(--accent); + outline-offset: -4px; + background: color-mix(in srgb, var(--accent) 4%, var(--bg)); +} + +/* ── Message Attachments ────────────────────── */ + +.msg-attachments { + display: flex; gap: 8px; flex-wrap: wrap; + margin-top: 8px; +} + +.msg-att-image { + cursor: pointer; position: relative; border-radius: var(--radius); + overflow: hidden; border: 1px solid var(--border); + transition: border-color var(--transition); +} +.msg-att-image:hover { border-color: var(--accent); } +.msg-att-image img { + display: block; max-height: 280px; border-radius: var(--radius); + background: var(--bg-raised); +} +.msg-att-image img.att-load-error { + min-width: 120px; min-height: 60px; + display: flex; align-items: center; justify-content: center; + font-size: 13px; color: var(--text-3); +} + +.att-vision-hint { + position: absolute; top: 4px; right: 4px; + background: color-mix(in srgb, var(--bg) 85%, transparent); + border-radius: 4px; padding: 2px 5px; font-size: 14px; + cursor: help; +} + +.msg-att-doc { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border: 1px solid var(--border); + border-radius: var(--radius); background: var(--bg-raised); + text-decoration: none; color: var(--text); + max-width: 280px; transition: border-color var(--transition); +} +.msg-att-doc:hover { border-color: var(--accent); } +.att-doc-icon { font-size: 20px; flex-shrink: 0; } +.att-doc-info { display: flex; flex-direction: column; min-width: 0; } +.att-doc-name { + font-size: 13px; font-weight: 500; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.att-doc-size { font-size: 11px; color: var(--text-3); } +.att-doc-dl { color: var(--text-3); font-size: 14px; margin-left: auto; } + +/* ── Image Lightbox ─────────────────────────── */ + +.lightbox-overlay { + display: none; position: fixed; inset: 0; z-index: 10000; + background: rgba(0,0,0,0.85); align-items: center; justify-content: center; +} +.lightbox-overlay.active { display: flex; } +.lightbox-overlay img { + max-width: 92vw; max-height: 90vh; object-fit: contain; + border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} +.lightbox-close { + position: absolute; top: 16px; right: 20px; + background: rgba(255,255,255,0.15); border: none; color: #fff; + font-size: 22px; width: 40px; height: 40px; border-radius: 50%; + cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: background 0.15s; +} +.lightbox-close:hover { background: rgba(255,255,255,0.3); } + +/* ── Admin Storage Panel ────────────────────── */ + +.admin-storage-card { + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px; margin-bottom: 12px; +} +.admin-storage-card h4 { margin: 0 0 10px 0; font-size: 14px; } +.admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 10px 0; } +.admin-storage-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 10px; +} +.admin-storage-item { display: flex; flex-direction: column; gap: 2px; } +.admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; } +.admin-storage-value { font-size: 14px; font-weight: 500; } + /* ── Buttons ─────────────────────────────── */ button { font-family: var(--font); cursor: pointer; } @@ -1354,6 +1481,9 @@ button { font-family: var(--font); cursor: pointer; } .team-admin-back:hover { opacity: 1; } .team-tab-content { padding: 0; } .badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-success { background: rgba(34,197,94,0.2); color: #4ade80; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-warning { background: rgba(234,179,8,0.2); color: #fbbf24; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-danger { background: rgba(239,68,68,0.2); color: #f87171; font-size: 10px; padding: 1px 8px; border-radius: 4px; } .admin-user-row.user-inactive { opacity: 0.7; } .loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; } .error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; } diff --git a/src/index.html b/src/index.html index 4900bb2..0393f82 100644 --- a/src/index.html +++ b/src/index.html @@ -147,7 +147,12 @@
+
+ +
+
@@ -706,6 +712,10 @@
+
+

🔐 Encryption

+
Loading…
+
@@ -766,6 +776,7 @@ + `; diff --git a/src/sw.js b/src/sw.js index 664d66a..f637144 100644 --- a/src/sw.js +++ b/src/sw.js @@ -21,6 +21,7 @@ const SHELL_FILES = [ './js/ui-admin.js', './js/tokens.js', './js/notes.js', + './js/attachments.js', './js/chat.js', './js/settings-handlers.js', './js/admin-handlers.js', diff --git a/status.go b/status.go new file mode 100644 index 0000000..9211bdf --- /dev/null +++ b/status.go @@ -0,0 +1,46 @@ +package crypto + +import ( + "database/sql" + "fmt" +) + +// VaultStatusInfo holds vault health information for admin display. +type VaultStatusInfo struct { + EncryptionKeySet bool `json:"encryption_key_set"` + EncryptedKeys int `json:"encrypted_keys"` // provider_configs with api_key_enc + VaultUsers int `json:"vault_users"` // users with vault_set = true +} + +// VaultStatus gathers vault health metrics from the database. +// Used by both the CLI (`switchboard vault status`) and the admin API endpoint. +func VaultStatus(db *sql.DB, encryptionKey string) (*VaultStatusInfo, error) { + if db == nil { + return nil, fmt.Errorf("database not available") + } + + info := &VaultStatusInfo{ + EncryptionKeySet: encryptionKey != "", + } + + // Count encrypted provider keys (global + team + personal) + err := db.QueryRow(` + SELECT COUNT(*) FROM provider_configs + WHERE api_key_enc IS NOT NULL + `).Scan(&info.EncryptedKeys) + if err != nil { + return nil, fmt.Errorf("count encrypted keys: %w", err) + } + + // Count users with active vaults + err = db.QueryRow(` + SELECT COUNT(*) FROM users + WHERE vault_set = true + `).Scan(&info.VaultUsers) + if err != nil { + // vault_set column might not exist on very old installs + info.VaultUsers = 0 + } + + return info, nil +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..768bdab --- /dev/null +++ b/styles.css @@ -0,0 +1,1893 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap'); + +/* ========================================== + Chat Switchboard + Clean utility layout · Open WebUI inspired + ========================================== */ + +:root { + --bg: #0e0e10; + --bg-surface: #18181b; + --bg-raised: #222227; + --bg-hover: #2a2a30; + --border: #2e2e35; + --border-light: #3a3a42; + --text: #e8e8ed; + --text-2: #9898a8; + --text-3: #6b6b7b; + --accent: #6c9fff; + --accent-hover: #84b0ff; + --accent-dim: rgba(108,159,255,0.12); + --danger: #ef4444; + --success: #22c55e; + --warning: #eab308; + --purple: #a78bfa; + --purple-dim: rgba(167,139,250,0.10); + --radius: 8px; + --radius-lg: 12px; + --sidebar-w: 260px; + --sidebar-rail: 52px; + --font: 'DM Sans', 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace; + --transition: 180ms ease; + + /* Banner system — set by JS from /api/v1/admin/settings/banner */ + --banner-top-height: 0px; + --banner-bottom-height: 0px; + --banner-bg: transparent; + --banner-fg: transparent; +} + +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; } +body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; font-size: 14px; } + +/* Global scrollbar styling */ +* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--border-light); } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +::selection { background: var(--accent-dim); } + +/* ── App Shell ───────────────────────────── */ + +.app { display: flex; height: 100vh; height: 100dvh; flex-direction: column; } +.app-body { display: flex; flex: 1; min-height: 0; } + +/* ── Environment Banners ──────────────────── */ + +.banner { + display: none; + width: 100%; text-align: center; + font-size: 12px; font-weight: 700; letter-spacing: 1px; + text-transform: uppercase; + padding: 2px 0; line-height: 1.4; + background: var(--banner-bg); color: var(--banner-fg); + flex-shrink: 0; z-index: 9999; + min-height: 22px; box-sizing: border-box; +} +.banner.active { display: flex; align-items: center; justify-content: center; } +.banner-top { order: -1; } +.banner-bottom { order: 999; } + +/* ── Sidebar ─────────────────────────────── */ + +.sidebar { + width: var(--sidebar-w); background: var(--bg-surface); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + transition: width var(--transition); + overflow: hidden; flex-shrink: 0; +} +.sidebar.collapsed { width: var(--sidebar-rail); } + +.sidebar-top { + display: flex; flex-direction: column; gap: 2px; + padding: 10px 10px 8px; + border-bottom: 1px solid var(--border); +} + +/* Brand / collapse toggle */ +.sb-brand { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: var(--radius); + background: none; border: none; color: var(--text); + cursor: pointer; font-size: 13px; white-space: nowrap; + transition: background var(--transition); width: 100%; + position: relative; +} +.sb-brand:hover { background: var(--bg-hover); } +.brand-logo { flex-shrink: 0; width: 18px; height: 18px; display: grid; place-items: center; } +.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; } +.brand-collapse { display: none; } +.brand-text { font-weight: 600; font-size: 14px; } + +.sb-btn { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: var(--radius); + background: none; border: none; color: var(--text-2); + cursor: pointer; font-size: 13px; white-space: nowrap; + transition: background var(--transition), color var(--transition); + width: 100%; +} +.sb-btn:hover { background: var(--bg-hover); color: var(--text); } +.sb-btn svg { flex-shrink: 0; } +.sb-label { overflow: hidden; transition: opacity var(--transition); } +.sidebar.collapsed .sb-label { opacity: 0; width: 0; } +.sidebar.collapsed .brand-text { opacity: 0; width: 0; } + +/* Collapsed centering */ +.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; } +.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; } +.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; } +.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; } +.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; } +.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; } +.sidebar.collapsed .user-flyout { + position: fixed; + left: var(--sidebar-rail); + bottom: 8px; + right: auto; + width: 200px; + margin-bottom: 0; +} +.sidebar.collapsed .avatar-bug { display: none; } + +/* Split button (New Chat + dropdown) */ +.split-btn { display: flex; width: 100%; gap: 0; } +.split-btn-main { + flex: 1; display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: var(--radius) 0 0 var(--radius); + background: none; border: none; color: var(--text-2); + cursor: pointer; font-size: 13px; white-space: nowrap; + transition: background var(--transition), color var(--transition); +} +.split-btn-main:hover { background: var(--bg-hover); color: var(--text); } +.split-btn-main svg { flex-shrink: 0; } + +.split-btn-drop { + display: flex; align-items: center; padding: 8px 6px; + border-radius: 0 var(--radius) var(--radius) 0; + background: none; border: none; border-left: 1px solid var(--border); + color: var(--text-3); cursor: pointer; + transition: background var(--transition), color var(--transition); +} +.split-btn-drop:hover { background: var(--bg-hover); color: var(--text); } +.sidebar.collapsed .split-btn-drop { display: none; } + +/* Dropdown menu */ +.split-dropdown { + display: none; position: absolute; left: 6px; right: 6px; + top: 100%; z-index: 200; + background: var(--bg-raised); border: 1px solid var(--border-light); + border-radius: var(--radius); padding: 4px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} +.split-dropdown.open { display: block; } +.split-dropdown-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border-radius: 4px; width: 100%; + background: none; border: none; color: var(--text-2); + cursor: pointer; font-size: 12px; white-space: nowrap; + transition: background var(--transition), color var(--transition); +} +.split-dropdown-item:hover { background: var(--bg-hover); color: var(--text); } +.split-dropdown-item.disabled { opacity: 0.4; pointer-events: none; } +.split-dropdown-item .dd-hint { + margin-left: auto; font-size: 10px; color: var(--text-3); +} + +/* Chat/Channel list */ +.sidebar-chats { + flex: 1; overflow-y: auto; overflow-x: hidden; + padding: 8px 6px; +} +.sidebar-chats::-webkit-scrollbar { width: 4px; } + +/* Channels section header */ +.sidebar-section-header { + display: flex; align-items: center; gap: 6px; + padding: 8px 10px 4px; cursor: pointer; + user-select: none; +} +.sidebar-section-header .section-arrow { + font-size: 10px; color: var(--text-3); transition: transform var(--transition); +} +.sidebar-section-header.collapsed .section-arrow { transform: rotate(-90deg); } +.sidebar-section-header .section-label { + font-size: 11px; font-weight: 600; color: var(--text-3); + text-transform: uppercase; letter-spacing: 0.5px; +} +.sidebar-section-header .section-count { + font-size: 10px; color: var(--text-3); margin-left: auto; +} +.sidebar.collapsed .sidebar-section-header { display: none; } + +.channel-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border-radius: var(--radius); + cursor: pointer; font-size: 13px; color: var(--text-2); + transition: background var(--transition); position: relative; + overflow: hidden; +} +.channel-item:hover { background: var(--bg-hover); color: var(--text); } +.channel-item.active { background: var(--bg-raised); color: var(--text); } +.channel-item .channel-hash { color: var(--text-3); font-weight: 600; flex-shrink: 0; } +.channel-item .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sidebar.collapsed .channel-item .channel-name { display: none; } + +.chat-group-label { + font-size: 11px; font-weight: 600; color: var(--text-3); + padding: 12px 10px 4px; text-transform: uppercase; letter-spacing: 0.5px; + white-space: nowrap; overflow: hidden; +} +.sidebar.collapsed .chat-group-label { display: none; } + +.chat-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border-radius: var(--radius); + cursor: pointer; font-size: 13px; color: var(--text-2); + transition: background var(--transition); position: relative; + overflow: hidden; +} +.chat-item:hover { background: var(--bg-hover); color: var(--text); } +.chat-item.active { background: var(--bg-raised); color: var(--text); } +.chat-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-item-time { + font-size: 10px; color: var(--text-3); flex-shrink: 0; + white-space: nowrap; +} +.chat-item-delete { + opacity: 0; position: absolute; right: 6px; background: var(--bg-raised); + border: none; color: var(--text-3); cursor: pointer; font-size: 11px; + padding: 2px 6px; border-radius: 4px; transition: opacity var(--transition); +} +.chat-item:hover .chat-item-delete { opacity: 1; } +.chat-item:hover .chat-item-time { display: none; } +.sidebar.collapsed .chat-item-title, +.sidebar.collapsed .chat-item-time, +.sidebar.collapsed .chat-item-delete { display: none; } +.sidebar.collapsed .sidebar-chats { visibility: hidden; } + +.sidebar-empty { + color: var(--text-3); font-size: 12px; text-align: center; padding: 2rem 0.5rem; +} +.sidebar.collapsed .sidebar-empty { display: none; } + +/* User area */ +.sidebar-bottom { + border-top: 1px solid var(--border); + padding: 8px 10px; position: relative; +} + +.user-btn { + display: flex; align-items: center; gap: 10px; + padding: 6px 8px; border-radius: var(--radius); + background: none; border: none; color: var(--text); + cursor: pointer; width: 100%; + transition: background var(--transition); +} +.user-btn:hover { background: var(--bg-hover); } + +.user-avatar { + width: 30px; height: 30px; border-radius: 50%; + background: var(--bg-raised); display: flex; align-items: center; + justify-content: center; font-size: 13px; font-weight: 600; + color: var(--accent); flex-shrink: 0; position: relative; + overflow: hidden; +} +.user-avatar-img { + width: 100%; height: 100%; object-fit: cover; border-radius: 50%; +} +.avatar-bug { + position: absolute; bottom: -3px; right: -3px; + font-size: 10px; line-height: 1; + display: none; + transition: filter var(--transition); +} +.avatar-bug.has-errors { + display: block; + filter: drop-shadow(0 0 4px var(--danger)); + animation: bug-pulse 1.5s ease infinite; +} +@keyframes bug-pulse { 0%,100% { filter: drop-shadow(0 0 3px var(--danger)); } 50% { filter: drop-shadow(0 0 8px var(--danger)); } } +.user-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Flyout */ +.user-flyout { + display: none; position: absolute; bottom: 100%; left: 8px; right: 8px; + background: var(--bg-raised); border: 1px solid var(--border-light); + border-radius: var(--radius-lg); padding: 4px; margin-bottom: 4px; + box-shadow: 0 -4px 24px rgba(0,0,0,0.5); z-index: 200; +} +.user-flyout.open { display: block; } + +.flyout-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: var(--radius); + background: none; border: none; color: var(--text-2); + cursor: pointer; font-size: 13px; width: 100%; + transition: background var(--transition), color var(--transition); +} +.flyout-item:hover { background: var(--bg-hover); color: var(--text); } +.flyout-item svg { flex-shrink: 0; } +.flyout-danger:hover { color: var(--danger); } +.flyout-divider { height: 1px; background: var(--border); margin: 4px 8px; } + +/* ── Chat Area ───────────────────────────── */ + +.chat-area { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); } + +.model-bar { + display: flex; align-items: center; gap: 6px; + padding: 8px 16px; flex-shrink: 0; +} +.model-bar select { + background: transparent; border: 1px solid transparent; + color: var(--text); font-size: 14px; font-weight: 500; + padding: 4px 8px; border-radius: var(--radius); cursor: pointer; + font-family: var(--font); max-width: 300px; + transition: border-color var(--transition); +} +.model-bar select:hover { border-color: var(--border); } +.model-bar select:focus { outline: none; border-color: var(--accent); } +.model-bar select option { background: var(--bg-surface); color: var(--text); } + +/* ── Custom Model Dropdown ───────────────── */ + +.model-dropdown { position: relative; } +.model-dropdown-btn { + display: flex; align-items: center; gap: 6px; + background: none; border: 1px solid transparent; + color: var(--text); font-size: 14px; font-weight: 500; + padding: 4px 8px; border-radius: var(--radius); cursor: pointer; + font-family: var(--font); max-width: 340px; + transition: border-color var(--transition); +} +.model-dropdown-btn:hover { border-color: var(--border); } +.model-dropdown-btn svg { flex-shrink: 0; opacity: 0.5; } +.model-dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model-dropdown-menu { + display: none; position: absolute; top: 100%; left: 0; z-index: 200; + min-width: 280px; max-width: 400px; max-height: 400px; overflow-y: auto; + background: var(--bg-raised); border: 1px solid var(--border-light); + border-radius: var(--radius); padding: 4px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + margin-top: 4px; +} +.model-dropdown-menu.open { display: block; } +.model-dropdown-group { + padding: 6px 10px 4px; font-size: 11px; font-weight: 600; + color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; +} +.model-dropdown-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border-radius: 4px; + cursor: pointer; font-size: 13px; + color: var(--text-2); white-space: nowrap; + transition: background var(--transition), color var(--transition); +} +.model-dropdown-item:hover { background: var(--bg-hover); color: var(--text); } +.model-dropdown-item.selected { background: var(--bg-hover); color: var(--accent); } +.model-dropdown-item .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; } +.model-dropdown-item .item-provider { margin-left: auto; font-size: 10px; color: var(--text-3); } +.dropdown-avatar { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } + +.model-caps { + display: flex; align-items: center; gap: 4px; + margin-left: 4px; flex-wrap: wrap; +} +.cap-badge { + display: inline-flex; align-items: center; gap: 3px; + font-size: 10px; line-height: 1; padding: 2px 6px; + border-radius: 3px; white-space: nowrap; + background: var(--bg-raised); color: var(--text-3); + border: 1px solid transparent; +} +.cap-badge.cap-accent { + color: var(--accent); border-color: color-mix(in srgb, var(--accent), transparent 80%); + background: color-mix(in srgb, var(--accent), transparent 92%); +} +.cap-badge.cap-context { + color: var(--text-3); +} + +.icon-btn { + background: none; border: none; color: var(--text-3); cursor: pointer; + padding: 4px; border-radius: 4px; + transition: color var(--transition); +} +.icon-btn:hover { color: var(--text); } + +/* Messages */ +.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; } + +.message { padding: 1.25rem 0; } +.message:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.03); } + +.msg-inner { max-width: 768px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 1rem; } + +.msg-avatar { + width: 28px; height: 28px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 14px; flex-shrink: 0; margin-top: 2px; + overflow: hidden; +} +.msg-avatar-img { + width: 100%; height: 100%; object-fit: cover; border-radius: 50%; +} +.message.user .msg-avatar { background: var(--accent-dim); } +.message.assistant .msg-avatar { background: var(--bg-raised); } + +.msg-body { flex: 1; min-width: 0; } +.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.msg-role { font-size: 13px; font-weight: 600; } +.msg-time { font-size: 11px; color: var(--text-3); } +.msg-actions { + margin-left: auto; display: flex; gap: 2px; opacity: 0; + transition: opacity var(--transition); +} +.message:hover .msg-actions { opacity: 1; } +.msg-action-btn { + background: none; border: none; color: var(--text-3); cursor: pointer; + padding: 2px 6px; border-radius: 4px; font-size: 11px; + transition: background var(--transition), color var(--transition); +} +.msg-action-btn:hover { background: var(--bg-hover); color: var(--text); } + +/* Branch navigation indicator */ +.branch-nav { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 12px; + color: var(--text-3); +} +.branch-arrow { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-2); + cursor: pointer; + padding: 0 5px; + font-size: 16px; + line-height: 18px; + transition: var(--transition); +} +.branch-arrow:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); } +.branch-arrow:disabled { opacity: 0.25; cursor: default; } +.branch-pos { + font-size: 11px; + font-variant-numeric: tabular-nums; + min-width: 28px; + text-align: center; + user-select: none; +} + +/* Inline message editing */ +.msg-edit-input { + width: 100%; + background: var(--bg-surface); + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--text); + padding: 10px 12px; + font-family: var(--font); + font-size: var(--msg-font, 14px); + line-height: 1.6; + resize: vertical; + min-height: 60px; + max-height: 400px; + outline: none; +} +.msg-edit-input:focus { border-color: var(--accent-hover); box-shadow: 0 0 0 2px var(--accent-dim); } +.msg-edit-actions { + display: flex; + gap: 8px; + margin-top: 8px; + justify-content: flex-end; +} +.msg-edit-cancel, .msg-edit-submit { + padding: 6px 14px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + border: 1px solid var(--border); +} +.msg-edit-cancel { + background: var(--bg-raised); + color: var(--text-2); +} +.msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); } +.msg-edit-submit { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.msg-edit-submit:hover { background: var(--accent-hover); } + +.msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; } + +/* ── Markdown Content ────────────────────── */ + +.msg-text p { margin: 0.4em 0; } +.msg-text p:first-child { margin-top: 0; } +.msg-text p:last-child { margin-bottom: 0; } + +.msg-text pre { + background: var(--bg-surface); border: 1px solid var(--border); + padding: 0.75rem 1rem; border-radius: var(--radius); + overflow-x: auto; margin: 0.75rem 0; position: relative; + font-size: 13px; +} +.msg-text code { + font-family: var(--mono); font-size: 0.9em; + background: var(--bg-raised); padding: 0.15em 0.4em; + border-radius: 4px; +} +.msg-text pre code { background: none; padding: 0; font-size: inherit; } + +/* Code block toolbar */ +.code-toolbar { + position: absolute; top: 6px; right: 6px; + display: flex; gap: 4px; align-items: center; + opacity: 0; transition: opacity var(--transition); +} +.msg-text pre:hover .code-toolbar { opacity: 1; } +.code-block.code-collapsed .code-toolbar { opacity: 1; } +.copy-code-btn { + background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text-3); border-radius: 4px; + padding: 2px 10px; font-size: 11px; cursor: pointer; + transition: color var(--transition), border-color var(--transition); +} +.copy-code-btn:hover { color: var(--text); border-color: var(--border-light); } +.code-collapse-btn { + background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text-3); border-radius: 4px; + padding: 2px 10px; font-size: 11px; cursor: pointer; + font-family: var(--mono); white-space: nowrap; + transition: color var(--transition), border-color var(--transition); +} +.code-collapse-btn:hover { color: var(--text); border-color: var(--border-light); } + +/* Collapsed code block */ +.code-block.code-collapsed code { + max-height: 3.6em; overflow: hidden; display: block; + mask-image: linear-gradient(to bottom, #000 40%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 100%); +} + +/* Language label */ +.code-lang { + position: absolute; top: 6px; left: 10px; + font-size: 10px; color: var(--text-3); font-family: var(--mono); + text-transform: uppercase; letter-spacing: 0.5px; user-select: none; +} + +/* ── Extension-rendered blocks ──────── */ + +.ext-rendered { + margin: 12px 0; +} + +/* HTML preview */ +/* ── Side Panel (Preview + Notes) ──────── */ + +.side-panel { + width: 0; min-width: 0; overflow: hidden; + background: var(--bg); border-left: 1px solid var(--border); + display: flex; flex-direction: column; position: relative; + transition: width 0.25s ease, min-width 0.25s ease; +} +.side-panel.open { + width: 480px; min-width: 480px; +} +.side-panel.fullscreen { + position: fixed; top: 0; right: 0; bottom: 0; + width: 100% !important; min-width: 100% !important; + z-index: 100; + transition: none; +} +.side-panel-resize { + position: absolute; left: -3px; top: 0; bottom: 0; width: 6px; + cursor: col-resize; z-index: 10; +} +.side-panel-resize:hover { background: var(--accent); opacity: 0.3; } +.side-panel.fullscreen .side-panel-resize { display: none; } +.side-panel-header { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 12px; border-bottom: 1px solid var(--border); + background: var(--bg-raised); flex-shrink: 0; +} +.side-panel-actions { + display: flex; align-items: center; gap: 2px; +} +.side-panel-action-btn { + background: none; border: none; color: var(--text-3); + cursor: pointer; padding: 4px 6px; border-radius: var(--radius); + transition: all var(--transition); display: flex; align-items: center; +} +.side-panel-action-btn:hover { background: var(--bg-hover); color: var(--text); } +.side-panel-tabs { + display: flex; gap: 2px; background: var(--bg-surface); + border-radius: var(--radius); padding: 2px; +} +.side-panel-tab { + padding: 4px 14px; border-radius: calc(var(--radius) - 2px); + font-size: 12px; font-family: var(--font); font-weight: 500; + background: none; border: none; color: var(--text-3); + cursor: pointer; transition: all var(--transition); +} +.side-panel-tab:hover { color: var(--text); } +.side-panel-tab.active { + background: var(--bg-raised); color: var(--text); + box-shadow: 0 1px 2px rgba(0,0,0,0.2); +} +.side-panel-close { + background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 16px; padding: 2px 6px; + border-radius: var(--radius); transition: all var(--transition); +} +.side-panel-close:hover { background: var(--bg-hover); color: var(--text); } +.side-panel-body { + flex: 1; overflow-y: auto; min-height: 0; +} +.side-panel-page { height: 100%; display: flex; flex-direction: column; } +.side-panel-empty { + display: flex; align-items: center; justify-content: center; + height: 100%; color: var(--text-3); font-size: 13px; + padding: 2rem; text-align: center; +} +.preview-frame { + flex: 1; width: 100%; border: none; background: #fff; +} + +/* Notes inside side panel */ +.notes-actions-bar { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border-bottom: 1px solid var(--border); +} +.side-panel .notes-list { padding: 4px 8px; } +.side-panel .notes-editor { padding: 8px 12px; } +.side-panel .note-read-content { + max-height: none; flex: 1; overflow-y: auto; +} +.side-panel .notes-content-input { + min-height: 150px; +} +.side-panel #notesListView { + flex: 1; overflow-y: auto; min-height: 0; +} +.side-panel #notesEditorView { + flex: 1; overflow-y: auto; min-height: 0; +} + +/* Mobile: full-width overlay */ +@media (max-width: 768px) { + .side-panel.open { + position: fixed; top: 0; right: 0; bottom: 0; + width: 100vw; min-width: 100vw; z-index: 200; + } +} + +.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; } +.msg-text li { margin: 0.2em 0; } +.msg-text li > p { margin: 0.2em 0; } + +.msg-text blockquote { + border-left: 3px solid var(--accent); padding: 0.25em 0.75em; margin: 0.5em 0; + color: var(--text-2); background: var(--accent-dim); border-radius: 0 var(--radius) var(--radius) 0; +} + +.msg-text hr { border: none; border-top: 1px solid var(--border); margin: 0.75em 0; } + +.msg-text table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 13px; } +.msg-text th, .msg-text td { border: 1px solid var(--border); padding: 0.4em 0.7em; text-align: left; } +.msg-text th { background: var(--bg-raised); font-weight: 600; } + +.msg-text h1, .msg-text h2, .msg-text h3, .msg-text h4 { margin: 0.75em 0 0.3em; font-weight: 600; } +.msg-text h1 { font-size: 1.4em; } +.msg-text h2 { font-size: 1.2em; } +.msg-text h3 { font-size: 1.05em; } + +.msg-text a { color: var(--accent); } +.msg-text img { max-width: 100%; border-radius: var(--radius); } + +/* Thinking blocks */ +.thinking-block { + border: 1px solid rgba(108,159,255,0.12); border-radius: var(--radius); + margin: 0.5rem 0; background: rgba(108,159,255,0.03); +} +.thinking-block summary { + padding: 0.4rem 0.75rem; cursor: pointer; font-size: 12px; + color: var(--text-3); user-select: none; + transition: color var(--transition); +} +.thinking-block summary:hover { color: var(--text-2); } +.thinking-block[open] summary { border-bottom: 1px solid rgba(108,159,255,0.08); } +.thinking-content { + padding: 0.5rem 0.75rem; font-size: 12px; color: var(--text-3); + max-height: 300px; overflow-y: auto; line-height: 1.6; +} + +/* Tool activity indicators */ +.msg-tools { + display: flex; flex-direction: column; gap: 4px; + margin-bottom: 0.5rem; +} +.tool-activity { + display: flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--text-3); + background: var(--bg-raised); border: 1px solid var(--border); + padding: 4px 10px; border-radius: var(--radius); + animation: tool-fade-in 0.2s ease; +} +@keyframes tool-fade-in { from { opacity: 0; transform: translateY(-4px); } } +.tool-icon { font-size: 13px; } +.tool-name { font-family: var(--mono); font-size: 11px; color: var(--text-2); } +.tool-status { font-size: 11px; margin-left: auto; } +.tool-running { color: var(--warning); } +.tool-running::after { + content: ''; display: inline-block; width: 4px; height: 4px; + border-radius: 50%; background: var(--warning); margin-left: 4px; + animation: tool-pulse 1s infinite; +} +@keyframes tool-pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } } +.tool-done { color: var(--success); } +.tool-error { color: var(--danger); } +.tool-hint { + font-size: 11px; color: var(--text-3); margin-left: 6px; + max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.tool-note-link { + background: none; border: none; color: var(--accent); cursor: pointer; + font-size: 11px; margin-left: 6px; padding: 0; + text-decoration: none; + transition: color var(--transition); +} +.tool-note-link:hover { color: var(--text); text-decoration: underline; } + +/* Tool calls in message history (collapsed) */ +.tool-call-block { + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg-raised); font-size: 12px; +} +.tool-call-summary { + display: flex; align-items: center; gap: 6px; + padding: 4px 10px; cursor: pointer; user-select: none; + color: var(--text-3); list-style: none; + transition: color var(--transition); +} +.tool-call-summary::-webkit-details-marker { display: none; } +.tool-call-summary::before { + content: '▸'; font-size: 10px; color: var(--text-3); + transition: transform 0.15s ease; +} +.tool-call-block[open] .tool-call-summary::before { transform: rotate(90deg); } +.tool-call-summary:hover { color: var(--text-2); } +.tool-detail { + border-top: 1px solid var(--border); padding: 6px 10px; + display: flex; flex-direction: column; gap: 6px; +} +.tool-detail-label { + font-size: 10px; color: var(--text-3); text-transform: uppercase; + letter-spacing: 0.5px; font-weight: 600; +} +.tool-detail-pre { + margin: 2px 0 0; padding: 6px 8px; + background: var(--bg-surface); border-radius: 4px; + font-size: 11px; color: var(--text-2); white-space: pre-wrap; + word-break: break-all; max-height: 200px; overflow-y: auto; +} + +/* Empty state */ +.empty-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem; +} +.empty-logo { font-size: 3rem; margin-bottom: 0.5rem; } +.empty-logo-img { width: 64px; height: 64px; object-fit: contain; } +.empty-state h2 { font-size: 1.25rem; font-weight: 600; color: var(--text-2); } +.empty-state p { font-size: 0.85rem; } + +/* Typing indicator */ +.typing-dots { display: flex; gap: 4px; padding: 0.5rem 0; } +.typing-dots span { + width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); + animation: dot-pulse 1.2s infinite; +} +.typing-dots span:nth-child(2) { animation-delay: 0.2s; } +.typing-dots span:nth-child(3) { animation-delay: 0.4s; } +@keyframes dot-pulse { 0%,60%,100% { opacity: 0.4; } 30% { opacity: 1; } } + +/* ── Input Area ──────────────────────────── */ + +.input-area { padding: 0 1rem 1rem; flex-shrink: 0; } + +/* Token counter below input */ +.input-meta { display: flex; justify-content: space-between; align-items: center; padding: 2px 12px 0; min-height: 18px; } +.input-token-count { font-size: 0.68rem; color: var(--text-3); transition: color 0.2s; } +.input-token-count.warning { color: var(--warning); } +.input-token-count.danger { color: var(--danger); } + +/* Context length warning banner */ +.context-warning { + display: flex; align-items: center; gap: 8px; + padding: 6px 12px; margin-bottom: 8px; border-radius: 8px; + background: color-mix(in srgb, var(--warning) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent); + font-size: 0.78rem; color: var(--text-2); animation: fadeIn 0.2s ease; +} +.context-warning.danger { + background: color-mix(in srgb, var(--danger) 12%, transparent); + border-color: color-mix(in srgb, var(--danger) 25%, transparent); +} +.context-warning-icon { flex-shrink: 0; } +.context-warning-text { flex: 1; } +.context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; } +.context-warning-dismiss:hover { color: var(--text-1); } +.context-warning-action { + flex-shrink: 0; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border); + background: var(--bg-surface); color: var(--text-1); cursor: pointer; + font-size: 0.75rem; white-space: nowrap; transition: all var(--transition); +} +.context-warning-action:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.context-warning-action:disabled { opacity: 0.5; cursor: not-allowed; } +.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text-1); border-color: var(--border); } +/* Summary message node */ +.message-summary { + border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent); + border-radius: 8px; padding: 10px 14px; margin: 4px 0; + background: color-mix(in srgb, var(--accent) 6%, transparent); + font-size: 0.85rem; position: relative; +} +.message-summary .summary-header { + display: flex; align-items: center; gap: 6px; margin-bottom: 6px; + font-size: 0.75rem; color: var(--text-3); font-weight: 500; +} +.message-summary .summary-toggle { + background: none; border: none; color: var(--accent); cursor: pointer; + font-size: 0.75rem; padding: 0; text-decoration: underline; +} +.message-summary .summary-toggle:hover { color: var(--text-1); } +.msg-dimmed { opacity: 0.5; } +.message-summary-divider { + text-align: center; padding: 8px 0; margin: 4px 0; +} +.message-summary-divider .summary-toggle { + background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 0.78rem; padding: 4px 12px; border-radius: 4px; + transition: all var(--transition); +} +.message-summary-divider .summary-toggle:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); } +.input-wrap { + max-width: 768px; margin: 0 auto; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 0; + display: flex; align-items: flex-end; + transition: border-color var(--transition); +} +.input-wrap:focus-within { border-color: var(--border-light); } + +.input-wrap textarea { + flex: 1; resize: none; background: none; border: none; + color: var(--text); padding: 12px 0 12px 16px; + font-family: var(--font); font-size: 14px; + max-height: 200px; line-height: 1.5; +} +.input-wrap textarea:focus { outline: none; } +.input-wrap textarea::placeholder { color: var(--text-3); } + +.input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; } + +.action-btn { + background: none; border: none; color: var(--text-3); cursor: pointer; + padding: 6px; border-radius: var(--radius); + transition: background var(--transition), color var(--transition); + display: flex; align-items: center; justify-content: center; +} +.action-btn:hover { background: var(--bg-hover); color: var(--text); } + +.send-btn { color: var(--accent); } +.send-btn:hover { background: var(--accent-dim); color: var(--accent-hover); } +.send-btn:disabled { opacity: 0.3; cursor: not-allowed; } + +.stop-btn { display: none; color: var(--warning); } +.stop-btn.visible { display: flex; } + +/* ── Attachments ─────────────────────────── */ + +.attach-btn { color: var(--text-3); padding: 6px 4px 12px 10px; } +.attach-btn:hover { color: var(--text); } + +.attachment-strip { + max-width: 768px; margin: 0 auto; padding: 6px 8px 2px; + display: flex; gap: 6px; flex-wrap: wrap; +} + +.att-chip { + display: flex; align-items: center; gap: 6px; + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius); padding: 4px 6px; + font-size: 12px; max-width: 220px; position: relative; +} +.att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); } +.att-chip.att-error { border-color: color-mix(in srgb, var(--error) 40%, var(--border)); } +.att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); } + +.att-thumb { + width: 32px; height: 32px; object-fit: cover; + border-radius: 3px; flex-shrink: 0; +} +.att-icon { font-size: 18px; flex-shrink: 0; line-height: 1; } + +.att-info { display: flex; flex-direction: column; min-width: 0; } +.att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 500; } +.att-meta { color: var(--text-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.att-remove { + background: none; border: none; color: var(--text-3); cursor: pointer; + padding: 2px 4px; font-size: 13px; line-height: 1; border-radius: 3px; + flex-shrink: 0; margin-left: auto; +} +.att-remove:hover { background: var(--bg-hover); color: var(--text); } + +/* Drag-and-drop overlay on chat area */ +.messages.drag-over { + outline: 2px dashed var(--accent); + outline-offset: -4px; + background: color-mix(in srgb, var(--accent) 4%, var(--bg)); +} + +/* ── Message Attachments ────────────────────── */ + +.msg-attachments { + display: flex; gap: 8px; flex-wrap: wrap; + margin-top: 8px; +} + +.msg-att-image { + cursor: pointer; position: relative; border-radius: var(--radius); + overflow: hidden; border: 1px solid var(--border); + transition: border-color var(--transition); +} +.msg-att-image:hover { border-color: var(--accent); } +.msg-att-image img { + display: block; max-height: 280px; border-radius: var(--radius); + background: var(--bg-raised); +} +.msg-att-image img.att-load-error { + min-width: 120px; min-height: 60px; + display: flex; align-items: center; justify-content: center; + font-size: 13px; color: var(--text-3); +} + +.att-vision-hint { + position: absolute; top: 4px; right: 4px; + background: color-mix(in srgb, var(--bg) 85%, transparent); + border-radius: 4px; padding: 2px 5px; font-size: 14px; + cursor: help; +} + +.msg-att-doc { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border: 1px solid var(--border); + border-radius: var(--radius); background: var(--bg-raised); + text-decoration: none; color: var(--text); + max-width: 280px; transition: border-color var(--transition); +} +.msg-att-doc:hover { border-color: var(--accent); } +.att-doc-icon { font-size: 20px; flex-shrink: 0; } +.att-doc-info { display: flex; flex-direction: column; min-width: 0; } +.att-doc-name { + font-size: 13px; font-weight: 500; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.att-doc-size { font-size: 11px; color: var(--text-3); } +.att-doc-dl { color: var(--text-3); font-size: 14px; margin-left: auto; } + +/* ── Image Lightbox ─────────────────────────── */ + +.lightbox-overlay { + display: none; position: fixed; inset: 0; z-index: 10000; + background: rgba(0,0,0,0.85); align-items: center; justify-content: center; +} +.lightbox-overlay.active { display: flex; } +.lightbox-overlay img { + max-width: 92vw; max-height: 90vh; object-fit: contain; + border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} +.lightbox-close { + position: absolute; top: 16px; right: 20px; + background: rgba(255,255,255,0.15); border: none; color: #fff; + font-size: 22px; width: 40px; height: 40px; border-radius: 50%; + cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: background 0.15s; +} +.lightbox-close:hover { background: rgba(255,255,255,0.3); } + +/* ── Admin Storage Panel ────────────────────── */ + +.admin-storage-card { + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px; margin-bottom: 12px; +} +.admin-storage-card h4 { margin: 0 0 10px 0; font-size: 14px; } +.admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 10px 0; } +.admin-storage-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 10px; +} +.admin-storage-item { display: flex; flex-direction: column; gap: 2px; } +.admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; } +.admin-storage-value { font-size: 14px; font-weight: 500; } + +/* ── Buttons ─────────────────────────────── */ + +button { font-family: var(--font); cursor: pointer; } +.btn-primary { + background: var(--accent); color: #fff; border: none; + padding: 8px 16px; border-radius: var(--radius); + font-weight: 500; font-size: 13px; + transition: background var(--transition); +} +.btn-primary:hover { background: var(--accent-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-full { width: 100%; } +.btn-small { + background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text-2); padding: 6px 12px; border-radius: var(--radius); + font-size: 12px; transition: all var(--transition); +} +.btn-small:hover { background: var(--bg-hover); color: var(--text); } +.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-danger { background: var(--danger); color: #fff; border: none; border-radius: var(--radius); } + +/* ── Auth Splash ─────────────────────────── */ + +.splash { + display: flex; + min-height: 100vh; + min-height: 100dvh; +} + +/* Hero Panel (left) */ +.splash-hero { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding: 4rem 3.5rem; + position: relative; + overflow: hidden; + background: + radial-gradient(ellipse 80% 60% at 30% 40%, rgba(108,159,255,0.06), transparent), + radial-gradient(ellipse 60% 50% at 70% 70%, rgba(167,139,250,0.05), transparent), + var(--bg); +} +.splash-hero::before { + content: ''; + position: absolute; + inset: -50%; + background-image: + linear-gradient(rgba(108,159,255,0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(108,159,255,0.04) 1px, transparent 1px); + background-size: 48px 48px; + animation: gridDrift 30s linear infinite; + pointer-events: none; +} +@keyframes gridDrift { to { transform: translate(48px, 48px); } } +.splash-hero::after { + content: ''; + position: absolute; + width: 320px; height: 320px; + bottom: -80px; right: -60px; + background: radial-gradient(circle, rgba(167,139,250,0.08), transparent 70%); + border-radius: 50%; + pointer-events: none; +} + +.hero-content { position: relative; z-index: 1; max-width: 520px; } +.hero-logo-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; } +.hero-logo-mark { width: 48px; height: 48px; flex-shrink: 0; } +.hero-logo-img { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; } +.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; } +.hero-wordmark span { color: var(--accent); } + +.hero-headline { + font-size: 2.4rem; font-weight: 700; line-height: 1.15; + letter-spacing: -0.03em; margin-bottom: 1rem; + background: linear-gradient(135deg, var(--text) 0%, var(--text-2) 100%); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; +} +.hero-sub { font-size: 1.05rem; color: var(--text-2); line-height: 1.6; margin-bottom: 2.5rem; max-width: 440px; } + +.hero-features { display: flex; flex-wrap: wrap; gap: 10px; } +.hero-pill { + display: inline-flex; align-items: center; gap: 7px; + padding: 7px 14px; background: var(--bg-surface); + border: 1px solid var(--border); border-radius: 100px; + font-size: 0.8rem; font-weight: 500; color: var(--text-2); + transition: all var(--transition); +} +.hero-pill:hover { border-color: var(--border-light); color: var(--text); } +.hero-pill .pill-icon { font-size: 0.95rem; line-height: 1; } +.hero-pill.accent { border-color: rgba(108,159,255,0.2); color: var(--accent); } +.hero-pill.purple { border-color: rgba(167,139,250,0.2); color: var(--purple); } +.hero-version { margin-top: 3rem; font-size: 0.72rem; font-family: var(--mono); color: var(--text-3); letter-spacing: 0.04em; } + +/* Auth Panel (right) */ +.splash-auth { + width: 440px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + padding: 2rem; background: var(--bg-surface); + border-left: 1px solid var(--border); +} +.auth-card { width: 100%; max-width: 340px; } +.auth-card-header { margin-bottom: 2rem; } +.auth-card-header h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 4px; } +.auth-card-header p { font-size: 0.85rem; color: var(--text-3); } + +.auth-tabs { display: flex; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); } +.auth-tab { + flex: 1; background: none; border: none; color: var(--text-3); + padding: 10px 0; cursor: pointer; font-family: var(--font); + font-size: 0.85rem; font-weight: 500; + border-bottom: 2px solid transparent; + transition: color var(--transition); +} +.auth-tab:hover { color: var(--text-2); } +.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; } +.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; } +.splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; } +.splash-error-hint { color: var(--text-muted); font-size: 0.72rem; } +.splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; } +.auth-actions { margin-top: 1.5rem; } +.auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; } +.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; } + +/* Splash entrance animation */ +.hero-logo-row, .hero-headline, .hero-sub, .hero-features, .hero-version, +.auth-card-header, .splash .auth-tabs, #authLoginForm, .splash .auth-actions { + opacity: 0; transform: translateY(12px); + animation: fadeUp 0.5s ease forwards; +} +.hero-logo-row { animation-delay: 0.05s; } +.hero-headline { animation-delay: 0.12s; } +.hero-sub { animation-delay: 0.19s; } +.hero-features { animation-delay: 0.26s; } +.hero-version { animation-delay: 0.33s; } +.auth-card-header { animation-delay: 0.15s; } +.splash .auth-tabs { animation-delay: 0.22s; } +#authLoginForm { animation-delay: 0.29s; } +.splash .auth-actions { animation-delay: 0.36s; } +@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +/* Splash responsive */ +@media (max-width: 860px) { + .splash { flex-direction: column; min-height: 100vh; min-height: 100dvh; overflow-y: auto; } + .splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; flex: none; } + .hero-headline { font-size: 1.8rem; } + .hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; } + .hero-version { margin-top: 1.5rem; } + .splash-auth { width: 100%; border-left: none; border-top: 1px solid var(--border); padding: 2rem 1.75rem; } + .auth-card { max-width: 400px; } +} +@media (max-width: 480px) { + .splash-hero { padding: 1.5rem 1.25rem 1rem; } + .hero-logo-row { gap: 10px; margin-bottom: 0.75rem; } + .hero-logo-mark { width: 40px; height: 40px; } + .hero-logo-img { width: 40px; height: 40px; } + .hero-wordmark { font-size: 1.3rem; } + .hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; } + .hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; } + .hero-features { gap: 6px; } + .hero-pill { padding: 5px 10px; font-size: 0.72rem; } + .hero-version { margin-top: 1rem; } + .splash-auth { padding: 1.5rem 1.25rem; } + .auth-card-header { margin-bottom: 1rem; } + .auth-card-header h2 { font-size: 1.1rem; } + .auth-actions { margin-top: 1rem; } +} + +/* ── Forms ────────────────────────────────── */ + +.form-group { margin-bottom: 0.75rem; } +.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; } +.form-group input, .form-group select, .form-group textarea { + width: 100%; background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text); padding: 8px 12px; border-radius: var(--radius); + font-family: var(--font); font-size: 13px; + transition: border-color var(--transition); +} +.form-group input:focus, .form-group select:focus, .form-group textarea:focus { + outline: none; border-color: var(--accent); +} +.form-row { display: flex; gap: 0.75rem; } +.form-row .form-group { flex: 1; } +.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; } +.range-input { + width: 100%; height: 4px; -webkit-appearance: none; appearance: none; + background: var(--border); border-radius: 2px; outline: none; + margin: 8px 0 4px; cursor: pointer; +} +.range-input::-webkit-slider-thumb { + -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; + background: var(--accent); border: 2px solid var(--bg-surface); cursor: pointer; +} +.range-input::-moz-range-thumb { + width: 16px; height: 16px; border-radius: 50%; + background: var(--accent); border: 2px solid var(--bg-surface); cursor: pointer; +} +.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); } +.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); } + +/* ── Modal ────────────────────────────────── */ + +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + display: none; align-items: center; justify-content: center; + z-index: 1000; backdrop-filter: blur(2px); + padding: 2rem; /* keeps modal away from viewport edges at any zoom */ +} +.modal-overlay.active { display: flex; } + +.modal { + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius-lg); width: 90%; max-width: 520px; + max-height: 100%; display: flex; flex-direction: column; + animation: modal-in 0.15s ease; +} +.modal-wide { max-width: 700px; } +@keyframes modal-in { from { opacity: 0; transform: translateY(-8px); } } + +.modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px; border-bottom: 1px solid var(--border); +} +.modal-header h2 { font-size: 16px; font-weight: 600; } +.modal-close { + background: none; border: none; color: var(--text-3); font-size: 18px; + cursor: pointer; padding: 2px 6px; border-radius: 4px; + transition: color var(--transition); +} +.modal-close:hover { color: var(--text); } +.modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; } +.modal-footer { + padding: 12px 20px; border-top: 1px solid var(--border); + display: flex; justify-content: flex-end; gap: 0.5rem; +} + +.settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); } +.settings-section:last-child { border-bottom: none; margin-bottom: 0; } +.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; } +.avatar-upload-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1rem; } +.avatar-preview { + width: 56px; height: 56px; border-radius: 50%; background: var(--bg-raised); + display: flex; align-items: center; justify-content: center; + font-size: 22px; font-weight: 600; color: var(--accent); flex-shrink: 0; + overflow: hidden; border: 2px solid var(--border); +} +.avatar-preview img { width: 100%; height: 100%; object-fit: cover; } +.avatar-actions { display: flex; flex-direction: column; gap: 4px; } +.avatar-actions .form-hint { font-size: 11px; margin-left: 0; } +.avatar-preview-sm { width: 42px; height: 42px; font-size: 18px; } + +/* Modal tab bars — shared horizontal scroll with arrow navigation */ +.modal-tabs-wrap { + position: relative; flex-shrink: 0; + border-bottom: 1px solid var(--border); background: var(--bg-raised); +} +.modal-tabs { + display: flex; padding: 0 16px; + border-bottom: 1px solid var(--border); background: var(--bg-raised); + overflow-x: auto; overflow-y: hidden; + scrollbar-width: none; -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + flex-shrink: 0; +} +.modal-tabs::-webkit-scrollbar { display: none; } +/* When wrapped, the wrapper owns the border */ +.modal-tabs-wrap .modal-tabs { border-bottom: none; background: none; } + +.tab-arrow { + position: absolute; top: 0; bottom: 1px; /* 1px for border */ width: 28px; + display: flex; align-items: center; justify-content: center; + background: var(--bg-raised); border: none; color: var(--text-2); + cursor: pointer; font-size: 16px; z-index: 2; opacity: 0; + pointer-events: none; transition: opacity 0.15s ease; +} +.tab-arrow.visible { opacity: 1; pointer-events: auto; } +.tab-arrow:hover { color: var(--accent); } +.tab-arrow-left { left: 0; padding-left: 2px; box-shadow: 4px 0 8px -2px rgba(0,0,0,0.15); } +.tab-arrow-right { right: 0; padding-right: 2px; box-shadow: -4px 0 8px -2px rgba(0,0,0,0.15); } + +/* Settings tabs */ +.settings-tabs { gap: 0; } +.settings-tabs .settings-tab { + padding: 10px 14px; background: none; border: none; border-radius: 0; + color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font); + border-bottom: 2px solid transparent; transition: color var(--transition); + outline: none; -webkit-appearance: none; appearance: none; + white-space: nowrap; flex-shrink: 0; +} +.settings-tabs .settings-tab:hover { color: var(--text-2); } +.settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +.settings-notice { + background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.2); + border-radius: var(--radius); padding: 10px 14px; font-size: 13px; + color: var(--warning); display: flex; align-items: center; gap: 8px; + margin-top: 1rem; +} + +/* Providers */ +.provider-row { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 0; border-bottom: 1px solid var(--border); +} +.provider-name { font-weight: 500; font-size: 13px; } +.provider-meta { font-size: 11px; color: var(--text-3); margin-left: 8px; } +.provider-actions { display: flex; align-items: center; gap: 8px; } +.badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; } + +/* Admin tabs */ +.admin-tabs { } +.admin-tab { + padding: 10px 14px; background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; + white-space: nowrap; flex-shrink: 0; transition: color var(--transition); + outline: none; +} +.admin-tab:hover { color: var(--text-2); } +.admin-tab:focus { color: var(--text-3); } +.admin-tab.active, .admin-tab.active:focus { color: var(--accent); border-bottom-color: var(--accent); } + +.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; } +.admin-hint { font-size: 12px; color: var(--text-3); } +.admin-inline-form { + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); + padding: 1rem; margin-bottom: 1rem; +} +.ext-edit-form { + background: var(--bg); padding: 12px 0; +} +.ext-edit-form textarea { + resize: vertical; min-height: 60px; + line-height: 1.5; white-space: pre; overflow-wrap: normal; overflow-x: auto; +} + +.admin-user-row { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; + min-height: 52px; +} +.admin-user-info { flex: 1; } +.admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; } +.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; } +.admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; } +.admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); } +.admin-user-actions .btn-danger { color: var(--danger); } +.admin-user-actions .btn-danger:hover { border-color: var(--danger); } +.inline-select { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 3px 8px; font-size: 12px; cursor: pointer; } + +.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; } +.admin-model-row .model-name { flex: 1; font-weight: 500; } +.admin-model-row .model-caps-inline { display: flex; gap: 3px; } +.admin-model-row .provider-meta { min-width: 80px; text-align: right; color: var(--text-3); font-size: 12px; } +.admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; } +.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); } +.admin-model-toggle.enabled { border-color: var(--success); color: var(--success); } +.admin-model-toggle.team { border-color: #60a5fa; color: #60a5fa; } +.model-list-item.model-hidden { opacity: 0.5; } +.model-list-item.model-hidden .model-name { text-decoration: line-through; } + +.admin-provider-row { + display: flex; align-items: center; gap: 12px; padding: 10px 0; + border-bottom: 1px solid var(--border); font-size: 14px; +} +.admin-provider-row .provider-name { font-weight: 500; flex: 1; } +.admin-provider-row .provider-endpoint { font-size: 12px; color: var(--text-3); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.btn-edit { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } +.btn-edit:hover { color: var(--accent); } +.admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } +.admin-provider-row .btn-delete:hover { color: var(--danger); } + +.admin-preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; } +.admin-preset-row .preset-info { flex: 1; min-width: 0; } +.preset-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; } +.preset-row-icon { margin-right: 4px; } +.admin-preset-row .preset-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; } +.admin-preset-row .preset-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } +.admin-preset-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } +.admin-preset-row .btn-delete:hover { color: var(--danger); } +.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-bg); color: var(--accent); } + +/* Stats cards */ +.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } +.stat-card { + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); + padding: 14px 16px; text-align: center; +} +.stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; } +.stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; } + +/* Audit log */ +.audit-row { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; } +.audit-row:last-child { border-bottom: none; } +.audit-main { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.audit-action { font-weight: 600; color: var(--accent); font-family: var(--font-mono, monospace); font-size: 12px; } +.audit-actor { color: var(--text); } +.audit-resource { color: var(--text-3); font-family: var(--font-mono, monospace); font-size: 11px; } +.audit-meta { display: flex; gap: 10px; margin-top: 2px; font-size: 11px; color: var(--text-3); flex-wrap: wrap; } +.audit-details { color: var(--text-2); } +.audit-ip { font-family: var(--font-mono, monospace); } +.pagination-row { display: flex; align-items: center; justify-content: center; gap: 12px; } + +/* Banner preview */ +.banner-preview { + margin-top: 0.75rem; padding: 6px 16px; text-align: center; + font-size: 12px; font-weight: 700; letter-spacing: 0.06em; + border-radius: var(--radius); +} +.color-input-wrap { display: flex; align-items: center; gap: 8px; } +.color-input-wrap input[type="color"] { + width: 36px; height: 32px; border: 1px solid var(--border); + border-radius: var(--radius); background: var(--bg); + cursor: pointer; padding: 2px; +} +.color-hex { + width: 80px; background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text); padding: 6px 10px; border-radius: var(--radius); + font-family: var(--mono); font-size: 12px; +} +.section-hint { font-size: 13px; color: var(--text-3); margin-bottom: 0.75rem; } + +/* User model list */ +.model-list-grid { display: flex; flex-direction: column; gap: 2px; } +.model-list-item { + display: flex; align-items: center; gap: 10px; padding: 8px 10px; + border-radius: var(--radius); font-size: 13px; cursor: default; +} +.model-list-item:hover { background: var(--bg-hover); } +.model-list-item .model-name { flex: 1; font-weight: 500; } +.model-list-item .model-provider { font-size: 12px; color: var(--text-3); } +.model-list-item .model-caps-inline { display: flex; gap: 3px; } + +.badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-team { background: rgba(59,130,246,0.2); color: #93c5fd; font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; } +.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: rgba(255,255,255,0.03); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); } +.admin-approve-form .checkbox-label { display: block; margin: 2px 0; } +.btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; } +.team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; } +.team-card-info { display: flex; align-items: center; gap: 8px; } +.team-admin-back { + cursor: pointer; margin-right: 4px; opacity: 0.5; + transition: opacity var(--transition); +} +.team-admin-back:hover { opacity: 1; } +.team-tab-content { padding: 0; } +.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-success { background: rgba(34,197,94,0.2); color: #4ade80; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-warning { background: rgba(234,179,8,0.2); color: #fbbf24; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.admin-user-row.user-inactive { opacity: 0.7; } +.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; } +.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; } +.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; } + +/* ── Toast ────────────────────────────────── */ + +.toast-container { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 10000; display: flex; flex-direction: column; gap: 0.5rem; } +.toast { + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 8px 16px; font-size: 13px; + display: flex; align-items: center; gap: 8px; + animation: toast-in 0.25s ease; box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} +.toast.error { border-color: var(--danger); } +.toast.warning { border-color: var(--warning); } +.toast.success { border-color: var(--success); } +.toast button { background: none; border: none; color: var(--text-3); cursor: pointer; } +@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } + +/* ── Debug Modal ─────────────────────────── */ + +.debug-modal { max-width: 900px; height: 100%; overflow: hidden; } +.debug-tabs { } +.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; } +.debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; display: flex; flex-direction: column; } +.debug-tab-content { flex: 1; display: flex; flex-direction: column; min-height: 0; } +.debug-toolbar { display: flex; gap: 1rem; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 11px; } +.debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; } +.debug-content { flex: 1; overflow-y: auto; padding: 6px; font-family: var(--mono); font-size: 11px; line-height: 1.5; } +.debug-entry { padding: 1px 6px; border-bottom: 1px solid rgba(128,128,128,0.06); white-space: pre-wrap; word-break: break-all; } +.debug-time { color: var(--text-3); opacity: 0.6; font-size: 10px; } +.debug-msg { color: var(--text); } +.debug-empty { color: var(--text-3); text-align: center; padding: 2rem; } +.debug-net-entry { border-bottom: 1px solid var(--border); } +.debug-net-entry summary { padding: 6px; cursor: pointer; font-size: 11px; } +.debug-net-method { font-weight: bold; color: var(--accent); } +.debug-net-url { color: var(--text); word-break: break-all; } +.debug-net-detail { padding: 6px 12px; font-size: 11px; } +.debug-pre { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; } +.debug-state-pre { max-height: none; margin: 0; } +.debug-footer { display: flex; align-items: center; } + +/* ── Sidebar Notes Button ───────────────── */ + +.sidebar-notes-btn { + padding: 4px 8px; border-top: 1px solid var(--border); + flex-shrink: 0; +} +.sb-notes { + display: flex; align-items: center; gap: 8px; width: 100%; + padding: 8px 12px; background: none; border: none; + border-radius: var(--radius); color: var(--text-2); + cursor: pointer; font-size: 13px; font-family: var(--font); + transition: background var(--transition), color var(--transition); +} +.sb-notes:hover { background: var(--bg-hover); color: var(--text); } +.sidebar.collapsed .sidebar-notes-btn .sb-label { display: none; } +.sidebar.collapsed .sb-notes { justify-content: center; padding: 8px; } + +/* ── Notes (inside side panel) ──────────── */ + +/* Notes toolbar and selection bar now live inside the side panel */ + +.notes-toolbar { + display: flex; gap: 8px; padding: 8px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); +} +.notes-search-wrap { + display: flex; align-items: center; gap: 6px; flex: 1; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 0 10px; +} +.notes-search-wrap svg { color: var(--text-3); flex-shrink: 0; } +.notes-search-wrap input { + flex: 1; background: none; border: none; color: var(--text); + font-size: 13px; font-family: var(--font); padding: 6px 0; + outline: none; +} +.notes-filter-select { + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); color: var(--text); font-size: 12px; + padding: 4px 8px; font-family: var(--font); cursor: pointer; + max-width: 160px; +} + +/* Notes toolbar and selection bar now live inside the side panel */ + +/* Selection bar */ +.notes-selection-bar { + display: flex; align-items: center; gap: 10px; + padding: 6px 12px; + background: var(--accent-dim); border-bottom: 1px solid var(--border); + font-size: 12px; +} +.notes-select-all { + display: flex; align-items: center; gap: 6px; + color: var(--text-2); cursor: pointer; font-size: 12px; +} +.notes-select-all input { cursor: pointer; } + +.notes-list { padding: 8px; } +.notes-empty, .notes-loading { + text-align: center; color: var(--text-3); padding: 2rem; + font-size: 13px; +} + +.note-item { + display: flex; align-items: flex-start; gap: 8px; + padding: 10px 12px; border-radius: var(--radius); + cursor: pointer; border: 1px solid transparent; + transition: background var(--transition), border-color var(--transition); +} +.note-item:hover { + background: var(--bg-hover); border-color: var(--border); +} +.note-item.selected { + background: var(--accent-dim); border-color: var(--accent); +} +.note-select-col { flex-shrink: 0; padding-top: 2px; } +.note-checkbox { cursor: pointer; accent-color: var(--accent); } +.note-content-col { flex: 1; min-width: 0; } +.note-item-header { + display: flex; align-items: baseline; justify-content: space-between; + gap: 8px; margin-bottom: 2px; +} +.note-item-title { + font-size: 13px; font-weight: 600; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.note-item-time { font-size: 11px; color: var(--text-3); flex-shrink: 0; } +.note-preview { + font-size: 12px; color: var(--text-3); line-height: 1.4; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-height: 1.4em; +} +.note-headline { + font-size: 12px; color: var(--text-3); line-height: 1.4; +} +.note-headline mark { + background: var(--accent-dim); color: var(--accent); + padding: 0 2px; border-radius: 2px; +} +.note-item-meta { + display: flex; align-items: center; gap: 4px; + margin-top: 4px; flex-wrap: wrap; +} +.note-folder { + font-size: 11px; color: var(--text-3); font-family: var(--mono); + background: var(--bg-raised); padding: 1px 6px; border-radius: 3px; +} +.note-tag { + font-size: 10px; color: var(--purple); background: var(--purple-dim); + padding: 1px 6px; border-radius: 3px; +} + +/* Notes editor */ +.notes-editor { padding: 12px; } +.notes-editor-toolbar { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 8px; +} + +/* Note read mode */ +.note-read-title { + font-size: 18px; font-weight: 600; color: var(--text); + margin: 0 0 6px; line-height: 1.3; +} +.note-read-meta { + display: flex; align-items: center; gap: 4px; flex-wrap: wrap; + margin-bottom: 12px; +} +.note-read-content { + font-size: var(--msg-font, 14px); line-height: 1.7; + max-height: 50vh; overflow-y: auto; + padding: 12px; background: var(--bg-surface); + border: 1px solid var(--border); border-radius: var(--radius); +} +.notes-back-btn { + background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 12px; font-family: var(--font); + padding: 4px 0; margin-bottom: 8px; + transition: color var(--transition); +} +.notes-back-btn:hover { color: var(--text); } +.notes-title-input { + font-size: 16px !important; font-weight: 600; + border: 1px solid var(--border) !important; + padding: 8px 10px !important; +} +.notes-folder-input, .notes-tags-input { + font-size: 12px !important; font-family: var(--mono); +} +.notes-content-input { + font-size: 13px !important; line-height: 1.6 !important; + min-height: 200px; resize: vertical; + font-family: var(--mono); border: 1px solid var(--border) !important; +} +.notes-editor input, .notes-editor textarea { + width: 100%; background: var(--bg-surface); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 10px; font-family: var(--font); outline: none; + transition: border-color var(--transition); +} +.notes-editor input:focus, .notes-editor textarea:focus { + border-color: var(--accent); +} +.notes-editor-actions { + display: flex; gap: 8px; margin-top: 12px; +} + +/* ── Sidebar Search ──────────────────────── */ + +.sidebar-search { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; margin: 4px 6px; + background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius); transition: border-color var(--transition); +} +.sidebar-search:focus-within { border-color: var(--accent); } +.sidebar-search svg { color: var(--text-3); flex-shrink: 0; } +.sidebar-search input { + flex: 1; background: none; border: none; color: var(--text); + font-size: 12px; font-family: var(--font); outline: none; + min-width: 0; +} +.sidebar-search input::placeholder { color: var(--text-3); } +.sidebar-search-clear { + background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 11px; padding: 2px 4px; border-radius: 4px; + transition: color var(--transition); display: none; +} +.sidebar-search-clear:hover { color: var(--text); } +.sidebar-search.has-value .sidebar-search-clear { display: block; } +.sidebar.collapsed .sidebar-search { display: none; } + +.sidebar-search-empty { + color: var(--text-3); font-size: 12px; text-align: center; + padding: 1.5rem 0.5rem; +} + +/* ── Command Palette ─────────────────────── */ + +.cmd-palette-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.5); + display: none; align-items: flex-start; justify-content: center; + z-index: 2000; backdrop-filter: blur(4px); + padding-top: min(20vh, 160px); +} +.cmd-palette-overlay.active { display: flex; } + +.cmd-palette { + background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-lg); width: 90%; max-width: 480px; + box-shadow: 0 16px 64px rgba(0,0,0,0.6); + animation: modal-in 0.12s ease; + overflow: hidden; +} + +.cmd-input-wrap { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; border-bottom: 1px solid var(--border); +} +.cmd-input-wrap svg { color: var(--text-3); flex-shrink: 0; } +.cmd-input-wrap input { + flex: 1; background: none; border: none; color: var(--text); + font-size: 15px; font-family: var(--font); outline: none; +} +.cmd-input-wrap input::placeholder { color: var(--text-3); } +.cmd-kbd { + font-size: 10px; font-family: var(--mono); color: var(--text-3); + background: var(--bg); border: 1px solid var(--border); + border-radius: 4px; padding: 2px 6px; flex-shrink: 0; +} + +.cmd-results { + max-height: 320px; overflow-y: auto; + padding: 4px; +} + +.cmd-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: var(--radius); + cursor: pointer; font-size: 13px; color: var(--text-2); + transition: background 60ms ease, color 60ms ease; +} +.cmd-item:hover, .cmd-item.active { + background: var(--accent-dim); color: var(--text); +} +.cmd-item svg { flex-shrink: 0; color: var(--text-3); } +.cmd-item.active svg { color: var(--accent); } +.cmd-item-label { flex: 1; } +.cmd-item-hint { + font-size: 11px; color: var(--text-3); flex-shrink: 0; +} +.cmd-item.active .cmd-item-hint { color: var(--accent); } +.cmd-group-label { + font-size: 10px; font-weight: 600; color: var(--text-3); + padding: 8px 12px 4px; text-transform: uppercase; letter-spacing: 0.5px; +} + +.cmd-footer { + display: flex; align-items: center; gap: 16px; + padding: 8px 16px; border-top: 1px solid var(--border); + font-size: 11px; color: var(--text-3); +} +.cmd-footer kbd { + font-size: 10px; font-family: var(--mono); + background: var(--bg); border: 1px solid var(--border); + border-radius: 3px; padding: 1px 4px; +} + +/* ── PWA Install Banner ──────────────────── */ + +.pwa-install-banner { + position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); + background: var(--bg-raised); border: 1px solid var(--border-light); + border-radius: var(--radius-lg); padding: 10px 16px; + display: flex; align-items: center; gap: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 3000; + font-size: 13px; color: var(--text-2); + animation: pwa-banner-in 0.3s ease; + max-width: 90vw; +} +@keyframes pwa-banner-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } } + +/* ── Confirm Dialog ──────────────────────── */ + +.confirm-overlay { + position: fixed; inset: 0; z-index: 9999; + display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.55); + animation: fade-in 0.12s ease; +} +.confirm-dialog { + background: var(--bg-raised); border: 1px solid var(--border-light); + border-radius: var(--radius-lg); padding: 0; + min-width: 320px; max-width: 440px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + animation: confirm-in 0.15s ease; +} +.confirm-header { + padding: 16px 20px 0; font-weight: 600; font-size: 15px; color: var(--text); +} +.confirm-body { + padding: 12px 20px 20px; font-size: 13px; color: var(--text-2); + line-height: 1.5; white-space: pre-wrap; +} +.confirm-footer { + display: flex; justify-content: flex-end; gap: 8px; + padding: 0 20px 16px; +} +@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } } +@keyframes fade-in { from { opacity: 0; } } + +/* ── Popup Menu (shared base) ───────────── */ + +.popup-menu { + display: none; position: absolute; z-index: 200; + background: var(--bg-raised); border: 1px solid var(--border-light); + border-radius: var(--radius); padding: 4px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + min-width: 160px; +} +.popup-menu.open { display: block; } +.popup-menu-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: var(--radius); + background: none; border: none; color: var(--text-2); + cursor: pointer; font-size: 13px; width: 100%; + transition: background var(--transition), color var(--transition); +} +.popup-menu-item:hover { background: var(--bg-hover); color: var(--text); } +.popup-menu-item.disabled { opacity: 0.4; pointer-events: none; } +.popup-menu-danger:hover { color: var(--danger); } +.popup-menu-icon { flex-shrink: 0; display: flex; align-items: center; } +.popup-menu-hint { margin-left: auto; font-size: 11px; color: var(--text-3); } +.popup-menu-divider { height: 1px; background: var(--border); margin: 4px 8px; } + +/* ── Responsive ──────────────────────────── */ + +.mobile-menu-btn { + display: none; align-items: center; justify-content: center; + background: none; border: none; color: var(--text-2); + cursor: pointer; padding: 4px; border-radius: var(--radius); + transition: color var(--transition); +} +.mobile-menu-btn:hover { color: var(--text); } +.sidebar-overlay { + display: none; position: fixed; inset: 0; z-index: 99; + background: rgba(0,0,0,0.5); +} + +@media (max-width: 768px) { + .sidebar { position: fixed; z-index: 100; height: 100%; transition: width 0.2s ease; } + .sidebar.collapsed { width: 0; border: none; overflow: hidden; } + .msg-inner { padding: 0 1rem; } + .model-caps { display: none; } + .input-area { padding: 0 0.5rem 0.5rem; } + .input-wrap textarea { font-size: 16px; } /* prevent iOS zoom */ + .mobile-menu-btn { display: flex; } + .modal-overlay { padding: 0.75rem; } + .modal { width: 100%; } + .tab-arrow { width: 36px; font-size: 20px; } /* larger touch target */ + .modal-tabs { padding: 0 12px; } +} diff --git a/ui-admin.js b/ui-admin.js new file mode 100644 index 0000000..78af8c4 --- /dev/null +++ b/ui-admin.js @@ -0,0 +1,727 @@ +// ========================================== +// Chat Switchboard – UI Admin +// ========================================== +// Extends UI with admin modal tabs: users, stats, roles, +// usage, audit, providers, models, presets, teams, settings. + +Object.assign(UI, { + // ── Admin Modal ────────────────────────── + + openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); }, + closeAdmin() { closeModal('adminModal'); }, + + openTeamAdmin() { + const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin'); + if (adminTeams.length === 0) { + UI.toast('You are not an admin of any teams', 'error'); + return; + } + openModal('teamAdminModal'); + if (adminTeams.length === 1) { + // Skip picker, go directly to the team + UI.openTeamManage(adminTeams[0].id, adminTeams[0].name); + } else { + // Show team picker + document.getElementById('teamAdminTitle').textContent = 'Team Management'; + document.getElementById('teamAdminPicker').style.display = ''; + document.getElementById('teamAdminContent').style.display = 'none'; + document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => ` +
+
+ ${esc(t.name)} + admin + Manage → +
+ ${t.description ? `
${esc(t.description)}
` : ''} +
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
+
+ `).join(''); + } + }, + closeTeamAdmin() { closeModal('teamAdminModal'); }, + + switchTeamTab(tab) { + const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab'); + tabs.forEach(t => { + t.classList.remove('active'); + if (t.dataset.ttab === tab) t.classList.add('active'); + }); + document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none'); + const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`); + if (panel) panel.style.display = ''; + + // Lazy-load tab data + const teamId = UI._managingTeamId; + if (tab === 'members') UI.loadTeamManageMembers(teamId); + if (tab === 'providers') UI.loadTeamManageProviders(teamId); + if (tab === 'presets') UI.loadTeamManagePresets(teamId); + if (tab === 'usage') UI.loadTeamUsage(); + if (tab === 'activity') UI.loadTeamAuditLog(1); + }, + + async switchAdminTab(tab) { + document.querySelectorAll('#adminModal .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); + document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none'); + const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`); + if (panel) panel.style.display = ''; + + if (tab === 'users') await this.loadAdminUsers(); + if (tab === 'stats') await this.loadAdminStats(); + if (tab === 'audit') await this.loadAuditLog(); + if (tab === 'providers') await this.loadAdminProviders(); + if (tab === 'models') await this.loadAdminModels(); + if (tab === 'presets') await this.loadAdminPresets(); + if (tab === 'teams') await this.loadAdminTeams(); + if (tab === 'settings') await this.loadAdminSettings(); + if (tab === 'roles') await this.loadAdminRoles(); + if (tab === 'usage') await this.loadAdminUsage(); + if (tab === 'extensions') await this.loadAdminExtensions(); + if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage(); + }, + + async loadAdminUsers(quiet) { + const el = document.getElementById('adminUserList'); + if (!quiet) el.innerHTML = '
Loading...
'; + try { + const resp = await API.adminListUsers(); + const users = resp.users || resp.data || []; + el.innerHTML = users.map(u => { + const teamBadges = (u.teams || []).map(t => + `${esc(t.team_name)}` + ).join(' '); + const isPending = !u.is_active; + return ` +
+ + +
+ `; + }).join('') || '
No users
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + async loadAdminStats() { + const el = document.getElementById('adminStats'); + el.innerHTML = '
Loading...
'; + try { + const s = await API.adminGetStats(); + const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' }; + el.innerHTML = '
' + + Object.entries(s).map(([k, v]) => ` +
+
${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}
+
${labels[k] || k.replace(/_/g, ' ')}
+
`).join('') + + '
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + // ── Admin: Roles ──────────────────────── + + async loadAdminRoles() { + const el = document.getElementById('adminRolesContent'); + if (!el) return; + + if (!UI._adminRoleConfig) { + UI._adminRoleConfig = renderRoleConfig(el, { + scope: 'admin', + showFallback: true, + modelIdField: 'model_id', + modelNameField: 'display_name', + providerIdField: 'provider_config_id', + fetchProviders: async () => { + const configs = await API.adminListGlobalConfigs(); + return configs.configs || configs || []; + }, + fetchModels: async () => { + const models = await API.adminListModels(); + return models.models || models.data || models || []; + }, + fetchRoles: () => API.adminListRoles(), + onSave: async (roleId, config) => { + await API.adminUpdateRole(roleId, config); + }, + onTest: (roleId) => API.adminTestRole(roleId), + }); + } + + await UI._adminRoleConfig.refresh(); + }, + + // ── Admin: Usage ──────────────────────── + + async loadAdminUsage() { + // Usage stats via primitive + const totalsEl = document.getElementById('adminUsageTotals'); + if (!totalsEl) return; + // Wrap totals+results in a container for the primitive (first time only) + let usageContainer = document.getElementById('_adminUsageContainer'); + if (!usageContainer) { + usageContainer = document.createElement('div'); + usageContainer.id = '_adminUsageContainer'; + const resultsEl = document.getElementById('adminUsageResults'); + totalsEl.parentElement.insertBefore(usageContainer, totalsEl); + usageContainer.appendChild(totalsEl); + if (resultsEl) usageContainer.appendChild(resultsEl); + } + + if (!UI._adminUsageDash) { + UI._adminUsageDash = renderUsageDashboard(usageContainer, { + apiFetch: (p) => API.adminGetUsage(p), + periodElId: 'usagePeriod', + groupByElId: 'usageGroupBy', + compact: false, + showUserColumn: true, + }); + } + await UI._adminUsageDash.refresh(); + + // Pricing table (admin-only, separate from usage primitive) + const pricingEl = document.getElementById('adminPricingTable'); + if (pricingEl) { + try { + const pricing = await API.adminListPricing(); + const entries = pricing || []; + if (entries.length === 0) { + pricingEl.innerHTML = '
No pricing configured. Sync providers to populate from catalog.
'; + } else { + pricingEl.innerHTML = ` + + + + + + + + ${entries.map(p => ` + + + + + `).join('')} +
ModelInput $/MOutput $/MSource
${esc(p.model_id)}${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}${p.source}
`; + } + } catch (e) { pricingEl.innerHTML = `
${esc(e.message)}
`; } + } + }, + + async loadAdminExtensions() { + const el = document.getElementById('adminExtensionsList'); + el.innerHTML = '
Loading...
'; + try { + const resp = await API._get('/api/v1/admin/extensions'); + const exts = resp.data || []; + this._adminExtensions = exts; // store for edit access + if (exts.length === 0) { + el.innerHTML = '
No extensions installed
'; + return; + } + el.innerHTML = exts.map(ext => { + const statusBadge = ext.is_enabled + ? 'enabled' + : 'disabled'; + const systemBadge = ext.is_system + ? ' system' + : ''; + return ` +
+ +
+ + + +
+
`; + }).join(''); + } catch (e) { + el.innerHTML = '
Failed to load extensions
'; + } + }, + + _auditPage: 1, + _auditPerPage: 30, + + async loadAuditLog(page) { + if (page) UI._auditPage = page; + const el = document.getElementById('adminAuditList'); + el.innerHTML = '
Loading...
'; + + // Populate action filter on first load + const actionSel = document.getElementById('auditFilterAction'); + if (actionSel && actionSel.options.length <= 1) { + try { + const resp = await API.adminListAuditActions(); + (resp.actions || []).forEach(a => { + const opt = document.createElement('option'); + opt.value = a; + opt.textContent = a; + actionSel.appendChild(opt); + }); + } catch (e) { /* optional */ } + } + + const params = { + page: UI._auditPage, + per_page: UI._auditPerPage, + action: document.getElementById('auditFilterAction')?.value || '', + resource_type: document.getElementById('auditFilterResource')?.value || '', + }; + + try { + const resp = await API.adminListAudit(params); + const entries = resp.data || []; + const total = resp.total || 0; + const totalPages = Math.ceil(total / UI._auditPerPage); + + if (entries.length === 0) { + el.innerHTML = '
No audit entries found
'; + } else { + el.innerHTML = entries.map(e => { + const ts = new Date(e.created_at); + const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); + const actor = e.actor_name || 'system'; + const meta = UI._formatAuditMeta(e.metadata); + return `
+
+ ${esc(e.action)} + ${esc(actor)} + ${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''} +
+
+ ${meta ? `${meta}` : ''} + ${timeStr} + ${e.ip_address ? `${esc(e.ip_address)}` : ''} +
+
`; + }).join(''); + } + + // Pagination + const pgEl = document.getElementById('auditPagination'); + if (totalPages > 1) { + pgEl.style.display = 'flex'; + document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`; + document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1; + document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages; + } else { + pgEl.style.display = 'none'; + } + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + _formatAuditMeta(metaStr) { + try { + const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr; + if (!m || Object.keys(m).length === 0) return ''; + return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · '); + } catch { return ''; } + }, + + async loadAdminProviders(quiet) { + const el = document.getElementById('adminProviderList'); + const formEl = document.getElementById('adminAddProviderForm'); + + // Initialize admin provider form primitive (once) + if (!UI._adminProvForm && formEl) { + UI._adminProvForm = renderProviderForm(formEl, { + prefix: 'adminProv', + showPrivate: true, + showDefaultModel: true, + onSubmit: async (vals, isEdit) => { + try { + if (isEdit) { + const updates = {}; + if (vals.name) updates.name = vals.name; + if (vals.endpoint) updates.endpoint = vals.endpoint; + if (vals.api_key) updates.api_key = vals.api_key; + updates.model_default = vals.model_default; + updates.is_private = vals.is_private || false; + await API.adminUpdateGlobalConfig(vals._editId, updates); + UI.toast('Provider updated', 'success'); + } else { + if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; } + await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false); + UI.toast('Provider added', 'success'); + } + formEl.style.display = 'none'; + UI._adminProvForm.setCreateMode(); + UI._adminProvList.refresh(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + onCancel: () => { + formEl.style.display = 'none'; + UI._adminProvForm.setCreateMode(); + }, + }); + } + + // Initialize admin provider list primitive (once) + if (!UI._adminProvList) { + UI._adminProvList = renderProviderList(el, { + apiFetch: () => API.adminListGlobalConfigs(), + showEndpoint: true, + showPrivate: true, + emptyMsg: 'No global providers — add one above', + onEdit: (prov) => { + if (UI._adminProvForm) { + UI._adminProvForm.setEditMode(prov.id, prov); + formEl.style.display = ''; + } + }, + onDelete: async (prov) => { + if (!await showConfirm('Delete this global provider?')) return; + try { + await API.adminDeleteGlobalConfig(prov.id); + UI.toast('Provider deleted', 'success'); + UI._adminProvList.refresh(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + }); + } + + await UI._adminProvList.refresh(quiet); + }, + + async loadAdminModels(quiet) { + const el = document.getElementById('adminModelList'); + if (!quiet) el.innerHTML = '
Loading...
'; + try { + const data = await API.adminListModels(); + const list = data.models || data.data || data || []; + const arr = Array.isArray(list) ? list : []; + el.innerHTML = arr.map(m => { + const caps = m.capabilities || {}; + const badges = renderCapBadges(caps, { compact: true }); + // Support both old is_enabled (bool) and new visibility (string) + const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled'); + const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled'; + const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : ''; + return `
+ ${esc(m.model_id || m.id)} + ${badges} + ${esc(m.provider_name || '')} + +
`; + }).join('') || '
No models — add a provider first, then click Fetch Models
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + async loadAdminPresets(quiet) { + const el = document.getElementById('adminPresetList'); + if (!quiet) el.innerHTML = '
Loading...
'; + try { + const data = await API.adminListPresets(); + const list = data.presets || []; + + // Ensure form is initialized for dropdown population + ensureAdminPresetForm(); + + // Populate model dropdown from admin model list (all synced models) + const modelSel = _adminPresetForm?.getModelSelect(); + if (modelSel) { + modelSel.innerHTML = ''; + try { + const modelData = await API.adminListModels(); + const allModels = modelData.models || modelData.data || []; + const arr = Array.isArray(allModels) ? allModels : []; + arr.forEach(m => { + const opt = document.createElement('option'); + const mid = m.model_id || m.id; + opt.value = mid; + opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : ''); + modelSel.appendChild(opt); + }); + } catch (e) { console.warn('Failed to load models for preset form:', e.message); } + } + + // Populate config dropdown + const cfgSel = _adminPresetForm?.getConfigSelect(); + if (cfgSel && cfgSel.options.length <= 1) { + try { + const cfgData = await API.adminListGlobalConfigs(); + const cfgs = cfgData.configs || cfgData.data || []; + (Array.isArray(cfgs) ? cfgs : []).forEach(c => { + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.name + ' (' + c.provider + ')'; + cfgSel.appendChild(opt); + }); + } catch (e) { /* optional */ } + } + + UI._presetCache = {}; + el.innerHTML = list.map(p => { + UI._presetCache[p.id] = p; + const avatarEl = p.avatar + ? `` + : ''; + // Scope badge with attribution + let scopeBadge = ''; + if (p.scope === 'global') { + scopeBadge = 'global'; + } else if (p.scope === 'team' && p.team_name) { + scopeBadge = `👥 ${esc(p.team_name)}`; + } else if (p.scope === 'personal') { + scopeBadge = 'personal'; + } + const creator = p.creator_name ? `by ${esc(p.creator_name)}` : ''; + const status = p.is_active ? '' : 'inactive'; + return `
+
+ ${avatarEl}${esc(p.name)} ${scopeBadge} ${creator} ${status} +
${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}
+ ${p.description ? `
${esc(p.description)}
` : ''} +
+
+ + + +
+
`; + }).join('') || '
No presets — create one to give users preconfigured model experiences
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + // ── Teams Admin ───────────────────────── + _teamEditId: null, + + async loadAdminTeams(quiet) { + const el = document.getElementById('adminTeamList'); + const detail = document.getElementById('adminTeamDetail'); + if (!quiet) { + el.innerHTML = '
Loading...
'; + detail.style.display = 'none'; + el.style.display = ''; + document.getElementById('adminAddTeamForm').style.display = 'none'; + } + try { + const resp = await API.adminListTeams(); + const teams = resp.data || []; + el.innerHTML = teams.map(t => ` +
+ + +
+ `).join('') || '
No teams yet — create one to organize users and set policies
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + async openTeamDetail(teamId, teamName) { + this._teamEditId = teamId; + document.getElementById('adminTeamList').style.display = 'none'; + document.getElementById('adminAddTeamForm').style.display = 'none'; + const detail = document.getElementById('adminTeamDetail'); + detail.style.display = ''; + document.getElementById('adminTeamDetailName').textContent = teamName; + document.getElementById('adminAddMemberForm').style.display = 'none'; + + // Load team settings for policy checkboxes + try { + const team = await API.adminGetTeam(teamId); + const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {}); + document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers; + // allow_team_providers defaults to true if not explicitly set + document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false; + } catch (e) { /* proceed with defaults */ } + + await this.loadTeamMembers(teamId); + }, + + async loadTeamMembers(teamId) { + const el = document.getElementById('adminMemberList'); + el.innerHTML = '
Loading...
'; + try { + const resp = await API.adminListMembers(teamId); + const members = resp.data || []; + + el.innerHTML = members.map(m => ` +
+ + +
+ `).join('') || '
No members — add users to this team
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + async loadMemberUserDropdown(teamId) { + const sel = document.getElementById('adminMemberUser'); + sel.innerHTML = ''; + try { + const resp = await API.adminListUsers(1, 200); + const users = resp.users || resp.data || []; + // Also get existing members to exclude + const memberResp = await API.adminListMembers(teamId); + const existingIds = new Set((memberResp.data || []).map(m => m.user_id)); + const available = users.filter(u => u.is_active && !existingIds.has(u.id)); + sel.innerHTML = '' + + available.map(u => ``).join(''); + } catch (e) { sel.innerHTML = ``; } + }, + + async loadAdminSettings() { + try { + const data = await API.adminGetSettings(); + // v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } } + const settings = data.settings || {}; + const policies = data.policies || {}; + + // Helper to read from settings map (JSONB values) + const getSetting = (key, fallback) => { + const v = settings[key]; + if (v === undefined || v === null) return fallback; + // Unwrap {value: X} wrapper if present + return (v && typeof v === 'object' && 'value' in v) ? v.value : v; + }; + + // Registration (policy: allow_registration) + document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true'; + + // Admin system prompt (global_settings) + const sysPrompt = getSetting('system_prompt', {}) || {}; + document.getElementById('adminSystemPrompt').value = sysPrompt.content || ''; + + // Registration default state (policy: default_user_active → 'true' means auto-active) + const defaultActive = policies.default_user_active === 'true'; + document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending'; + + // User providers / BYOK (policy: allow_user_byok) + document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true'; + + // User presets / personas (policy: allow_user_personas) + document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true'; + + // Default model (policy: default_model) — populate from current model list + const defModelSel = document.getElementById('adminDefaultModel'); + if (defModelSel) { + defModelSel.innerHTML = ''; + App.models.filter(m => !m.hidden).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.baseModelId || m.id; + opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''); + defModelSel.appendChild(opt); + }); + defModelSel.value = policies.default_model || ''; + } + + // Banner (global_settings) + const banner = getSetting('banner', {}) || {}; + document.getElementById('adminBannerEnabled').checked = !!banner.enabled; + document.getElementById('adminBannerText').value = banner.text || ''; + document.getElementById('adminBannerPosition').value = banner.position || 'both'; + document.getElementById('adminBannerBg').value = banner.bg || '#007a33'; + document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33'; + document.getElementById('adminBannerFg').value = banner.fg || '#ffffff'; + document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff'; + document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none'; + UI.updateBannerPreview(); + + // Load banner presets (global_settings) + const presets = getSetting('banner_presets', {}) || {}; + const sel = document.getElementById('adminBannerPreset'); + sel.innerHTML = ''; + Object.entries(presets).forEach(([key, p]) => { + sel.innerHTML += ``; + }); + UI._bannerPresets = presets; + + // Vault / Encryption status + const vaultEl = document.getElementById('adminVaultStatus'); + if (vaultEl) { + try { + const vault = await API.adminGetVaultStatus(); + const keyBadge = vault.encryption_key_set + ? 'Active' + : 'Not Set'; + vaultEl.innerHTML = ` +
+
+ Encryption + ${keyBadge} +
+
+ Encrypted Keys + ${vault.encrypted_keys} +
+
+ Vault Users + ${vault.vault_users} +
+
`; + } catch (e) { + vaultEl.innerHTML = 'Could not load vault status'; + } + } + } catch (e) { console.debug('Failed to load admin settings:', e); } + }, + + _bannerPresets: {}, + + updateBannerPreview() { + const prev = document.getElementById('bannerPreview'); + if (!prev) return; + const text = document.getElementById('adminBannerText').value || 'PREVIEW'; + const bg = document.getElementById('adminBannerBg').value; + const fg = document.getElementById('adminBannerFg').value; + prev.textContent = text; + prev.style.background = bg; + prev.style.color = fg; + }, + +});