package store import ( "context" "crypto/rand" "encoding/base32" "fmt" "strings" "time" "github.com/loyaly/behavision-server/internal/api" ) // CreateSite is the row half of opening a shop. The broker half follows it in // the handler and the provisioner, and both use this so there is one // definition of what a site is. func (s *Store) CreateSite(ctx context.Context, clientID, slug, name, tz string) (api.NewSite, error) { var out api.NewSite if s.secrets == nil { return out, ErrNoSecrets } slug = strings.ToLower(strings.TrimSpace(slug)) if tz == "" { tz = "UTC" } if _, err := time.LoadLocation(tz); err != nil { return out, fmt.Errorf("unknown timezone %q", tz) } var clientSlug string if err := s.pool.QueryRow(ctx, `SELECT slug FROM clients WHERE id = $1::uuid`, clientID).Scan(&clientSlug); err != nil { return out, fmt.Errorf("client %s: %w", clientID, err) } tx, err := s.pool.Begin(ctx) if err != nil { return out, err } defer tx.Rollback(ctx) //nolint:errcheck // A plain INSERT, not an upsert: the API must not let an owner silently // rename an existing shop by re-posting its slug. A duplicate surfaces as // 23505 and the handler turns it into 409. if err := tx.QueryRow(ctx, ` INSERT INTO sites (client_id, slug, name, timezone) VALUES ($1::uuid, $2, $3, $4) RETURNING id::text`, clientID, slug, name, tz).Scan(&out.SiteID); err != nil { return out, err } out.Slug, out.Name, out.Timezone = slug, name, tz // The broker username IS the topic namespace: .. The ACL is // written against it, so it is derived, never chosen. out.Username = clientSlug + "." + slug var agentID string if err := tx.QueryRow(ctx, ` INSERT INTO agents (client_id, site_id, mqtt_username) VALUES ($1::uuid, $2::uuid, $3) RETURNING id::text`, clientID, out.SiteID, out.Username).Scan(&agentID); err != nil { return out, fmt.Errorf("create agent: %w", err) } out.Password, err = randomSecret(24) if err != nil { return out, err } // Sealed with the agent id as aad, so a row copied between agents does not // decrypt into a working credential. sealed, err := s.secrets.SealString(out.Password, agentID) if err != nil { return out, err } if _, err := tx.Exec(ctx, `UPDATE agents SET mqtt_password_enc = $2 WHERE id = $1::uuid`, agentID, sealed); err != nil { return out, err } return out, tx.Commit(ctx) } // DeleteNewSite removes a shop that was created moments ago and could not be // registered with the broker. Scoped to the tenant and refused once the shop // has anything under it: this is compensation for a failed create, not a // delete-shop feature. func (s *Store) DeleteNewSite(ctx context.Context, clientID, siteID string) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) //nolint:errcheck var used bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM visits WHERE site_id = $1::uuid) OR EXISTS (SELECT 1 FROM site_cameras WHERE site_id = $1::uuid)`, siteID).Scan(&used); err != nil { return err } if used { return fmt.Errorf("site %s is in use", siteID) } if _, err := tx.Exec(ctx, `DELETE FROM agents WHERE site_id = $1::uuid AND client_id = $2::uuid`, siteID, clientID); err != nil { return err } if _, err := tx.Exec(ctx, `DELETE FROM sites WHERE id = $1::uuid AND client_id = $2::uuid`, siteID, clientID); err != nil { return err } return tx.Commit(ctx) } func randomSecret(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } // base32 without padding: this gets typed, pasted into config files and // read down a phone line, and base64's + / = survive none of that. return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil }