array(__('Dashboard', 'postly'), '/home'), 'publishing' => array(__('Social publishing', 'postly'), '/publishing/social'), 'blog' => array(__('Blog publishing', 'postly'), '/publishing/blog'), 'email' => array(__('Email marketing', 'postly'), '/publishing/email'), 'whatsapp' => array(__('WhatsApp marketing', 'postly'), '/publishing/whatsapp'), 'polls' => array(__('Polls', 'postly'), '/publishing/polls'), 'posts' => array(__('Posts and drafts', 'postly'), '/posts'), 'calendar' => array(__('Calendar', 'postly'), '/calendar'), 'campaigns' => array(__('Campaigns (optional)', 'postly'), '/campaigns'), 'auto-post' => array(__('Auto Post to Blog or Social', 'postly'), '/publishing/auto_post'), 'rss' => array(__('RSS feeds to Blog or Social', 'postly'), '/publishing/rss_feeds'), 'ecommerce' => array(__('Ecommerce to Blog or Social', 'postly'), '/publishing/ecommerce'), 'media' => array(__('Media library', 'postly'), '/media_library'), 'create' => array(__('Templates and creation tools', 'postly'), '/create'), 'ai' => array(__('AI Studio', 'postly'), '/ai-studio'), 'team-notes' => array(__('Team notes', 'postly'), '/team-notes'), 'analytics' => array(__('Publishing analytics', 'postly'), '/analytics/publishing'), 'connections' => array(__('Channels and connected blogs', 'postly'), '/social_accounts'), 'products' => array(__('All products and tools', 'postly'), '/products'), 'agents' => array(__('AI agents (optional)', 'postly'), '/mcp-ai-agents'), 'workspace' => array(__('Workspace settings', 'postly'), '/workspace-settings'), 'billing' => array(__('Manage billing on Postly web', 'postly'), '/home/billing'), ); } public static function dashboard_url($key = 'home') { $destinations = self::destinations(); $path = isset($destinations[$key]) ? $destinations[$key][1] : '/home'; // Links carry context, never the profile bearer. The web handoff verifies // the signed-in actor/workspace again before opening any tool or billing. $profile = self::profile(); if ($profile) { return add_query_arg(array('tool' => isset($destinations[$key]) ? $key : 'home', 'workspace_id' => $profile['workspace_id'], 'actor_id' => $profile['actor_id']), self::ORIGIN . '/wordpress-plugin'); } return self::ORIGIN . $path; } private static function installation() { $value = get_user_meta(get_current_user_id(), self::INSTALLATION, true); if (!is_string($value) || !preg_match('/^[A-Za-z0-9_-]{43}$/D', $value)) { $value = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); update_user_meta(get_current_user_id(), self::INSTALLATION, $value); } return $value; } private static function crypt($value, $decrypt = false) { if (!function_exists('openssl_encrypt') || !function_exists('openssl_decrypt')) return ''; $key = hash('sha256', wp_salt('auth') . ':' . get_current_user_id(), true); if ($decrypt) { $bytes = base64_decode($value, true); if ($bytes === false || strlen($bytes) < 29) return ''; $plain = openssl_decrypt(substr($bytes, 28), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, substr($bytes, 0, 12), substr($bytes, 12, 16)); return $plain === false ? '' : $plain; } $iv = random_bytes(12); $tag = ''; $encrypted = openssl_encrypt($value, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); return $encrypted === false ? '' : base64_encode($iv . $tag . $encrypted); } private static function token() { $stored = get_user_meta(get_current_user_id(), self::CONNECTION, true); return is_string($stored) && $stored !== '' ? self::crypt($stored, true) : ''; } private static function rpc($method, $data) { $response = wp_remote_post(self::API . $method, array('timeout' => 12, 'redirection' => 0, 'headers' => array('Content-Type' => 'application/json', 'X-Postly-Product' => 'postly'), 'body' => wp_json_encode(array('request' => array('data' => $data))))); if (is_wp_error($response)) return new WP_Error('postly_unavailable', __('Postly is temporarily unavailable. Try again; your scheduled campaigns are unchanged.', 'postly')); $body = json_decode(wp_remote_retrieve_body($response), true); if (wp_remote_retrieve_response_code($response) !== 200 || !is_array($body) || ($body['code'] ?? 0) !== 200 || !isset($body['data']) || !is_array($body['data'])) { return new WP_Error('postly_connection', __('Postly could not verify this connection. Reconnect your account or try again.', 'postly')); } return $body['data']; } private static function identity_data() { return array('site_url' => untrailingslashit(home_url('/')), 'installation' => self::installation()); } private static function valid_profile($profile) { if (!is_array($profile) || !isset($profile['permissions']) || !is_array($profile['permissions'])) return false; foreach (array('actor_id', 'workspace_id') as $field) { if (!isset($profile[$field]) || !is_string($profile[$field]) || !preg_match('/^[a-f0-9]{24}$/D', $profile[$field])) return false; } foreach (array('display_name', 'workspace_name') as $field) { if (!isset($profile[$field]) || !is_string($profile[$field]) || strlen($profile[$field]) > 512) return false; } foreach (array('draft', 'approve', 'connections', 'settings', 'billing', 'agents') as $field) { if (!isset($profile['permissions'][$field]) || !is_bool($profile['permissions'][$field])) return false; } return isset($profile['publishing_access']) && is_bool($profile['publishing_access']); } public static function profile() { if (self::$profile_user !== get_current_user_id()) { self::$profile_loaded = false; self::$profile = null; self::$profile_user = get_current_user_id(); } if (self::$profile_loaded) return self::$profile; self::$profile_loaded = true; $token = self::token(); if ($token === '') return null; $result = self::rpc('profile', array_merge(self::identity_data(), array('token' => $token))); if (!is_wp_error($result) && self::valid_profile($result)) self::$profile = $result; return self::$profile; } private static function require_local_access() { if (!current_user_can('edit_posts')) wp_die(esc_html__('You do not have permission to manage publishing.', 'postly')); } public static function connect() { self::require_local_access(); check_admin_referer('postly_connect'); $code = isset($_POST['postly_code']) && is_string($_POST['postly_code']) ? sanitize_text_field(wp_unslash($_POST['postly_code'])) : ''; if (!preg_match('/^[A-Za-z0-9_-]{43}$/D', $code)) wp_die(esc_html__('Paste a valid connection code from Postly.', 'postly')); // Refuse before consuming a one-use code if this host cannot protect it. if (self::crypt('check') === '') wp_die(esc_html__('This server needs OpenSSL support to connect securely.', 'postly')); $result = self::rpc('exchange', array_merge(self::identity_data(), array('code' => $code))); if (is_wp_error($result)) wp_die(esc_html($result->get_error_message())); if (!isset($result['token']) || !is_string($result['token']) || !preg_match('/^[A-Za-z0-9_-]{43}$/D', $result['token']) || !self::valid_profile($result['profile'] ?? null)) { wp_die(esc_html__('The connection could not be verified. Generate a new code in Postly.', 'postly')); } $old = self::token(); $encrypted = self::crypt($result['token']); if ($encrypted === '' || !update_user_meta(get_current_user_id(), self::CONNECTION, $encrypted)) { self::rpc('revoke', array_merge(self::identity_data(), array('token' => $result['token']))); wp_die(esc_html__('The connection could not be saved. Generate a new code and try again.', 'postly')); } if ($old !== '') self::rpc('revoke', array_merge(self::identity_data(), array('token' => $old))); wp_safe_redirect(admin_url('admin.php?page=postly')); exit; } public static function disconnect() { self::require_local_access(); check_admin_referer('postly_disconnect'); $token = self::token(); if ($token !== '') { $result = self::rpc('revoke', array_merge(self::identity_data(), array('token' => $token))); if (is_wp_error($result)) wp_die(esc_html__('Postly could not confirm disconnection. Try again, or sign out of the linked session in Postly to revoke its access.', 'postly')); } delete_user_meta(get_current_user_id(), self::CONNECTION); wp_safe_redirect(admin_url('admin.php?page=postly')); exit; } public static function logout($user_id) { // Local logout removes this user's encrypted profile credential. Other // WordPress users keep their own links; Postly logout invalidates the // parent session server-side. Tokens are never cached across requests. delete_user_meta($user_id, self::CONNECTION); delete_user_meta($user_id, self::INSTALLATION); self::$profile_loaded = false; self::$profile = null; self::$profile_user = null; } private static function permission_for($key) { if ($key === 'billing') return 'billing'; if ($key === 'connections') return 'connections'; if ($key === 'workspace') return 'settings'; if ($key === 'agents') return 'agents'; if (in_array($key, array('publishing', 'blog', 'email', 'whatsapp', 'polls', 'auto-post', 'rss', 'ecommerce', 'create', 'ai'), true)) return 'draft'; return ''; } public static function open_tool() { self::require_local_access(); check_admin_referer('postly_open'); $key = isset($_GET['tool']) && is_string($_GET['tool']) ? sanitize_key(wp_unslash($_GET['tool'])) : 'home'; $destinations = self::destinations(); if (!isset($destinations[$key])) wp_die(esc_html__('Unknown Postly tool.', 'postly')); $profile = self::profile(); if (!$profile) wp_die(esc_html__('Connect or refresh your Postly account before opening this tool.', 'postly')); $permission = self::permission_for($key); if ($permission !== '' && $profile['permissions'][$permission] !== true) wp_die(esc_html__('Your Postly workspace role does not allow this action. Ask your workspace owner.', 'postly')); // Only a fixed Postly origin is allowed. Plan/credits are checked in the // hosted action handler; an expired plan must not hide feature controls. wp_redirect(self::dashboard_url($key)); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Fixed Postly origin, never user-supplied. exit; } private static function tool_action_url($key) { return wp_nonce_url(add_query_arg(array('action' => 'postly_open', 'tool' => $key), admin_url('admin-post.php')), 'postly_open'); } public static function campaign_url($source, $type = 'blog', $destination = 'social') { $url = wp_parse_url($source); if (!is_array($url) || empty($url['host']) || !in_array($url['scheme'] ?? '', array('http', 'https'), true) || isset($url['user']) || isset($url['pass']) || isset($url['fragment']) || strlen($source) > 2048 || !in_array($type, array('blog', 'rss'), true) || !in_array($destination, array('blog', 'social'), true)) { return ''; } return add_query_arg(array( 'from' => 'wordpress-plugin', 'source_type' => $type, 'destination' => $destination, 'source_url' => $source, ), self::dashboard_url($type === 'rss' ? 'rss' : 'auto-post')); } public static function post_campaign_url($post, $destination = 'social') { // Only a public permalink may leave WordPress. Drafts, private and // password-protected posts must never be exposed through query strings. // No publish/save hooks run: revising an article cannot duplicate jobs. if ($post->post_type !== 'post' || !current_user_can('edit_post', $post->ID) || !is_post_publicly_viewable($post) || !empty($post->post_password)) { return ''; } return self::campaign_url(get_permalink($post), 'blog', $destination); } public static function menu() { add_menu_page(__('Postly publishing', 'postly'), 'Postly', 'edit_posts', 'postly', array(__CLASS__, 'render'), 'dashicons-share', 27); } public static function meta_box($post) { if (current_user_can('edit_post', $post->ID)) { add_meta_box('postly', __('Postly publishing', 'postly'), array(__CLASS__, 'render_meta_box'), 'post', 'side', 'default', array('__block_editor_compatible_meta_box' => true)); } } private static function link($url, $label, $primary = false) { echo '' . esc_html($label) . ''; } public static function render_meta_box($post) { if (!current_user_can('edit_post', $post->ID)) { return; } $url = self::post_campaign_url($post); echo '

' . esc_html__('Turn this article into channel-ready drafts with your AI agent or the Postly editor. Review the content and publishing times in Postly.', 'postly') . '

'; self::link(self::dashboard_url('blog'), __('Open article editor', 'postly')); if ($url) { echo '

'; self::link($url, __('Create social campaign', 'postly')); echo '

'; self::link(self::post_campaign_url($post, 'blog'), __('Create blog campaign', 'postly')); echo '

' . esc_html__('Sends the public article URL when you click. Review and activate the campaign in Postly.', 'postly') . '

'; } else { echo '

' . esc_html__('Publish this article publicly to use it as a campaign source. Private and draft content stays in WordPress.', 'postly') . '

'; } } public static function row_actions($actions, $post) { $url = self::post_campaign_url($post); if ($url) { $actions['postly'] = '' . esc_html__('Create Postly social campaign', 'postly') . ''; $actions['postly_blog'] = '' . esc_html__('Create Postly blog campaign', 'postly') . ''; } return $actions; } public static function plugin_links($links) { if (current_user_can('edit_posts')) { array_unshift($links, '' . esc_html__('Open Postly', 'postly') . ''); } return $links; } public static function render() { if (!current_user_can('edit_posts')) { wp_die(esc_html__('You do not have permission to manage publishing.', 'postly')); } echo '

' . esc_html__('Postly publishing', 'postly') . '

'; echo '

' . esc_html__('Write in Postly, select your connected channels, review each publication preview, then publish or schedule. Blog and Email use a block editor. Campaign grouping and external AI agents are optional.', 'postly') . '

'; echo '

' . esc_html__('For example, turn a customer advice article into a LinkedIn tip and a Facebook post linking to the full guide. Start with a public article below and review each draft before scheduling.', 'postly') . '

'; echo '

' . esc_html__('This is the official companion from Postly Technologies Inc., the publisher of postly.ai. For support, write to team@postly.ai.', 'postly') . '

'; self::render_profile(); echo '

' . esc_html__('Workspace tools', 'postly') . '

'; $profile = self::profile(); foreach (self::destinations() as $key => $destination) { $permission = self::permission_for($key); if ($key === 'billing' && (!$profile || !$profile['permissions']['billing'])) continue; echo ''; } echo '
' . esc_html($destination[0]) . ''; if ($profile && ($permission === '' || $profile['permissions'][$permission])) { /* translators: %s: Postly workspace tool name. */ self::link(self::tool_action_url($key), sprintf(__('Open %s', 'postly'), $destination[0])); } elseif ($profile) { echo esc_html__('Ask your workspace owner for permission.', 'postly'); } else { self::link(self::ORIGIN . '/wordpress-plugin', __('Sign in to Postly', 'postly')); } echo '
'; if (current_user_can('manage_options')) { self::render_connection(); } echo '

' . esc_html__('This site’s articles', 'postly') . '

' . esc_html__('Use a public article or feed for blog or social automation. To distribute new articles, set up an RSS-to-social campaign for this site. Review destinations and schedules in Postly before activation.', 'postly') . '

'; $feed = self::campaign_url(get_feed_link(), 'rss'); if ($feed) { self::link($feed, __('Create RSS-to-social campaign', 'postly')); echo ' '; self::link(self::campaign_url(get_feed_link(), 'rss', 'blog'), __('Create RSS-to-blog campaign', 'postly')); } // Bounded, uncached local query: WordPress owns visibility and edits; // no cross-user cache can retain an article after permissions change. $query = new WP_Query(array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 20, 'no_found_rows' => true, 'orderby' => 'date', 'order' => 'DESC', 'update_post_meta_cache' => false, 'update_post_term_cache' => false, )); echo ''; $shown = 0; foreach ($query->posts as $post) { $url = self::post_campaign_url($post); if (!$url) { continue; } ++$shown; echo ''; } if (!$shown) { echo ''; } echo '
' . esc_html__('Article', 'postly') . '' . esc_html__('Action', 'postly') . '
' . esc_html(get_the_title($post)) . ''; self::link($url, __('Create social campaign', 'postly')); echo ' '; self::link(self::post_campaign_url($post, 'blog'), __('Create blog campaign', 'postly')); echo '
' . esc_html__('No public articles available. Publish an article in WordPress or create one in Postly.', 'postly') . '

' . esc_html__('View all WordPress posts', 'postly') . '

'; } private static function render_profile() { echo '

' . esc_html__('Your Postly account', 'postly') . '

'; $profile = self::profile(); if ($profile) { echo '

' . esc_html($profile['display_name']) . ' · ' . esc_html($profile['workspace_name']) . '

'; echo '

' . esc_html__('Your Postly workspace role controls drafting, approvals, account connections and billing. WordPress administrator access does not grant additional Postly permissions. Plans and AI credits are checked when you use hosted features.', 'postly') . '

'; self::link(admin_url('admin.php?page=postly'), __('Refresh permissions', 'postly')); echo '
'; wp_nonce_field('postly_disconnect'); echo '

'; } else { if (self::token() !== '') echo '

' . esc_html__('Your Postly permissions could not be verified. Refresh this page or reconnect. Hosted campaigns continue independently.', 'postly') . '

'; echo '

' . esc_html__('Connect your own Postly profile to use the correct workspace and permissions. Each WordPress user connects separately. The connection reads your name, workspace and permitted actions; it cannot publish or charge your account.', 'postly') . '

'; self::link(add_query_arg(self::identity_data(), self::ORIGIN . '/wordpress-plugin'), __('Connect my Postly account', 'postly'), true); echo '
'; wp_nonce_field('postly_connect'); echo '

'; } echo '

' . esc_html__('Billing opens on the Postly website for authorized users. If you cannot manage billing, ask your workspace owner to review the plan. External AI agents require Postly API access and their own setup.', 'postly') . '

'; echo '

' . esc_html__('Disconnecting this profile or removing the plugin does not stop hosted campaigns. Pause campaigns in Postly and revoke the WordPress publishing connection separately.', 'postly') . '

'; } private static function render_connection() { echo '

' . esc_html__('Connect this WordPress site', 'postly') . '

  1. ' . esc_html__('Open Channels in Postly, choose WordPress, then select self-hosted WordPress.', 'postly') . '
  2. ' . esc_html__('Enter this site URL:', 'postly') . ' ' . esc_html(home_url('/')) . '
  3. ' . esc_html__('Create an application password in your WordPress profile. Enter your username and that application password directly in Postly.', 'postly') . '

'; self::link(admin_url('profile.php#application-passwords-section'), __('Open WordPress profile', 'postly')); echo '

' . esc_html__('The plugin does not store WordPress publishing credentials or check the WordPress publishing connection status. Manage and revoke that connection in Postly or your WordPress profile. For WordPress.com sites, use the WordPress.com connection option.', 'postly') . '

'; } } Postly_Publishing::boot();