$caps]); } return true; } /** * Calendar ids this caller may read, or false when they may read all of * them. Every tool uses this so lists and detail reads agree on scope. * Cached per request. * * @return array|false false means "no restriction" */ public static function readableCalendarIds() { static $cache = []; $userId = get_current_user_id(); // Keyed by permission set and blog too: grants can change within one // request (the permission-matrix gate does this), and a multisite // request can switch blogs. $blogId = function_exists('get_current_blog_id') ? get_current_blog_id() : 0; $key = $blogId . '|' . $userId . '|' . md5((string) wp_json_encode(PermissionManager::getUserPermissions())); if (array_key_exists($key, $cache)) { return $cache[$key]; } if (PermissionManager::hasAllCalendarAccess(true)) { return $cache[$key] = false; } return $cache[$key] = self::resolveReadableCalendarIds($userId); } /** * The calendars a restricted user may read: the ones they own, plus the * ones CalendarService::isSharedCalendar() would admit them to. Three * narrow queries instead of hydrating every calendar with its events. * * `settings` is PHP-serialized, so team_members can't be filtered in SQL. * The LIKE only narrows the rows; the in_array below decides. * * @param int $userId * * @return array */ private static function resolveReadableCalendarIds($userId) { global $wpdb; $calendars = $wpdb->prefix . (new Calendar())->getTable(); $events = $wpdb->prefix . (new CalendarSlot())->getTable(); $owned = $wpdb->get_col($wpdb->prepare("SELECT `id` FROM `{$calendars}` WHERE `user_id` = %d", $userId)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names come from the models' getTable(), not from request input // isSharedCalendar()'s first branch: the user owns an event on it. $hosting = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT `calendar_id` FROM `{$events}` WHERE `user_id` = %d", $userId)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names come from the models' getTable(), not from request input $readable = array_merge(array_map('intval', $owned), array_map('intval', $hosting)); // and its second: the user is listed in an event's team_members. $candidates = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names come from the models' getTable(), not from request input $wpdb->prepare( "SELECT `calendar_id`, `settings` FROM `{$events}` WHERE `settings` LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared '%' . $wpdb->esc_like('i:' . (int) $userId . ';') . '%' ), ARRAY_A ); foreach ($candidates as $candidate) { $calendarId = (int) $candidate['calendar_id']; if (in_array($calendarId, $readable, true)) { continue; } $settings = maybe_unserialize($candidate['settings']); if (!is_array($settings)) { continue; } $members = array_map('intval', (array) Arr::get($settings, 'team_members', [])); if (in_array((int) $userId, $members, true)) { $readable[] = $calendarId; } } return array_values(array_unique($readable)); } /** * Apply readableCalendarIds() to a query on any table with a calendar_id. * * @param object $query * @param string $column * @return object */ public static function scopeToReadableCalendars($query, $column = 'calendar_id') { $ids = self::readableCalendarIds(); if ($ids === false) { return $query; } // whereIn with an empty set must return nothing, not everything. return $query->whereIn($column, $ids ? $ids : [0]); } /** * True when the caller may read bookings beyond their own calendars. One * helper so list and report tools can't drift apart on scope. * * @return bool */ public static function canSeeAllBookings() { return PermissionManager::userCanSeeAllBookings(); } /** * The `meta.scope` marker, from the same check the query uses. * * @return string */ public static function currentScope() { return self::canSeeAllBookings() ? MCPHelper::SCOPE_ALL : MCPHelper::SCOPE_OWN; } /** * Transport gate for the `fluent-booking` server. Replaces the adapter's * default `current_user_can('read')`, which every subscriber passes and is * too loose for attendee contact data. Per-ability checks still run on top. * * @param mixed $request unused; the adapter passes the REST request * @return true|\WP_Error */ public static function transport($request = null) { if (!self::isEnabled()) { return MCPHelper::error( 'disabled', __('The FluentBooking MCP server is disabled. Enable it in FluentBooking → Settings → MCP for AI Agents.', 'fluent-booking') ); } if (!is_user_logged_in()) { return MCPHelper::error( 'unauthorized', __('Authentication is required to access the FluentBooking MCP server.', 'fluent-booking') ); } if (!PermissionManager::currentUserHasAnyPermission()) { return MCPHelper::error( 'forbidden', __('Your account does not have FluentBooking access.', 'fluent-booking') ); } return true; } /** * The stored MCP settings, defaults merged in. * * @param bool $cached * @return array */ public static function getSettings($cached = true) { static $settings = null; static $forBlog = null; // Keyed by blog, in case a multisite request switches sites. $blogId = function_exists('get_current_blog_id') ? get_current_blog_id() : 0; if ($cached && $settings !== null && $forBlog === $blogId) { return $settings; } $forBlog = $blogId; $stored = get_option(self::OPTION_KEY, []); if (!is_array($stored)) { $stored = []; } $settings = [ 'enabled' => Arr::get($stored, 'enabled') === 'yes' ? 'yes' : 'no', 'toolsets' => self::sanitizeToolsets(Arr::get($stored, 'toolsets', [])), ]; return $settings; } /** * The master switch. Off by default. * * @return bool */ public static function isEnabled() { $settings = self::getSettings(); return Arr::get($settings, 'enabled') === 'yes'; } /** * Persist the master switch. * * The capability is re-checked here even though callers sit behind * SettingsPolicy, because the FluentToolkit toggle path delegates auth to * another plugin. `manage_options`, not is_super_admin: it's a per-site option. * * @param bool $enabled * @return bool the persisted state */ public static function setEnabled($enabled) { if (!current_user_can('manage_options')) { return self::isEnabled(); } return self::saveSettings(['enabled' => $enabled ? 'yes' : 'no']); } /** * Toolsets currently exposed. Always includes `core`. * * @return array */ public static function enabledToolsets() { $settings = self::getSettings(); return self::sanitizeToolsets(Arr::get($settings, 'toolsets', [])); } /** * @param string $toolset * @return bool */ public static function isToolsetEnabled($toolset) { return in_array($toolset, self::enabledToolsets(), true); } /** * Persist toolset selection. * * @param array $toolsets * @return array the persisted toolsets */ public static function setToolsets($toolsets) { if (!current_user_can('manage_options')) { return self::enabledToolsets(); } self::saveSettings(['toolsets' => self::sanitizeToolsets($toolsets)]); return self::enabledToolsets(); } /** * Every toolset the server knows about, with its label. `payments` is only * offered when Pro is active. * * @return array keyed by toolset slug */ public static function availableToolsets() { $toolsets = [ self::TOOLSET_CORE => [ 'label' => __('Core', 'fluent-booking'), 'description' => __('Bookings, availability, event types, diagnostics and reporting. Always on.', 'fluent-booking'), 'locked' => true, ], self::TOOLSET_SCHEDULING => [ 'label' => __('Scheduling setup', 'fluent-booking'), 'description' => __('Let the agent create and edit event types and availability schedules.', 'fluent-booking'), 'locked' => false, ], ]; if (defined('FLUENT_BOOKING_PRO_DIR_FILE')) { $toolsets[self::TOOLSET_PAYMENTS] = [ 'label' => __('Payments', 'fluent-booking'), 'description' => __('Let the agent read booking orders and transactions.', 'fluent-booking'), 'locked' => false, ]; } return $toolsets; } /** * Coerce a toolset list to known slugs, always including `core`. * * @param mixed $toolsets * @return array */ private static function sanitizeToolsets($toolsets) { if (!is_array($toolsets)) { $toolsets = []; } $known = [self::TOOLSET_CORE, self::TOOLSET_SCHEDULING, self::TOOLSET_PAYMENTS]; $toolsets = array_values(array_intersect($known, array_map('sanitize_text_field', $toolsets))); if (!in_array(self::TOOLSET_CORE, $toolsets, true)) { array_unshift($toolsets, self::TOOLSET_CORE); } return $toolsets; } /** * Merge-write into the option so setEnabled() and setToolsets() cannot * clobber each other, then bust the static cache. * * @param array $changes * @return bool the persisted enabled state */ private static function saveSettings($changes) { $current = self::getSettings(false); update_option(self::OPTION_KEY, array_merge($current, (array) $changes), true); $settings = self::getSettings(false); return Arr::get($settings, 'enabled') === 'yes'; } }