diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ecb67..6d0ace4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: run: composer install --prefer-dist --no-progress - name: Run PHPCS - run: php -d xdebug.mode=off vendor/bin/phpcs --standard=WordPress src/ owlstack.php + run: php -d xdebug.mode=off vendor/bin/phpcs tests: name: PHPUnit (PHP ${{ matrix.php }}) diff --git a/CLAUDE.md b/CLAUDE.md index e8341f8..b8d08f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,6 @@ If you see `'owlstack-wp'` as a second argument in `__()`, `_e()`, `esc_html__() ## Tool Usage - **Prefer grep and find** to locate code — never guess file locations or class names -- **Run PHPCS after every change**: `php -d xdebug.mode=off vendor/bin/phpcs --standard=WordPress src/ owlstack.php` -- **Run I18n check specifically**: `php -d xdebug.mode=off vendor/bin/phpcs --standard=WordPress --sniffs=WordPress.WP.I18n src/ owlstack.php` +- **Run PHPCS after every change**: `php -d xdebug.mode=off vendor/bin/phpcs` (uses `phpcs.xml.dist` — WordPress security/I18n/DB/PHP sniffs on the repo's PSR-style code; do NOT run the raw `--standard=WordPress` ruleset, the codebase intentionally doesn't follow its formatting rules) +- **Run I18n check specifically**: `php -d xdebug.mode=off vendor/bin/phpcs --sniffs=WordPress.WP.I18n` - **Use `git diff`** to verify changes before committing diff --git a/owlstack.php b/owlstack.php index 3ac4c48..48f9cc6 100644 --- a/owlstack.php +++ b/owlstack.php @@ -4,7 +4,7 @@ * Plugin Name: Owlstack * Plugin URI: https://owlstack.dev * Description: Publish content to Telegram, X (Twitter), Facebook, Instagram, LinkedIn, Discord, and more — directly from WordPress. - * Version: 1.0.1 + * Version: 1.1.0 * Requires at least: 6.4 * Requires PHP: 8.1 * Author: Ali Hesari @@ -23,7 +23,7 @@ } // Plugin constants. -define('OWLSTACK_VERSION', '1.0.1'); +define('OWLSTACK_VERSION', '1.1.0'); define('OWLSTACK_FILE', __FILE__); define('OWLSTACK_DIR', plugin_dir_path(__FILE__)); define('OWLSTACK_URL', plugin_dir_url(__FILE__)); diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..86fb995 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,59 @@ + + + Owlstack WP coding standard: WordPress security, I18n, DB, and PHP sniffs on a PSR-style codebase. + + src + owlstack.php + uninstall.php + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/readme.txt b/readme.txt index 7c50e83..75bfe83 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Donate link: https://owlstack.dev Tags: social media, auto publish, telegram, twitter, facebook Requires at least: 6.4 Tested up to: 6.9 -Stable tag: 1.0.1 +Stable tag: 1.1.0 Requires PHP: 8.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -102,6 +102,10 @@ Yes. Configure proxy settings in **Owlstack > Settings** for servers that cannot Delivery logs are stored in a custom database table (`wp_owlstack_delivery_logs`). You can view them under **Owlstack > Delivery Logs**. += What is the OwlStack Cloud connection? = + +If you use the OwlStack Cloud dashboard, you can connect it to your site without sharing a WordPress username or Application Password. Go to **Owlstack > Cloud**, generate a site token, and paste it into the OwlStack dashboard together with your site URL. The token is scoped to a minimal set of plugin endpoints: OwlStack Cloud can create posts (as the author you choose, as a draft or published based on your setting), upload images for featured media, and remove posts it created — nothing else. Only a hash of the token is stored, you can revoke it at any time, and the plugin never makes outbound calls to OwlStack Cloud. The feature is entirely optional and inactive until you generate a token. + == Third-Party Services == This plugin connects to external third-party services to publish your content. Data such as post title, excerpt, URL, and featured image may be sent to the platforms you configure. **No data is sent unless you explicitly configure and enable a platform.** @@ -180,6 +184,12 @@ This plugin connects to external third-party services to publish your content. D == Changelog == += 1.1.0 = +* New: OwlStack Cloud connection (**Owlstack > Cloud**). Generate a revocable site token so the OwlStack Cloud dashboard can publish posts to this site without a WordPress username or Application Password. +* New: Incoming content controls — always draft, always publish, or honor the requested status; configurable post author and post type. +* New: REST endpoints under `owlstack/v1/cloud` for site info, post creation, image upload, and removal of Cloud-created posts. Token-authenticated; only a SHA-256 hash of the token is stored. +* The plugin makes no outbound calls to OwlStack Cloud; the feature is inactive until a token is generated. + = 1.0.0 = * Initial public release. * Support for 11 platforms: Telegram, X (Twitter), Facebook, Instagram, LinkedIn, Discord, Pinterest, Reddit, Slack, Tumblr, and WhatsApp. diff --git a/src/Admin/CloudSettingsPage.php b/src/Admin/CloudSettingsPage.php new file mode 100644 index 0000000..2f2747d --- /dev/null +++ b/src/Admin/CloudSettingsPage.php @@ -0,0 +1,145 @@ +tokens->isPaired(); + $tokenInfo = $this->tokens->info(); + $settings = $this->settings; + + require __DIR__ . '/views/cloud-settings-page.php'; + } + + /** + * Generate (or regenerate) the site token. + */ + public function handleGenerate(): void + { + $this->verifyRequest('owlstack_cloud_generate'); + + $token = $this->tokens->generate(get_current_user_id()); + + // One-time reveal for the current admin only. + set_transient(self::REVEAL_TRANSIENT . get_current_user_id(), $token, 5 * MINUTE_IN_SECONDS); + + $this->redirectBack('token-generated'); + } + + /** + * Revoke the site token. + */ + public function handleRevoke(): void + { + $this->verifyRequest('owlstack_cloud_revoke'); + + $this->tokens->revoke(); + + $this->redirectBack('token-revoked'); + } + + /** + * Save content-handling settings. + */ + public function handleSettings(): void + { + $this->verifyRequest('owlstack_cloud_settings'); + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in verifyRequest(). + $policy = isset($_POST['post_status_policy']) ? sanitize_key(wp_unslash($_POST['post_status_policy'])) : CloudSettings::POLICY_HONOR; + $author = isset($_POST['default_author']) ? absint(wp_unslash($_POST['default_author'])) : 0; + $type = isset($_POST['post_type']) ? sanitize_key(wp_unslash($_POST['post_type'])) : 'post'; + // phpcs:enable WordPress.Security.NonceVerification.Missing + + $this->settings->update($policy, $author, $type); + + $this->redirectBack('settings-saved'); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private function verifyRequest(string $action): void + { + if (! current_user_can('manage_options')) { + wp_die(esc_html__('You are not allowed to manage Owlstack Cloud settings.', 'owlstack')); + } + + check_admin_referer($action); + } + + private function redirectBack(string $notice): void + { + wp_safe_redirect( + add_query_arg( + ['page' => self::PAGE_SLUG, 'owlstack-notice' => $notice], + admin_url('admin.php'), + ), + ); + exit; + } +} diff --git a/src/Admin/views/cloud-settings-page.php b/src/Admin/views/cloud-settings-page.php new file mode 100644 index 0000000..f3fb2eb --- /dev/null +++ b/src/Admin/views/cloud-settings-page.php @@ -0,0 +1,162 @@ + +
+

+ + +
+

+
+ +
+

+
+ + +

+ +

+ + +

+ + +
+

+

+
+ + + + + + + + + +
+ + + +
+ + +
+ + + +
+ + + +

+ +

+ + + +

+ +
+ + + + + + + + + + + + + + + + + + + +
+
diff --git a/src/Admin/views/delivery-logs-page.php b/src/Admin/views/delivery-logs-page.php index f5337fd..74d18a2 100644 --- a/src/Admin/views/delivery-logs-page.php +++ b/src/Admin/views/delivery-logs-page.php @@ -32,9 +32,9 @@ diff --git a/src/Admin/views/platform-settings-page.php b/src/Admin/views/platform-settings-page.php index 2e73f52..b958f06 100644 --- a/src/Admin/views/platform-settings-page.php +++ b/src/Admin/views/platform-settings-page.php @@ -22,7 +22,7 @@ tokens->all()['post_status_policy'] ?? self::POLICY_HONOR; + + return in_array($policy, self::POLICIES, true) ? $policy : self::POLICY_HONOR; + } + + /** + * Resolve the final post status for a requested status. + */ + public function resolveStatus(string $requested): string + { + $requested = $requested === self::POLICY_DRAFT ? self::POLICY_DRAFT : self::POLICY_PUBLISH; + + return match ($this->statusPolicy()) { + self::POLICY_DRAFT => self::POLICY_DRAFT, + self::POLICY_PUBLISH => self::POLICY_PUBLISH, + default => $requested, + }; + } + + /** + * WP user ID that authors posts created by OwlStack Cloud. + * Falls back to the user who generated the token. + */ + public function defaultAuthor(): int + { + $data = $this->tokens->all(); + + $author = (int) ($data['default_author'] ?? 0); + if ($author > 0 && get_userdata($author) !== false) { + return $author; + } + + $creator = (int) ($data['created_by'] ?? 0); + if ($creator > 0 && get_userdata($creator) !== false) { + return $creator; + } + + return 0; + } + + /** + * Post type for posts created by OwlStack Cloud. + */ + public function postType(): string + { + $type = $this->tokens->all()['post_type'] ?? 'post'; + + return is_string($type) && $type !== '' && post_type_exists($type) ? $type : 'post'; + } + + /** + * Persist settings from the admin form. + */ + public function update(string $statusPolicy, int $defaultAuthor, string $postType): void + { + $data = $this->tokens->all(); + + $data['post_status_policy'] = in_array($statusPolicy, self::POLICIES, true) ? $statusPolicy : self::POLICY_HONOR; + $data['default_author'] = $defaultAuthor > 0 && get_userdata($defaultAuthor) !== false ? $defaultAuthor : 0; + $data['post_type'] = post_type_exists($postType) ? $postType : 'post'; + + $this->tokens->save($data); + } +} diff --git a/src/Cloud/CloudTokenService.php b/src/Cloud/CloudTokenService.php new file mode 100644 index 0000000..69770e8 --- /dev/null +++ b/src/Cloud/CloudTokenService.php @@ -0,0 +1,121 @@ +all(); + $data['token_hash'] = hash('sha256', $token); + $data['token_hint'] = substr($token, 0, strlen(self::TOKEN_PREFIX) + 4); + $data['created_at'] = time(); + $data['created_by'] = $userId; + $data['last_used_at'] = null; + + $this->save($data); + + return $token; + } + + /** + * Check a presented token against the stored hash. + */ + public function verify(string $token): bool + { + $hash = $this->all()['token_hash'] ?? ''; + + if (! is_string($hash) || $hash === '' || $token === '') { + return false; + } + + return hash_equals($hash, hash('sha256', $token)); + } + + /** + * Whether a token has been generated (site is pairable). + */ + public function isPaired(): bool + { + $hash = $this->all()['token_hash'] ?? ''; + + return is_string($hash) && $hash !== ''; + } + + /** + * Remove the token. Cloud requests fail until a new one is generated. + */ + public function revoke(): void + { + $data = $this->all(); + unset($data['token_hash'], $data['token_hint'], $data['created_at'], $data['created_by'], $data['last_used_at']); + $this->save($data); + } + + /** + * Record that the token was just used successfully. + */ + public function touch(): void + { + $data = $this->all(); + $data['last_used_at'] = time(); + $this->save($data); + } + + /** + * Token metadata for the admin UI (never the token itself). + * + * @return array{hint: string, created_at: ?int, created_by: ?int, last_used_at: ?int} + */ + public function info(): array + { + $data = $this->all(); + + return [ + 'hint' => is_string($data['token_hint'] ?? null) ? $data['token_hint'] : '', + 'created_at' => is_int($data['created_at'] ?? null) ? $data['created_at'] : null, + 'created_by' => is_int($data['created_by'] ?? null) ? $data['created_by'] : null, + 'last_used_at' => is_int($data['last_used_at'] ?? null) ? $data['last_used_at'] : null, + ]; + } + + /** + * @return array + */ + public function all(): array + { + $data = get_option(self::OPTION_KEY, []); + + return is_array($data) ? $data : []; + } + + /** + * @param array $data + */ + public function save(array $data): void + { + update_option(self::OPTION_KEY, $data, false); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 56ffdfc..23b4634 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -30,15 +30,19 @@ use Owlstack\Core\Platforms\Twitter\TwitterPlatform; use Owlstack\Core\Platforms\WhatsApp\WhatsAppPlatform; use Owlstack\Core\Publishing\Publisher; +use Owlstack\WordPress\Admin\CloudSettingsPage; use Owlstack\WordPress\Admin\DeliveryLogsPage; use Owlstack\WordPress\Admin\MetaBox; use Owlstack\WordPress\Admin\OptionsManager; use Owlstack\WordPress\Admin\SettingsPage; use Owlstack\WordPress\Auth\WpTokenStore; +use Owlstack\WordPress\Cloud\CloudSettings; +use Owlstack\WordPress\Cloud\CloudTokenService; use Owlstack\WordPress\Events\WpEventDispatcher; use Owlstack\WordPress\Http\WpHttpClient; use Owlstack\WordPress\Publishing\PostPublisher; use Owlstack\WordPress\Publishing\SendTo; +use Owlstack\WordPress\Rest\CloudRestController; use Owlstack\WordPress\Rest\OwlstackRestController; /** @@ -100,6 +104,7 @@ public function boot(): void // REST API. add_action('rest_api_init', [OwlstackRestController::class, 'register']); + add_action('rest_api_init', [CloudRestController::class, 'register']); // Post publishing hook. add_action('transition_post_status', [PostPublisher::class, 'handle'], 10, 3); @@ -323,6 +328,11 @@ private function registerAdminHooks(): void $logsPage = new DeliveryLogsPage(); add_action('admin_menu', [$logsPage, 'register']); + $cloudTokens = new CloudTokenService(); + $cloudPage = new CloudSettingsPage($cloudTokens, new CloudSettings($cloudTokens)); + add_action('admin_menu', [$cloudPage, 'register']); + $cloudPage->registerActions(); + add_action('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']); } @@ -357,6 +367,7 @@ public function enqueueAdminAssets(string $hook): void $owlstackPages = [ 'toplevel_page_owlstack', 'owlstack_page_owlstack-logs', + 'owlstack_page_owlstack-cloud', 'owlstack_page_owlstack-telegram', 'owlstack_page_owlstack-twitter', 'owlstack_page_owlstack-facebook', diff --git a/src/Rest/CloudRestController.php b/src/Rest/CloudRestController.php new file mode 100644 index 0000000..f7fd3e9 --- /dev/null +++ b/src/Rest/CloudRestController.php @@ -0,0 +1,374 @@ +\d+) + * + * All routes authenticate via the X-Owlstack-Token header against the + * hashed site token — no WordPress user session or nonce is involved. + * The token can only reach these routes; it grants no other WP access. + */ +class CloudRestController +{ + private const NAMESPACE = 'owlstack/v1'; + + private const META_KEY = '_owlstack_cloud'; + + public const ALLOWED_MIME_TYPES = [ + 'jpg|jpeg|jpe' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + ]; + + /** + * Register REST routes. + */ + public static function register(): void + { + register_rest_route(self::NAMESPACE, '/cloud/site', [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [self::class, 'siteInfo'], + 'permission_callback' => [self::class, 'checkToken'], + ]); + + register_rest_route(self::NAMESPACE, '/cloud/posts', [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [self::class, 'createPost'], + 'permission_callback' => [self::class, 'checkToken'], + 'args' => [ + 'title' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'content' => [ + 'required' => true, + 'type' => 'string', + // Sanitized with wp_kses_post in the callback. + ], + 'status' => [ + 'required' => false, + 'type' => 'string', + 'enum' => ['draft', 'publish'], + 'default' => 'publish', + 'sanitize_callback' => 'sanitize_key', + ], + 'featured_media' => [ + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + 'excerpt' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_textarea_field', + ], + 'slug' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_title', + ], + ], + ]); + + register_rest_route(self::NAMESPACE, '/cloud/media', [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [self::class, 'uploadMedia'], + 'permission_callback' => [self::class, 'checkToken'], + ]); + + register_rest_route(self::NAMESPACE, '/cloud/posts/(?P\d+)', [ + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => [self::class, 'deletePost'], + 'permission_callback' => [self::class, 'checkToken'], + 'args' => [ + 'id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + 'force' => [ + 'required' => false, + 'type' => 'boolean', + 'default' => false, + ], + ], + ]); + } + + // ── Auth ───────────────────────────────────────────────────────────── + + /** + * Validate the X-Owlstack-Token header against the stored hash. + */ + public static function checkToken(\WP_REST_Request $request): bool|\WP_Error + { + $tokens = self::tokens(); + + if (! $tokens->isPaired()) { + return new \WP_Error( + 'owlstack_not_paired', + __('No site token has been generated. Create one under Owlstack → Cloud.', 'owlstack'), + ['status' => 403], + ); + } + + $provided = (string) $request->get_header('X-Owlstack-Token'); + + if ($provided === '' || ! $tokens->verify($provided)) { + return new \WP_Error( + 'owlstack_invalid_token', + __('Invalid site token.', 'owlstack'), + ['status' => 401], + ); + } + + $tokens->touch(); + + return true; + } + + // ── Callbacks ──────────────────────────────────────────────────────── + + /** + * Site metadata used by OwlStack Cloud to validate and label the connection. + */ + public static function siteInfo(): \WP_REST_Response + { + $settings = self::settings(); + + return new \WP_REST_Response([ + 'name' => get_bloginfo('name'), + 'url' => home_url(), + 'icon' => get_site_icon_url() ?: null, + 'wp_version' => get_bloginfo('version'), + 'plugin_version' => defined('OWLSTACK_VERSION') ? OWLSTACK_VERSION : null, + 'post_status_policy' => $settings->statusPolicy(), + 'default_post_type' => $settings->postType(), + ]); + } + + /** + * Create a post from OwlStack Cloud content. + */ + public static function createPost(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $settings = self::settings(); + + $author = $settings->defaultAuthor(); + if ($author === 0) { + return new \WP_Error( + 'owlstack_no_author', + __('No valid author is configured for Cloud posts. Check Owlstack → Cloud settings.', 'owlstack'), + ['status' => 500], + ); + } + + $status = $settings->resolveStatus((string) $request->get_param('status')); + + $postarr = [ + 'post_title' => (string) $request->get_param('title'), + 'post_content' => wp_kses_post((string) $request->get_param('content')), + 'post_status' => $status, + 'post_type' => $settings->postType(), + 'post_author' => $author, + 'meta_input' => [self::META_KEY => 1], + ]; + + $excerpt = $request->get_param('excerpt'); + if (is_string($excerpt) && $excerpt !== '') { + $postarr['post_excerpt'] = $excerpt; + } + + $slug = $request->get_param('slug'); + if (is_string($slug) && $slug !== '') { + $postarr['post_name'] = $slug; + } + + $postId = wp_insert_post(wp_slash($postarr), true); + + if (is_wp_error($postId)) { + $postId->add_data(['status' => 500]); + + return $postId; + } + + $featuredMedia = (int) ($request->get_param('featured_media') ?? 0); + if ($featuredMedia > 0 && get_post_type($featuredMedia) === 'attachment') { + set_post_thumbnail($postId, $featuredMedia); + } + + return new \WP_REST_Response([ + 'id' => $postId, + 'status' => get_post_status($postId), + 'link' => get_permalink($postId), + 'edit_link' => admin_url(sprintf('post.php?post=%d&action=edit', $postId)), + ], 201); + } + + /** + * Sideload an image (raw request body, wp/v2-style headers). + */ + public static function uploadMedia(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $bits = $request->get_body(); + if ($bits === '') { + return new \WP_Error( + 'owlstack_empty_upload', + __('No file content received.', 'owlstack'), + ['status' => 400], + ); + } + + $filename = self::filenameFromDisposition((string) $request->get_header('Content-Disposition')); + if ($filename === '') { + return new \WP_Error( + 'owlstack_missing_filename', + __('Content-Disposition header with a filename is required.', 'owlstack'), + ['status' => 400], + ); + } + + $type = wp_check_filetype($filename, self::ALLOWED_MIME_TYPES); + if (empty($type['ext']) || empty($type['type'])) { + return new \WP_Error( + 'owlstack_unsupported_type', + __('Only JPEG, PNG, GIF, and WebP images are supported.', 'owlstack'), + ['status' => 415], + ); + } + + $upload = wp_upload_bits($filename, null, $bits); + if (! empty($upload['error'])) { + return new \WP_Error( + 'owlstack_upload_failed', + (string) $upload['error'], + ['status' => 500], + ); + } + + // Verify the actual bytes match an allowed image type, not just the name. + $real = wp_check_filetype_and_ext($upload['file'], $filename, self::ALLOWED_MIME_TYPES); + if (empty($real['ext']) || empty($real['type'])) { + wp_delete_file($upload['file']); + + return new \WP_Error( + 'owlstack_invalid_image', + __('The uploaded file is not a valid image.', 'owlstack'), + ['status' => 415], + ); + } + + $attachmentId = wp_insert_attachment( + [ + 'post_mime_type' => $real['type'], + 'post_title' => sanitize_file_name(pathinfo($filename, PATHINFO_FILENAME)), + 'post_status' => 'inherit', + 'post_author' => self::settings()->defaultAuthor(), + 'meta_input' => [self::META_KEY => 1], + ], + $upload['file'], + 0, + true, + ); + + if (is_wp_error($attachmentId)) { + wp_delete_file($upload['file']); + $attachmentId->add_data(['status' => 500]); + + return $attachmentId; + } + + require_once ABSPATH . 'wp-admin/includes/image.php'; + wp_update_attachment_metadata( + $attachmentId, + wp_generate_attachment_metadata($attachmentId, $upload['file']), + ); + + return new \WP_REST_Response([ + 'id' => $attachmentId, + 'source_url' => wp_get_attachment_url($attachmentId) ?: $upload['url'], + ], 201); + } + + /** + * Trash or delete a post previously created by OwlStack Cloud. + */ + public static function deletePost(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $postId = (int) $request->get_param('id'); + $post = get_post($postId); + + if (! $post instanceof \WP_Post) { + return new \WP_Error( + 'owlstack_post_not_found', + __('Post not found.', 'owlstack'), + ['status' => 404], + ); + } + + // The token may only touch content it created. + if (! get_post_meta($postId, self::META_KEY, true)) { + return new \WP_Error( + 'owlstack_forbidden_post', + __('This post was not created by OwlStack Cloud.', 'owlstack'), + ['status' => 403], + ); + } + + $force = (bool) $request->get_param('force'); + $result = $force ? wp_delete_post($postId, true) : wp_trash_post($postId); + + if (! $result) { + return new \WP_Error( + 'owlstack_delete_failed', + __('Failed to delete the post.', 'owlstack'), + ['status' => 500], + ); + } + + return new \WP_REST_Response([ + 'deleted' => true, + 'id' => $postId, + ]); + } + + // ── Services ───────────────────────────────────────────────────────── + + private static function tokens(): CloudTokenService + { + return new CloudTokenService(); + } + + private static function settings(): CloudSettings + { + return new CloudSettings(self::tokens()); + } + + /** + * Extract the filename from a Content-Disposition header. + */ + private static function filenameFromDisposition(string $disposition): string + { + if (! preg_match('/filename\*?=(?:UTF-8\'\')?"?([^";]+)"?/i', $disposition, $matches)) { + return ''; + } + + return sanitize_file_name(rawurldecode(trim($matches[1]))); + } +} diff --git a/src/Uninstaller.php b/src/Uninstaller.php index b1f92ad..9ee51cd 100644 --- a/src/Uninstaller.php +++ b/src/Uninstaller.php @@ -35,6 +35,7 @@ public static function uninstall(): void private static function removeOptions(): void { delete_option('owlstack_settings'); + delete_option('owlstack_cloud'); delete_option('owlstack_db_version'); } diff --git a/src/helpers.php b/src/helpers.php index fa87d61..e2add91 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -2,9 +2,10 @@ declare(strict_types=1); -// Prevent direct access. +// Prevent direct access. `return` (not `exit`) so the Composer `files` +// autoload doesn't kill CLI tools like PHPUnit that load outside WordPress. if (! defined('ABSPATH')) { - exit; + return; } use Owlstack\WordPress\Plugin; diff --git a/tests/Unit/Cloud/CloudSettingsTest.php b/tests/Unit/Cloud/CloudSettingsTest.php new file mode 100644 index 0000000..9eb376a --- /dev/null +++ b/tests/Unit/Cloud/CloudSettingsTest.php @@ -0,0 +1,127 @@ +tokens = new CloudTokenService(); + $this->settings = new CloudSettings($this->tokens); + } + + private function setOption(string $key, mixed $value): void + { + $data = $this->tokens->all(); + $data[$key] = $value; + $this->tokens->save($data); + } + + public function testDefaultPolicyIsHonor(): void + { + $this->assertSame('honor', $this->settings->statusPolicy()); + } + + public function testInvalidPolicyFallsBackToHonor(): void + { + $this->setOption('post_status_policy', 'pending'); + + $this->assertSame('honor', $this->settings->statusPolicy()); + } + + #[DataProvider('statusMatrix')] + public function testResolveStatusMatrix(string $policy, string $requested, string $expected): void + { + $this->setOption('post_status_policy', $policy); + + $this->assertSame($expected, $this->settings->resolveStatus($requested)); + } + + /** + * @return array + */ + public static function statusMatrix(): array + { + return [ + 'draft policy, publish requested' => ['draft', 'publish', 'draft'], + 'draft policy, draft requested' => ['draft', 'draft', 'draft'], + 'publish policy, publish requested' => ['publish', 'publish', 'publish'], + 'publish policy, draft requested' => ['publish', 'draft', 'publish'], + 'honor policy, publish requested' => ['honor', 'publish', 'publish'], + 'honor policy, draft requested' => ['honor', 'draft', 'draft'], + ]; + } + + public function testUnknownRequestedStatusResolvesToPublish(): void + { + $this->setOption('post_status_policy', 'honor'); + + $this->assertSame('publish', $this->settings->resolveStatus('private')); + } + + public function testDefaultAuthorFallsBackToTokenCreator(): void + { + $this->tokens->generate(7); + + $this->assertSame(7, $this->settings->defaultAuthor()); + } + + public function testDefaultAuthorPrefersConfiguredUser(): void + { + $this->tokens->generate(7); + $this->setOption('default_author', 1); + + $this->assertSame(1, $this->settings->defaultAuthor()); + } + + public function testDeletedAuthorFallsBack(): void + { + $this->tokens->generate(7); + $this->setOption('default_author', 99); + + $this->assertSame(7, $this->settings->defaultAuthor()); + } + + public function testNoValidAuthorReturnsZero(): void + { + $this->assertSame(0, $this->settings->defaultAuthor()); + } + + public function testPostTypeFallsBackToPost(): void + { + $this->setOption('post_type', 'nonexistent_type'); + + $this->assertSame('post', $this->settings->postType()); + } + + public function testUpdatePersistsValidValues(): void + { + $this->settings->update('draft', 7, 'page'); + + $this->assertSame('draft', $this->settings->statusPolicy()); + $this->assertSame(7, $this->settings->defaultAuthor()); + $this->assertSame('page', $this->settings->postType()); + } + + public function testUpdateRejectsInvalidValues(): void + { + $this->settings->update('pending', 99, 'nonexistent_type'); + + $this->assertSame('honor', $this->settings->statusPolicy()); + $this->assertSame(0, $this->settings->defaultAuthor()); + $this->assertSame('post', $this->settings->postType()); + } +} diff --git a/tests/Unit/Cloud/CloudTokenServiceTest.php b/tests/Unit/Cloud/CloudTokenServiceTest.php new file mode 100644 index 0000000..198076c --- /dev/null +++ b/tests/Unit/Cloud/CloudTokenServiceTest.php @@ -0,0 +1,107 @@ +service = new CloudTokenService(); + } + + public function testGenerateReturnsPrefixedToken(): void + { + $token = $this->service->generate(1); + + $this->assertStringStartsWith('owlstk_', $token); + $this->assertSame(7 + 64, strlen($token)); + } + + public function testPlaintextTokenIsNeverStored(): void + { + $token = $this->service->generate(1); + + $stored = wp_json_encode($GLOBALS['owlstack_test_options'][CloudTokenService::OPTION_KEY]); + + $this->assertStringNotContainsString($token, (string) $stored); + } + + public function testVerifyAcceptsCorrectToken(): void + { + $token = $this->service->generate(1); + + $this->assertTrue($this->service->verify($token)); + } + + public function testVerifyRejectsWrongToken(): void + { + $this->service->generate(1); + + $this->assertFalse($this->service->verify('owlstk_' . str_repeat('0', 64))); + $this->assertFalse($this->service->verify('')); + } + + public function testVerifyRejectsWhenNotPaired(): void + { + $this->assertFalse($this->service->isPaired()); + $this->assertFalse($this->service->verify('owlstk_' . str_repeat('0', 64))); + } + + public function testRegenerateInvalidatesOldToken(): void + { + $old = $this->service->generate(1); + $new = $this->service->generate(1); + + $this->assertFalse($this->service->verify($old)); + $this->assertTrue($this->service->verify($new)); + } + + public function testRevokeRemovesToken(): void + { + $token = $this->service->generate(1); + $this->service->revoke(); + + $this->assertFalse($this->service->isPaired()); + $this->assertFalse($this->service->verify($token)); + } + + public function testRevokePreservesSettings(): void + { + $this->service->generate(1); + $data = $this->service->all(); + $data['post_status_policy'] = 'draft'; + $this->service->save($data); + + $this->service->revoke(); + + $this->assertSame('draft', $this->service->all()['post_status_policy']); + } + + public function testInfoExposesHintButNotHash(): void + { + $token = $this->service->generate(7); + $info = $this->service->info(); + + $this->assertSame(substr($token, 0, 11), $info['hint']); + $this->assertSame(7, $info['created_by']); + $this->assertNull($info['last_used_at']); + $this->assertArrayNotHasKey('token_hash', $info); + } + + public function testTouchRecordsLastUse(): void + { + $this->service->generate(1); + $this->service->touch(); + + $this->assertIsInt($this->service->info()['last_used_at']); + } +} diff --git a/tests/Unit/Rest/CloudRestControllerTest.php b/tests/Unit/Rest/CloudRestControllerTest.php new file mode 100644 index 0000000..2cd0e42 --- /dev/null +++ b/tests/Unit/Rest/CloudRestControllerTest.php @@ -0,0 +1,223 @@ +tokens = new CloudTokenService(); + } + + private function request(array $params = [], array $headers = [], string $body = ''): \WP_REST_Request + { + return new \WP_REST_Request($params, $headers, $body); + } + + // ── checkToken ─────────────────────────────────────────────────────── + + public function testCheckTokenFailsWhenNotPaired(): void + { + $result = CloudRestController::checkToken($this->request()); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('owlstack_not_paired', $result->get_error_code()); + $this->assertSame(403, $result->get_error_data()['status']); + } + + public function testCheckTokenRejectsWrongToken(): void + { + $this->tokens->generate(1); + + $result = CloudRestController::checkToken( + $this->request([], ['X-Owlstack-Token' => 'owlstk_' . str_repeat('0', 64)]), + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('owlstack_invalid_token', $result->get_error_code()); + $this->assertSame(401, $result->get_error_data()['status']); + } + + public function testCheckTokenRejectsMissingHeader(): void + { + $this->tokens->generate(1); + + $result = CloudRestController::checkToken($this->request()); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('owlstack_invalid_token', $result->get_error_code()); + } + + public function testCheckTokenAcceptsValidTokenAndRecordsUse(): void + { + $token = $this->tokens->generate(1); + + $result = CloudRestController::checkToken( + $this->request([], ['X-Owlstack-Token' => $token]), + ); + + $this->assertTrue($result); + $this->assertIsInt($this->tokens->info()['last_used_at']); + } + + // ── createPost ─────────────────────────────────────────────────────── + + public function testCreatePostHonorsRequestedStatus(): void + { + $this->tokens->generate(1); + + $response = CloudRestController::createPost($this->request([ + 'title' => 'Hello', + 'content' => '

Body

', + 'status' => 'draft', + ])); + + $this->assertInstanceOf(\WP_REST_Response::class, $response); + $this->assertSame(201, $response->get_status()); + $this->assertSame('draft', $response->get_data()['status']); + } + + public function testCreatePostPolicyOverridesRequestedStatus(): void + { + $this->tokens->generate(1); + $data = $this->tokens->all(); + $data['post_status_policy'] = 'draft'; + $this->tokens->save($data); + + $response = CloudRestController::createPost($this->request([ + 'title' => 'Hello', + 'content' => '

Body

', + 'status' => 'publish', + ])); + + $this->assertSame('draft', $response->get_data()['status']); + } + + public function testCreatePostSanitizesHostileContent(): void + { + $this->tokens->generate(1); + + $response = CloudRestController::createPost($this->request([ + 'title' => 'Hello', + 'content' => '

Safe

', + 'status' => 'publish', + ])); + + $postId = $response->get_data()['id']; + $stored = $GLOBALS['owlstack_test_posts'][$postId]->post_content; + + $this->assertStringContainsString('

Safe

', $stored); + $this->assertStringNotContainsString('