Files
goclaw/cmd/bitrix_portal.go
Duy /zuey/andGitHub ef829c1ab4 fix(bitrix24): resolve PR 8-10 stack findings
Resolve the PR #8/#9/#10 stack on current dev, including Bitrix24 install callback hardening, migration renumbering, duplicate-domain fail-closed routing, UI textarea/mobile cleanup, and review hardening.
2026-05-22 20:53:01 +07:00

383 lines
14 KiB
Go

package cmd
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"
"github.com/nextlevelbuilder/goclaw/internal/channels/bitrix24"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// bitrixPortalCmd wires `goclaw bitrix-portal ...` — direct-DB management of
// `bitrix_portals` rows. It seeds the portal row required before an operator
// runs the OAuth install flow at `/bitrix24/install`.
//
// Writes go through PGBitrixPortalStore so GOCLAW_ENCRYPTION_KEY is applied
// to the credentials column the same way the runtime would. Reads via `list`
// deliberately don't print secrets — credentials stay encrypted at rest, and
// a debug tool dumping them would be a regression.
func bitrixPortalCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "bitrix-portal",
Short: "Manage Bitrix24 portals (direct DB access; postgres only)",
Long: `Manage Bitrix24 portal rows in the database.
GoClaw expects a ` + "`bitrix_portals`" + ` row to exist before an operator runs the
OAuth install flow at ` + "`/bitrix24/install`" + `. This command seeds that row without
requiring SQL access to the database.`,
}
cmd.AddCommand(bitrixPortalCreateCmd())
cmd.AddCommand(bitrixPortalListCmd())
cmd.AddCommand(bitrixPortalUpdateCredentialsCmd())
cmd.AddCommand(bitrixPortalSetPublicURLCmd())
return cmd
}
// bitrixPortalUpdateCredentialsCmd swaps client_id/client_secret on an
// existing portal row. Used when rotating client_secret OR migrating from
// local app to marketplace app on the same Bitrix24 portal — the row stays
// (so channel configs keep working by name) but OAuth identity changes.
//
// Side effect: the existing OAuth state token is invalidated by default
// because it was minted against the OLD client_id/secret and will fail
// to refresh against new credentials. Pass --keep-state to skip that.
// After update, the portal admin must visit the install URL to obtain
// new tokens against the new credentials.
func bitrixPortalUpdateCredentialsCmd() *cobra.Command {
var (
tenantID string
name string
clientID string
clientSecret string
keepState bool
)
cmd := &cobra.Command{
Use: "update-credentials",
Short: "Replace client_id/client_secret on an existing portal row",
Long: `Update OAuth credentials on an existing bitrix_portals row.
Use this when rotating client_secret or migrating from local app to
marketplace app. The OAuth state token is cleared by default (state from
old credentials cannot refresh under new client_id/secret); pass
--keep-state only if rotating the secret of the SAME application.`,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(name) == "" ||
strings.TrimSpace(clientID) == "" || strings.TrimSpace(clientSecret) == "" {
return fmt.Errorf("--tenant-id, --name, --client-id, --client-secret are all required")
}
tid, err := uuid.Parse(tenantID)
if err != nil {
return fmt.Errorf("invalid --tenant-id: %w", err)
}
dsn, err := resolveDSN()
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
if err := db.PingContext(cmd.Context()); err != nil {
return fmt.Errorf("ping db: %w", err)
}
encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
if encKey == "" {
fmt.Fprintln(os.Stderr, "WARNING: GOCLAW_ENCRYPTION_KEY is not set — credentials will be stored UNENCRYPTED")
}
creds := store.BitrixPortalCredentials{
ClientID: clientID,
ClientSecret: clientSecret,
}
credsJSON, err := json.Marshal(creds)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
portalStore := pg.NewPGBitrixPortalStore(db, encKey)
if err := portalStore.UpdateCredentials(cmd.Context(), tid, name, credsJSON); err != nil {
return fmt.Errorf("update credentials: %w", err)
}
if !keepState {
if err := portalStore.UpdateState(cmd.Context(), tid, name, nil); err != nil {
return fmt.Errorf("clear state: %w", err)
}
}
fmt.Printf("Updated bitrix_portals row:\n")
fmt.Printf(" tenant_id: %s\n", tid)
fmt.Printf(" name: %s\n", name)
if !keepState {
fmt.Printf(" state: cleared (admin must reinstall to mint new tokens)\n")
} else {
fmt.Printf(" state: kept (only valid if rotating same-app secret)\n")
}
fmt.Printf("\nNext step — have the portal admin visit:\n")
fmt.Printf(" https://<public_url>/bitrix24/install?state=%s:%s\n", tid, name)
return nil
},
}
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Tenant UUID this portal belongs to (required)")
cmd.Flags().StringVar(&name, "name", "", "Portal name to update (required)")
cmd.Flags().StringVar(&clientID, "client-id", "", "New Bitrix24 application client_id (required)")
cmd.Flags().StringVar(&clientSecret, "client-secret", "", "New Bitrix24 application client_secret (required)")
cmd.Flags().BoolVar(&keepState, "keep-state", false, "Keep existing OAuth state token (only safe when rotating secret of SAME application)")
return cmd
}
func bitrixPortalCreateCmd() *cobra.Command {
var (
tenantID string
name string
domain string
clientID string
clientSecret string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a bitrix_portals row with client_id/client_secret",
Long: `Create a new Bitrix24 portal registration.
After the row exists, direct the portal admin to
` + "`https://<public_url>/bitrix24/install?state=<tenant_id>:<name>`" + `
to authorize the app — the install handler writes the OAuth token into the
` + "`state`" + ` column of this same row.`,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(name) == "" ||
strings.TrimSpace(domain) == "" || strings.TrimSpace(clientID) == "" ||
strings.TrimSpace(clientSecret) == "" {
return fmt.Errorf("--tenant-id, --name, --domain, --client-id, --client-secret are all required")
}
tid, err := uuid.Parse(tenantID)
if err != nil {
return fmt.Errorf("invalid --tenant-id: %w", err)
}
// Strip protocol + trailing slash from domain; Bitrix24 identifies
// the portal by bare host (e.g. `tamgiac.bitrix24.com`).
dom := normalizeBitrixDomain(domain)
dsn, err := resolveDSN()
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
if err := db.PingContext(cmd.Context()); err != nil {
return fmt.Errorf("ping db: %w", err)
}
encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
if encKey == "" {
// Not fatal — pg store passes plaintext through when the key is
// empty — but the runtime gateway would also run unencrypted,
// which is almost never what a production deploy wants. Warn
// loud so the operator notices instead of silently storing
// client_secret as cleartext.
fmt.Fprintln(os.Stderr, "WARNING: GOCLAW_ENCRYPTION_KEY is not set — credentials will be stored UNENCRYPTED")
}
creds := store.BitrixPortalCredentials{
ClientID: clientID,
ClientSecret: clientSecret,
}
credsJSON, err := json.Marshal(creds)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
portalStore := pg.NewPGBitrixPortalStore(db, encKey)
data := &store.BitrixPortalData{
TenantID: tid,
Name: name,
Domain: dom,
Credentials: credsJSON,
// State stays empty — it's populated by /bitrix24/install
// after the portal admin authorizes the app.
}
if err := portalStore.Create(cmd.Context(), data); err != nil {
return fmt.Errorf("create portal: %w", err)
}
fmt.Printf("Created bitrix_portals row:\n")
fmt.Printf(" id: %s\n", data.ID)
fmt.Printf(" tenant_id: %s\n", data.TenantID)
fmt.Printf(" name: %s\n", data.Name)
fmt.Printf(" domain: %s\n", data.Domain)
fmt.Printf("\nNext step — have the portal admin visit:\n")
fmt.Printf(" https://<public_url>/bitrix24/install?state=%s:%s\n", data.TenantID, data.Name)
fmt.Printf("(public_url must match the `public_url` field on the channel_instance config.)\n")
return nil
},
}
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Tenant UUID this portal belongs to (required)")
cmd.Flags().StringVar(&name, "name", "", "Short portal name, referenced by channel_instance.config.portal (required)")
cmd.Flags().StringVar(&domain, "domain", "", "Bitrix24 portal host, e.g. tamgiac.bitrix24.com (required)")
cmd.Flags().StringVar(&clientID, "client-id", "", "Bitrix24 application client_id / application_id (required)")
cmd.Flags().StringVar(&clientSecret, "client-secret", "", "Bitrix24 application client_secret / application key (required)")
return cmd
}
func bitrixPortalListCmd() *cobra.Command {
var tenantID string
cmd := &cobra.Command{
Use: "list",
Short: "List bitrix_portals rows (optionally scoped to one tenant)",
RunE: func(cmd *cobra.Command, args []string) error {
dsn, err := resolveDSN()
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
if err := db.PingContext(cmd.Context()); err != nil {
return fmt.Errorf("ping db: %w", err)
}
portalStore := pg.NewPGBitrixPortalStore(db, os.Getenv("GOCLAW_ENCRYPTION_KEY"))
var rows []store.BitrixPortalData
if tenantID == "" {
rows, err = portalStore.ListAllForLoader(cmd.Context())
} else {
tid, parseErr := uuid.Parse(tenantID)
if parseErr != nil {
return fmt.Errorf("invalid --tenant-id: %w", parseErr)
}
rows, err = portalStore.ListByTenant(cmd.Context(), tid)
}
if err != nil {
return fmt.Errorf("list portals: %w", err)
}
if len(rows) == 0 {
fmt.Println("(no portals)")
return nil
}
fmt.Printf("%-36s %-36s %-24s %s\n", "ID", "TENANT_ID", "NAME", "DOMAIN")
for _, r := range rows {
// Credentials deliberately not printed. If the runtime couldn't
// decrypt them the scan already logged a warning; we tag that
// case here so operators spot a corrupt row at a glance.
nameCol := r.Name
if len(r.Credentials) == 0 {
nameCol += " (creds:empty)"
}
fmt.Printf("%-36s %-36s %-24s %s\n", r.ID, r.TenantID, nameCol, r.Domain)
}
return nil
},
}
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Filter to one tenant UUID (optional)")
return cmd
}
// bitrixPortalSetPublicURLCmd backfills bitrix_portals.state.public_url for
// portals installed before automatic public_url capture existed. One-shot op:
// after running once, channel registration succeeds for new bots and future
// reinstalls update the URL automatically.
//
// Usage:
//
// goclaw bitrix-portal set-public-url \
// --tenant-id <uuid> --name <portal> --url https://goclaw.example.com
func bitrixPortalSetPublicURLCmd() *cobra.Command {
var (
tenantID string
name string
url string
)
cmd := &cobra.Command{
Use: "set-public-url",
Short: "Backfill state.public_url for a portal installed pre Phase-01",
Long: `Set the gateway-public URL used to register Bitrix24 imbot event handlers.
Required for portals that were installed before the goclaw release that
auto-captures the URL from the /bitrix24/install callback. Without it, the
factory cannot build a valid EVENT_MESSAGE_ADD URL for new channels.
After running once, the value is persisted in bitrix_portals.state.public_url
and reused on every restart. Subsequent reinstalls (when the public URL
rotates) overwrite the value automatically — this command is only for the
initial backfill.`,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(name) == "" ||
strings.TrimSpace(url) == "" {
return fmt.Errorf("--tenant-id, --name, --url are all required")
}
tid, err := uuid.Parse(tenantID)
if err != nil {
return fmt.Errorf("invalid --tenant-id: %w", err)
}
dsn, err := resolveDSN()
if err != nil {
return err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
if err := db.PingContext(cmd.Context()); err != nil {
return fmt.Errorf("ping db: %w", err)
}
encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
if encKey == "" {
fmt.Fprintln(os.Stderr, "WARNING: GOCLAW_ENCRYPTION_KEY is not set — state will be read/written UNENCRYPTED")
}
portalStore := pg.NewPGBitrixPortalStore(db, encKey)
portal, err := bitrix24.NewPortal(cmd.Context(), tid, name, portalStore, encKey)
if err != nil {
return fmt.Errorf("load portal: %w", err)
}
if err := portal.UpdatePublicURL(cmd.Context(), strings.TrimRight(url, "/")); err != nil {
return fmt.Errorf("update public_url: %w", err)
}
fmt.Printf("Updated bitrix_portals.state.public_url:\n")
fmt.Printf(" tenant_id: %s\n", tid)
fmt.Printf(" name: %s\n", name)
fmt.Printf(" public_url: %s\n", portal.PublicURL())
fmt.Printf("\nNew channels on this portal can now imbot.register successfully.\n")
fmt.Printf("Set BITRIX24_FORCE_REREGISTER=1 + restart if existing channels were\n")
fmt.Printf("registered against a stale URL and need to refresh Bitrix-side handlers.\n")
return nil
},
}
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Tenant UUID this portal belongs to (required)")
cmd.Flags().StringVar(&name, "name", "", "Portal name (required)")
cmd.Flags().StringVar(&url, "url", "", "Gateway public URL, e.g. https://goclaw.tamgiac.com (required)")
return cmd
}
// normalizeBitrixDomain strips scheme and trailing slashes so callers can paste
// either `https://tamgiac.bitrix24.com/` or bare `tamgiac.bitrix24.com` and get
// a consistent value in the DB. Bitrix24's OAuth callback compares the bare
// host, so storing it with scheme would silently break the install flow.
func normalizeBitrixDomain(raw string) string {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "https://")
s = strings.TrimPrefix(s, "http://")
s = strings.TrimSuffix(s, "/")
return s
}