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 . 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) }