Head of Product & Engineering 9995027093
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: per-location Cast API key and unified admin config API
Each GHL location can now have its own Cast API key and sender ID stored
in MongoDB. Falls back to global CAST_API_KEY / CAST_SENDER_ID env vars
when not set per-location.

Admin endpoints (all require Authorization: Bearer <INBOUND_API_KEY>):
  GET  /api/admin/locations                        — list all locations
  GET  /api/admin/locations/{locationId}/config    — get location config
  PUT  /api/admin/locations/{locationId}/config    — set sender_id + cast_api_key

Cast API key is masked in GET responses (first 12 chars + "...").
Replaces the /sender-id endpoint deployed in the previous commit.

Also adds FUTURE_DEV.md documenting the migration path to Infisical
for secret management, plus MongoDB security hardening checklist.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-06 14:08:29 +02:00

178 lines
5.0 KiB
Go

package ghl
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
"github.com/go-chi/chi/v5"
)
// AdminStore is the store interface used by AdminHandler.
type AdminStore interface {
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error
ListTokens(ctx context.Context) ([]*store.TokenRecord, error)
}
// AdminHandler exposes management endpoints for the cast-backend admin portal.
// All routes require Authorization: Bearer <adminKey>.
type AdminHandler struct {
adminKey string
store AdminStore
}
func NewAdminHandler(adminKey string, store AdminStore) *AdminHandler {
return &AdminHandler{adminKey: adminKey, store: store}
}
func (h *AdminHandler) auth(r *http.Request) bool {
return h.adminKey != "" && r.Header.Get("Authorization") == "Bearer "+h.adminKey
}
// locationConfigView is the JSON shape returned by GET endpoints.
// The Cast API key is masked to avoid leaking secrets over the wire.
type locationConfigView struct {
LocationID string `json:"location_id"`
CompanyID string `json:"company_id"`
SenderID string `json:"sender_id"`
CastAPIKeyMasked string `json:"cast_api_key"`
InstalledAt time.Time `json:"installed_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func maskAPIKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 12 {
return strings.Repeat("*", len(key))
}
return key[:12] + "..."
}
func toView(r *store.TokenRecord) locationConfigView {
return locationConfigView{
LocationID: r.LocationID,
CompanyID: r.CompanyID,
SenderID: r.SenderID,
CastAPIKeyMasked: maskAPIKey(r.CastAPIKey),
InstalledAt: r.InstalledAt,
UpdatedAt: r.UpdatedAt,
}
}
// HandleListLocations returns all installed locations with their config.
//
// GET /api/admin/locations
func (h *AdminHandler) HandleListLocations(w http.ResponseWriter, r *http.Request) {
if !h.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
records, err := h.store.ListTokens(ctx)
if err != nil {
slog.Error("admin: list locations failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
views := make([]locationConfigView, 0, len(records))
for _, rec := range records {
views = append(views, toView(rec))
}
writeJSON(w, http.StatusOK, views)
}
// HandleGetLocationConfig returns the config for a single location.
//
// GET /api/admin/locations/{locationId}/config
func (h *AdminHandler) HandleGetLocationConfig(w http.ResponseWriter, r *http.Request) {
if !h.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
locationID := chi.URLParam(r, "locationId")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
rec, err := h.store.GetToken(ctx, locationID)
if err != nil {
slog.Error("admin: get location config failed", "location_id", locationID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if rec == nil {
http.Error(w, "location not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, toView(rec))
}
// HandleSetLocationConfig sets the sender ID and Cast API key for a location.
//
// PUT /api/admin/locations/{locationId}/config
// {"sender_id": "CAST", "cast_api_key": "cast_abc123..."}
func (h *AdminHandler) HandleSetLocationConfig(w http.ResponseWriter, r *http.Request) {
if !h.auth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
locationID := chi.URLParam(r, "locationId")
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
var payload struct {
SenderID string `json:"sender_id"`
CastAPIKey string `json:"cast_api_key"`
}
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.store.UpdateLocationConfig(ctx, locationID, payload.SenderID, payload.CastAPIKey); err != nil {
slog.Error("admin: update location config failed", "location_id", locationID, "err", err)
if err.Error() == "location not found" {
http.Error(w, "location not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("admin: location config updated", "location_id", locationID, "sender_id", payload.SenderID, "cast_api_key_set", payload.CastAPIKey != "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
data, err := json.Marshal(v)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(data)
}