state === 'active', 'active_required', 403); demand(content_execution_user_allowed($fresh->policy->execution_user_id), 'execution_user_denied', 403); $caps = effective_policy($fresh)->capabilities; $grants = ['media.upload' => 'media.upload', 'publication.create_draft' => 'draft.create', 'publication.amend_draft' => 'draft.update', 'publication.amend_published' => 'published.update', 'publication.create_publish' => 'publication.publish', 'publication.publish_draft' => 'publication.publish']; demand(isset($grants[$body->operation]), 'grant_denied', 403); $grant = $grants[$body->operation]; demand(in_array($grant, $caps, true), 'grant_denied', 403); if (in_array($body->operation, ['publication.create_draft', 'publication.create_publish'], true)) demand(can_create_posts($fresh->policy->execution_user_id), 'create_denied', 403); $a = $body->authorization; $now = microtime(true); $start = utc_seconds($a->approved_at); $end = utc_seconds($a->expires_at); demand($start <= $now && $now <= $end && $end - $start <= 900, 'approval_expired', 403); foreach (effect_hashes($body) as $key => $hash) if ($key !== 'execution_fingerprint') demand(hash_equals($body->{$key}, $hash), 'semantic_mismatch', 409); return $fresh; } function owned_handle($b, $kind, $handle) { $record = journal_read(journal_scope($b), $kind . ':' . $handle); demand($record !== null && isset($record->object_id) && $record->installation_id === $b->installation_id, 'handle_denied', 403); clean_post_cache($record->object_id); $post = get_post($record->object_id); demand($post && get_post_meta($post->ID, '_publisher_connector_scope', true) === journal_scope($b) && get_post_meta($post->ID, '_publisher_connector_handle', true) === $handle, 'handle_denied', 403); demand($post->post_type === ($kind === 'media' ? 'attachment' : 'post'), 'handle_denied', 403); return $record; } function validate_html($html, $binding = null) { $html = preg_replace_callback('~\[embed\](https://www\.youtube\.com/watch\?v=[A-Za-z0-9_-]{11})\[/embed\]~', static function ($match) { return ''; }, $html); $html = preg_replace_callback('~\[gallery ids="([1-9][0-9]*)" columns="1" size="full" link="none"\]~', static function ($match) use ($binding) { demand($binding !== null, 'gallery_binding_required', 403); $attachment_id = (int) $match[1]; $attachment = get_post($attachment_id); demand($attachment && $attachment->post_type === 'attachment' && wp_attachment_is_image($attachment_id) && in_array((int) $attachment->post_author, effective_policy($binding)->allowed_author_ids, true) && user_can($binding->policy->execution_user_id, 'read_post', $attachment_id), 'gallery_attachment_denied', 403); return ''; }, $html); demand(!preg_match('/<\?| [], 'br' => [], 'strong' => [], 'em' => [], 'ul' => [], 'ol' => [], 'li' => [], 'blockquote' => [], 'h2' => [], 'h3' => [], 'a' => ['href' => true, 'title' => true]]; demand(wp_kses($html, $allowed, ['https']) === $html, 'html_rejected'); demand(class_exists('DOMDocument'), 'html_parser_unavailable', 503); $dom = new \DOMDocument(); $prior = libxml_use_internal_errors(true); try { demand($dom->loadHTML('
' . $html . '', LIBXML_NONET), 'html_rejected'); foreach ($dom->getElementsByTagName('*') as $element) { $tag = strtolower($element->tagName); if ($tag === 'html' || $tag === 'body') continue; demand(isset($allowed[$tag]), 'html_rejected'); foreach ($element->attributes as $attribute) demand(isset($allowed[$tag][$attribute->name]), 'html_rejected'); if ($tag === 'a' && $element->hasAttribute('href')) { $url = $element->getAttribute('href'); $parts = wp_parse_url($url); demand($parts && ($parts['scheme'] ?? '') === 'https' && isset($parts['host']) && !isset($parts['user']) && !isset($parts['pass']) && !preg_match('/[\x00-\x20\\\\]/', $url), 'html_rejected'); } } } finally { libxml_clear_errors(); libxml_use_internal_errors($prior); } } function validate_projection($b, $p) { $policy = effective_policy($b); $categories = $p->category_ids; sort($categories, SORT_NUMERIC); demand($categories === $p->category_ids && in_array($p->author_id, $policy->allowed_author_ids, true), 'target_denied', 403); foreach ($categories as $id) demand(in_array($id, $policy->allowed_category_ids, true) && category_exists($id) && user_can($b->policy->execution_user_id, 'assign_term', $id), 'target_denied', 403); demand(wp_strip_all_tags($p->title) === $p->title && wp_strip_all_tags($p->excerpt) === $p->excerpt && !str_contains($p->title . $p->excerpt, "\0"), 'text_rejected'); validate_html($p->body_html, $b); if ($p->media_handle !== null) owned_handle($b, 'media', $p->media_handle); } function projection_read($b, $record) { clean_post_cache($record->object_id); $post = get_post($record->object_id); demand($post && $post->post_type === 'post', 'object_absent', 409); $categories = wp_get_post_categories($post->ID, ['fields' => 'ids']); demand(!is_wp_error($categories), 'read_failed', 503); $categories = array_map('intval', $categories); sort($categories, SORT_NUMERIC); $media = $record->projection->media_handle; $attachment = $media === null ? 0 : (str_starts_with($media, 'adopted:') ? (int) substr($media, 8) : owned_handle($b, 'media', $media)->object_id); demand((int) get_post_thumbnail_id($post->ID) === (int) $attachment, 'content_drift', 409); return obj(['title' => $post->post_title, 'slug' => $post->post_name, 'excerpt' => $post->post_excerpt, 'body_html' => $post->post_content, 'author_id' => (int) $post->post_author, 'category_ids' => $categories, 'media_handle' => $media]); } function read_publication_by_url($b, $token) { demand(is_string($token) && preg_match('/^[A-Za-z0-9_-]{1,2731}$/D', $token), 'invalid_url', 400); $encoded = strtr($token, '-_', '+/'); $encoded .= str_repeat('=', (4 - strlen($encoded) % 4) % 4); $url = base64_decode($encoded, true); demand(is_string($url) && strlen($url) <= 2048 && rtrim(strtr(base64_encode($url), '+/', '-_'), '=') === $token, 'invalid_url', 400); $url = preg_replace('/#.*$/s', '', $url); $target = wp_parse_url($url); $homeUrl = home_url('/'); $home = wp_parse_url($homeUrl); demand($target && $home && ($target['scheme'] ?? '') === 'https' && strtolower($target['host'] ?? '') === strtolower($home['host'] ?? '') && ($target['port'] ?? null) === ($home['port'] ?? null) && !isset($target['user']) && !isset($target['pass']) && !preg_match('/[\x00-\x20\\\\]/', $url), 'site_mismatch', 403); $root = rtrim($home['path'] ?? '/', '/') . '/'; $path = $target['path'] ?? '/'; demand($root === '/' || str_starts_with($path, $root), 'site_mismatch', 403); $postId = url_to_postid($url); $post = $postId > 0 ? get_post($postId) : null; demand($post && $post->post_type === 'post' && get_post_status($post->ID) !== 'trash', 'not_found', 404); demand(user_can($b->policy->execution_user_id, 'read_post', $post->ID), 'auth_required', 403); $canonicalUrl = get_permalink($post); demand(is_string($canonicalUrl) && $canonicalUrl !== '', 'read_failed', 503); $canonical = wp_parse_url($canonicalUrl); demand($canonical && ($canonical['scheme'] ?? '') === 'https' && strtolower($canonical['host'] ?? '') === strtolower($home['host'] ?? '') && ($canonical['port'] ?? null) === ($home['port'] ?? null) && !isset($canonical['user']) && !isset($canonical['pass']) && !isset($canonical['fragment']) && ($root === '/' || str_starts_with($canonical['path'] ?? '/', $root)), 'read_failed', 503); $categories = wp_get_post_categories($post->ID, ['fields' => 'ids']); demand(!is_wp_error($categories), 'read_failed', 503); $categories = array_map('intval', $categories); sort($categories, SORT_NUMERIC); $featured = (int) get_post_thumbnail_id($post->ID); demand(strlen((string) $post->post_title) <= 1000 && strlen((string) $post->post_content) <= 100000, 'content_too_large', 413); $projection = obj(['title' => (string) $post->post_title, 'slug' => (string) $post->post_name, 'excerpt' => (string) $post->post_excerpt, 'body_html' => (string) $post->post_content, 'author_id' => (int) $post->post_author, 'category_ids' => $categories, 'media_handle' => null]); return obj(['protocol_version' => 1, 'tenant_id' => $b->tenant_id, 'site_id' => $b->site_id, 'installation_id' => $b->installation_id, 'connection_id' => $b->connection_id, 'policy_hash' => $b->policy_hash, 'canonical_url' => $canonicalUrl, 'post_id' => (int) $post->ID, 'status' => (string) get_post_status($post->ID), 'title' => (string) $post->post_title, 'content' => (string) $post->post_content, 'category_ids' => $categories, 'featured_media_id' => $featured > 0 ? $featured : null, 'projection' => $projection, 'content_hash' => semantic_hash($projection)]); } function locate_published_amendment($b, $publication) { $url = $publication->canonical_url; $target = wp_parse_url($url); $home = wp_parse_url(home_url('/')); demand($target && $home && ($target['scheme'] ?? '') === 'https' && strtolower($target['host'] ?? '') === strtolower($home['host'] ?? '') && ($target['port'] ?? null) === ($home['port'] ?? null) && !isset($target['user']) && !isset($target['pass']) && !isset($target['fragment']) && !preg_match('/[\x00-\x20\\\\]/', $url), 'site_mismatch', 403); $id = url_to_postid($url); $post = $id > 0 ? get_post($id) : null; demand($post && $post->post_type === 'post' && get_post_status($post->ID) === 'publish' && (int) $post->ID === $publication->post_id && hash_equals(rtrim(get_permalink($post), '/'), rtrim($url, '/')) && user_can($b->policy->execution_user_id, 'edit_post', $post->ID), 'published_changed', 409); demand(in_array((int) $post->post_author, effective_policy($b)->allowed_author_ids, true), 'target_denied', 403); $categories = wp_get_post_categories($post->ID, ['fields' => 'ids']); demand(!is_wp_error($categories), 'read_failed', 503); $categories = array_map('intval', $categories); sort($categories, SORT_NUMERIC); foreach ($categories as $category) demand(in_array($category, effective_policy($b)->allowed_category_ids, true), 'target_denied', 403); $media_id = (int) get_post_thumbnail_id($post->ID); $before = obj(['title' => $post->post_title, 'slug' => $post->post_name, 'excerpt' => $post->post_excerpt, 'body_html' => $post->post_content, 'author_id' => (int) $post->post_author, 'category_ids' => $categories, 'media_handle' => null]); $current_hash = semantic_hash($before); $record = obj(['installation_id' => $b->installation_id, 'object_id' => (int) $post->ID, 'projection' => obj(['title' => $post->post_title, 'slug' => $post->post_name, 'excerpt' => $post->post_excerpt, 'body_html' => $post->post_content, 'author_id' => (int) $post->post_author, 'category_ids' => $categories, 'media_handle' => null]), 'adopted_media_id' => $media_id]); demand($current_hash === $publication->expected_draft_hash, 'published_changed', 409); return $record; } function validate_publication($b, $publication) { validate_projection($b, $publication->projection); if ($publication->mode === 'amend_published') return locate_published_amendment($b, $publication); if (in_array($publication->mode, ['publish_draft', 'amend_draft'], true)) { $record = owned_handle($b, 'publication', $publication->draft_handle); demand(get_post_status($record->object_id) === 'draft' && user_can($b->policy->execution_user_id, 'edit_post', $record->object_id), 'draft_changed', 409); $before = projection_read($b, $record); $hash = semantic_hash($before); demand($hash === $publication->expected_draft_hash, 'draft_changed', 409); if ($publication->mode === 'publish_draft') demand($hash === semantic_hash($record->projection) && user_can($b->policy->execution_user_id, 'publish_posts') && $hash === semantic_hash($publication->projection), 'draft_changed', 409); if ($publication->mode === 'amend_draft') { $after = $publication->projection; demand($before->title === $after->title && $before->slug === $after->slug && $before->excerpt === $after->excerpt && $before->author_id === $after->author_id && canonical($before->category_ids) === canonical($after->category_ids), 'amendment_scope_denied', 403); } return $record; } return null; } function validate_media($media) { demand(!str_contains($media->filename, '..') && preg_match('/\.(png|jpe?g)$/iD', $media->filename) && !preg_match('/\.(php[0-9]?|phtml|phar|cgi|pl|html?)(\.|$)/i', $media->filename), 'media_name_rejected', 415); $bytes = base64_decode($media->bytes_base64, true); demand(is_string($bytes) && base64_encode($bytes) === $media->bytes_base64 && strlen($bytes) <= 8388608, 'media_limit', 413); demand(hash_equals($media->sha256, hash('sha256', $bytes)), 'media_hash_mismatch'); demand(!preg_match('/<\?(?:php|=)|