reviews = $reviews ?? new Review_Service(); } /** * Validates one uploaded REST multipart file, sanitises it, encrypts it in * private transient storage, and creates an attachment record. * * The caller must have already run Session_Service::verify_guest_request(), * CSRF validation, upload rate limiting, and idempotency handling. This * method independently refreshes the guest session and permits a guest to * attach only to a comment (and, where supplied, message) that they own. * The `$field` value must name a single upload field in * WP_REST_Request::get_file_params(); nested/multiple file arrays are * intentionally rejected. * * @param array $session A verified active guest session. * @return array|\WP_Error Path-free asset descriptor on success. */ public function upload_from_rest_file(array $session, int $comment_id, \WP_REST_Request $request, ?int $message_id = null, string $field = 'file', ?Idempotency $idempotency = null, string $idempotency_route = '', string $idempotency_key = ''): array|\WP_Error { if ($comment_id < 1 || !preg_match('/\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/', $field)) { return Security::error('invalid_attachment', __('A valid attachment target is required.', 'commentmate'), 400); } $files = $request->get_file_params(); $file = $files[$field] ?? null; if (!is_array($file) || isset($file['tmp_name']) && !is_string($file['tmp_name']) || isset($file['name']) && !is_string($file['name']) || isset($file['type']) && !is_string($file['type']) || isset($file['error']) && !is_int($file['error']) && !is_string($file['error'])) { return Security::error('attachment_missing', __('Choose one file to upload.', 'commentmate'), 400); } $error = isset($file['error']) ? (int) $file['error'] : UPLOAD_ERR_NO_FILE; if (UPLOAD_ERR_OK !== $error) { return $this->upload_error($error); } $tmp_name = isset($file['tmp_name']) ? (string) $file['tmp_name'] : ''; if ('' === $tmp_name || !is_uploaded_file($tmp_name)) { return Security::error('attachment_unavailable', __('The uploaded file could not be verified.', 'commentmate'), 400); } $bytes = $this->read_limited_file($tmp_name); if (is_wp_error($bytes)) { return $bytes; } $original_name = isset($file['name']) ? (string) $file['name'] : 'attachment'; $declared_mime = isset($file['type']) ? (string) $file['type'] : ''; $prepared = $this->prepare_payload($bytes, $original_name, $declared_mime, true); if (is_wp_error($prepared)) { return $prepared; } // Do not let a user-supplied filename impersonate a generated screenshot. if (0 === strpos((string) $prepared['original_name'], self::SCREENSHOT_PREFIX)) { $prepared['original_name'] = 'attachment-' . (string) $prepared['original_name']; } return $this->persist_guest_asset($session, $comment_id, $message_id, $prepared, null, $idempotency, $idempotency_route, $idempotency_key); } /** * Sanitises and stores one cropped context screenshot for a guest-owned * comment. `$payload` may be decoded binary image data or a strict * `data:image/...;base64,...` URI. Only JPEG, PNG, and WebP are accepted. * * `$state` is persisted to comments.screenshot_state after the encrypted * asset record commits and must be `captured` or `partial`. Call * mark_screenshot_unavailable() if capture failed before a valid image was * produced. The returned descriptor contains `kind => screenshot`, never a * storage path, key, or checksum. * * @param array $session A verified active guest session. * @return array|\WP_Error Path-free asset descriptor on success. */ public function store_screenshot(array $session, int $comment_id, string $payload, ?string $declared_mime = null, string $state = 'captured', ?Idempotency $idempotency = null, string $idempotency_route = '', string $idempotency_key = ''): array|\WP_Error { if ($comment_id < 1 || !in_array($state, array('captured', 'partial'), true)) { return Security::error('invalid_screenshot', __('A valid screenshot target is required.', 'commentmate'), 400); } $decoded = $this->decode_screenshot_payload($payload, $declared_mime); if (is_wp_error($decoded)) { return $decoded; } $prepared = $this->prepare_payload($decoded['bytes'], 'screenshot', $decoded['mime'], false); if (is_wp_error($prepared)) { return $prepared; } if (!$this->is_image_mime((string) $prepared['mime_type'])) { return Security::error('invalid_screenshot', __('Screenshots must be JPEG, PNG, or WebP images.', 'commentmate'), 400); } $prepared['original_name'] = self::SCREENSHOT_PREFIX . $comment_id . '.' . (string) $prepared['extension']; return $this->persist_guest_asset($session, $comment_id, null, $prepared, $state, $idempotency, $idempotency_route, $idempotency_key); } /** * Marks a guest-owned comment as having no usable screenshot. This is safe * to call after a browser-side capture failure; it never creates a file. * * @param array $session A verified active guest session. * @return bool|\WP_Error True when the state was committed. */ public function mark_screenshot_unavailable(array $session, int $comment_id): bool|\WP_Error { $session = $this->fresh_guest_session($session); if (is_wp_error($session)) { return $session; } if ($comment_id < 1 || !$this->begin_guest_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $comment = $this->locked_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { $this->rollback(); return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } if ((int) $comment['author_session_id'] !== (int) $session['id']) { $this->rollback(); return Security::error('comment_not_owned', __('Only the comment creator may update its screenshot.', 'commentmate'), 403); } global $wpdb; $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. Database::table('commentmate_comments'), array( 'screenshot_state' => 'unavailable', 'updated_at' => current_time('mysql', true), ), array('id' => $comment_id), array('%s', '%s'), array('%d') ); if (false === $updated || !$this->commit()) { $this->rollback(); return Security::error('screenshot_not_saved', __('The screenshot state could not be saved.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], isset($session['_owner_user_id']) ? 'admin' : 'guest', (int) ($session['_owner_user_id'] ?? $session['id']), 'screenshot_unavailable', array('comment_id' => $comment_id)); return true; } /** * Returns private transient-storage capacity for one review, or for the * current active/paused review when `$review_id` is null. This method does * not authorize its caller; expose it only through an owner-guarded route. * * `usedBytes` is the encrypted on-disk footprint represented by transient * attachment rows, while the configured limit remains site-wide. * * @return array{reviewId:int|null,usedBytes:int,limitBytes:int,availableBytes:int,usage:float,warning:string,acceptingFiles:bool} */ public function storage_status(?int $review_id = null): array { if (null === $review_id) { $current = $this->reviews->current(); $review_id = is_array($current) ? (int) $current['id'] : 0; } $used = $review_id > 0 ? $this->transient_bytes($review_id) : 0; $limit = Private_Storage::byte_limit(); $usage = $limit > 0 ? round($used / $limit, 4) : 1.0; $warning = $used >= (int) ($limit * 0.85) ? 'critical' : ($used >= (int) ($limit * 0.70) ? 'warning' : 'ok'); return array( 'reviewId' => $review_id > 0 ? $review_id : null, 'usedBytes' => $used, 'limitBytes' => $limit, 'availableBytes' => max(0, $limit - $used), 'usage' => $usage, 'warning' => $warning, 'acceptingFiles' => $used < $limit, ); } /** * Finds one active asset belonging to the current guest's review. The * descriptor intentionally excludes storage keys, checksums, and URLs. * * @param array $session A verified active guest session. * @return array|\WP_Error `attachment_not_found` when absent. */ public function find_for_session(array $session, int $attachment_id): array|\WP_Error { $session = $this->fresh_guest_session($session); if (is_wp_error($session)) { return $session; } $asset = $this->find_row((int) $session['review_id'], $attachment_id, false); if (!is_array($asset)) { return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); } return $this->asset_descriptor($asset); } /** * Lists active, path-free asset descriptors for one comment in the current * guest's review. It is safe for a review-data endpoint after normal guest * session verification. * * @param array $session A verified active guest session. * @return list>|\WP_Error */ public function list_for_session(array $session, int $comment_id): array|\WP_Error { $session = $this->fresh_guest_session($session); if (is_wp_error($session)) { return $session; } return $this->descriptors_for_comment((int) $session['review_id'], $comment_id); } /** * Finds one active asset for the designated CommentMate owner. Owner and * capability checks happen inside this method so an admin controller cannot * accidentally expose cross-review data by direct object ID. * * @return array|\WP_Error Path-free asset descriptor. */ public function find_for_owner(int $review_id, int $attachment_id): array|\WP_Error { $owner = $this->require_owner(); if (is_wp_error($owner)) { return $owner; } $review_state = $this->ensure_unlocked_review($review_id); if (is_wp_error($review_state)) { return $review_state; } $asset = $this->find_row($review_id, $attachment_id, false); if (!is_array($asset)) { return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); } return $this->asset_descriptor($asset); } /** * Lists active, path-free assets for an owner-visible comment. * * @return list>|\WP_Error */ public function list_for_owner(int $review_id, int $comment_id): array|\WP_Error { $owner = $this->require_owner(); if (is_wp_error($owner)) { return $owner; } $review_state = $this->ensure_unlocked_review($review_id); if (is_wp_error($review_state)) { return $review_state; } return $this->descriptors_for_comment($review_id, $comment_id); } /** * Streams a private transient asset to the current guest response, or safely * redirects a previously promoted image to its ordinary Media Library URL. * Private streaming sends no-cache, no-sniff, same-origin headers and never * exposes storage metadata. A REST endpoint that calls it must stop normal * REST JSON rendering immediately after a true result (for example from a * dedicated raw-response hook); it must not append JSON after the response. * * PDFs are always download-only. Images are streamed inline only after they * were decoded and re-encoded by this service. * * @param array $session A verified active guest session. * @return bool|\WP_Error True after the full asset was emitted. */ public function stream_for_session(array $session, int $attachment_id): bool|\WP_Error { $session = $this->fresh_guest_session($session); if (is_wp_error($session)) { return $session; } $asset = $this->find_row((int) $session['review_id'], $attachment_id, false); if (!is_array($asset)) { return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); } return $this->stream_asset($asset); } /** * Streams a private transient asset, or redirects a promoted image, for the * designated CommentMate owner. It has the same raw-response integration * requirement as stream_for_session(). * * @return bool|\WP_Error True after the full asset was emitted. */ public function stream_for_owner(int $review_id, int $attachment_id): bool|\WP_Error { if (!Security::is_https()) { return Security::error('https_required', __('CommentMate requires HTTPS before private attachments can be downloaded.', 'commentmate'), 403); } $owner = $this->require_owner(); if (is_wp_error($owner)) { return $owner; } $review_state = $this->ensure_unlocked_review($review_id); if (is_wp_error($review_state)) { return $review_state; } $asset = $this->find_row($review_id, $attachment_id, false); if (!is_array($asset)) { return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); } return $this->stream_asset($asset); } /** * Decrypts one transient asset for a private owner export without exposing a * storage path or key. Files are capped at the same 5 MB payload limit used * at upload, so an in-memory ZIP entry remains bounded. * * @return array{descriptor:array,bytes:string,checksum:string}|\WP_Error */ public function export_plaintext_for_owner(int $review_id, int $attachment_id): array|\WP_Error { $owner = $this->require_owner(); if (is_wp_error($owner)) { return $owner; } $state = $this->ensure_unlocked_review($review_id); if (is_wp_error($state)) { return $state; } $asset = $this->find_row($review_id, $attachment_id, false); if (!is_array($asset) || 'transient' !== (string) $asset['disposition']) { return Security::error('attachment_not_found', __('That attachment is unavailable for export.', 'commentmate'), 404); } $bytes = ''; $result = $this->decrypt_asset( $asset, static function (string $chunk) use (&$bytes): bool { $bytes .= $chunk; return true; } ); if (is_wp_error($result)) { return $result; } return array( 'descriptor' => $this->asset_descriptor($asset), 'bytes' => $bytes, 'checksum' => (string) $result['checksum'], ); } /** Save a separate compressed copy; retain the review original and existing Media item. */ public function save_compressed_to_media(int $review_id, int $attachment_id, array $options): array|\WP_Error { $descriptor = $this->find_for_owner($review_id, $attachment_id); if (is_wp_error($descriptor)) return $descriptor; if (!current_user_can('upload_files')) return Security::error('media_forbidden', __('Your account cannot add files to the Media Library.', 'commentmate'), 403); $width = filter_var($options['maxWidth'] ?? null, FILTER_VALIDATE_INT); $quality = filter_var($options['compressionPercent'] ?? null, FILTER_VALIDATE_INT); if (!$width || $width < 1 || $width > 10000 || !$quality || $quality < 10 || $quality > 100 || !is_bool($options['convertToWebp'] ?? null) || !is_string($options['filename'] ?? null)) { return Security::error('invalid_compression', __('Choose a width from 1 to 10,000 pixels and compression from 10 to 100.', 'commentmate'), 400); } if ('attachment' !== $descriptor['kind'] || !$this->is_image_mime($descriptor['mimeType'])) return Security::error('asset_not_promotable', __('Only uploaded JPEG, PNG, or WebP images can be saved.', 'commentmate'), 400); $asset = $this->find_row($review_id, $attachment_id, false); if (!is_array($asset)) return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); $filename = sanitize_file_name(substr($options['filename'], 0, 200)); if ('' === $filename) $filename = (string) $asset['original_name']; $source_mime = 'promoted' === $asset['disposition'] ? (string) get_post_mime_type((int) $asset['media_attachment_id']) : (string) $asset['mime_type']; if (!$this->is_image_mime($source_mime)) return Security::error('attachment_unavailable', __('The saved image format is unavailable.', 'commentmate'), 410); $mime = $options['convertToWebp'] ? 'image/webp' : $source_mime; $extension = array('image/webp' => 'webp', 'image/jpeg' => 'jpg', 'image/png' => 'png')[$mime]; $filename = pathinfo($filename, PATHINFO_FILENAME) . '-compressed.' . $extension; $fingerprint = hash('sha256', wp_json_encode(array(2, $attachment_id, $asset['checksum'], $width, $quality, $mime, $filename))); $source = ''; $output = ''; $media_id = 0; $transaction = false; // Respect this explicit export format even when an image optimizer normally converts uploads. $preserve_format = static fn(array $formats): array => array(); add_filter('image_editor_output_format', $preserve_format, PHP_INT_MAX); try { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; global $wpdb; if (false === $wpdb->query('START TRANSACTION')) return Security::error('media_import_failed', __('The image could not be saved.', 'commentmate'), 503); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. $transaction = true; $lock = $this->lock_owner_review_for_mutation($review_id); if (is_wp_error($lock)) return $lock; // The review lock also serializes duplicate saves and review cleanup. $asset = $this->find_row($review_id, $attachment_id, false); if (!is_array($asset)) return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); $existing = get_posts(array('post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_key' => '_commentmate_compressed_source', 'meta_value' => $fingerprint)); if ($existing && is_file((string) get_attached_file((int) $existing[0]))) { return array('mediaAttachmentId' => (int) $existing[0]); } if ('promoted' === $asset['disposition']) { $source = (string) get_attached_file((int) $asset['media_attachment_id']); $uploads = wp_get_upload_dir(); $real = realpath($source); $base = realpath((string) $uploads['basedir']); if (!$real || !$base || is_link($source) || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) return Security::error('attachment_unavailable', __('The saved Media Library image is unavailable.', 'commentmate'), 410); } else { $source = $this->decrypt_to_private_temp($asset); if (is_wp_error($source)) return $source; } $editor = wp_get_image_editor($source); if (is_wp_error($editor)) return Security::error('compression_unavailable', __('Your server could not prepare this image.', 'commentmate'), 503); $size = $editor->get_size(); if ((int) $size['width'] * (int) $size['height'] > self::MAX_IMAGE_PIXELS) return Security::error('image_too_large', __('This image is too large to compress safely.', 'commentmate'), 413); $result = $editor->set_quality($quality); if (is_wp_error($result)) return Security::error('compression_unavailable', __('Your server could not set the image quality.', 'commentmate'), 503); if ((int) $size['width'] > $width) { $result = $editor->resize($width, null, false); if (is_wp_error($result)) return Security::error('compression_unavailable', __('Your server could not resize this image.', 'commentmate'), 503); } // Prepare outside public uploads, then let WordPress validate and import. $output = wp_tempnam($filename, dirname($source)); if (!$output) return Security::error('compression_unavailable', __('Temporary image storage is unavailable.', 'commentmate'), 503); $saved = $editor->save($output, $mime); if (is_wp_error($saved)) return Security::error('compression_unavailable', __('Your server does not support the selected image format.', 'commentmate'), 503); if ($saved['path'] !== $output) { $this->safe_unlink($output); $output = $saved['path']; } if (($saved['mime-type'] ?? '') !== $mime) return Security::error('compression_unavailable', __('The selected image format is not supported.', 'commentmate'), 503); $file = array('name' => $filename, 'tmp_name' => $output, 'error' => UPLOAD_ERR_OK, 'size' => filesize($output)); $import = media_handle_sideload($file, 0, pathinfo($filename, PATHINFO_FILENAME), array('post_status' => 'inherit')); if (is_wp_error($import)) return Security::error('media_import_failed', __('The compressed image could not be saved to the Media Library.', 'commentmate'), 503); $media_id = (int) $import; if (!add_post_meta($media_id, '_commentmate_compressed_source', $fingerprint, true)) return Security::error('media_import_failed', __('The compressed image could not be recorded.', 'commentmate'), 503); if (false === $wpdb->query('COMMIT')) return Security::error('media_import_failed', __('The compressed image could not be saved.', 'commentmate'), 503); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. $transaction = false; Audit::record($review_id, 'admin', get_current_user_id(), 'asset_compressed', array('attachment_id' => $attachment_id, 'media_attachment_id' => $media_id)); return array('mediaAttachmentId' => $media_id); } catch (\Throwable) { return Security::error('media_import_failed', __('The compressed image could not be saved. Please try again.', 'commentmate'), 503); } finally { remove_filter('image_editor_output_format', $preserve_format, PHP_INT_MAX); if ($transaction) { if ($media_id > 0) wp_delete_attachment($media_id, true); $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. } if (is_string($output) && '' !== $output) $this->safe_unlink($output); if ('transient' === ($asset['disposition'] ?? '') && is_string($source) && '' !== $source) $this->safe_unlink($source); } } /** * Securely imports a sanitised transient image into the normal WordPress * Media Library. Only reviewer-uploaded image attachments are eligible; * generated context screenshots and PDFs remain private. The source is decrypted into a * random 0600 private temporary file, checksum-verified, sideloaded with an * allowlisted MIME map, then compared again before the DB record is changed. * * A successful promotion retains the attachment row as a `promoted` record * and never deletes the resulting Media Library item. The encrypted transient * copy is retired best-effort; purge_retired_files() can retry it safely. * * @return array|\WP_Error Path-free promoted asset descriptor. */ public function promote_to_media(int $review_id, int $attachment_id): array|\WP_Error { $owner = $this->require_owner(); if (is_wp_error($owner)) { return $owner; } if (!current_user_can('upload_files')) { return Security::error('media_forbidden', __('Your account cannot add files to the Media Library.', 'commentmate'), 403); } $review_state = $this->ensure_unlocked_review($review_id); if (is_wp_error($review_state)) { return $review_state; } $asset = $this->find_row($review_id, $attachment_id, false); if (!is_array($asset)) { return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); } if ('promoted' === $asset['disposition']) { return $this->asset_descriptor($asset); } if ('transient' !== $asset['disposition'] || !$this->is_image_mime((string) $asset['mime_type']) || 0 === strpos((string) $asset['original_name'], self::SCREENSHOT_PREFIX)) { return Security::error('asset_not_promotable', __('Only uploaded JPEG, PNG, or WebP attachments can be added to the Media Library.', 'commentmate'), 400); } $tmp_path = $this->decrypt_to_private_temp($asset); if (is_wp_error($tmp_path)) { return $tmp_path; } $media_id = 0; $handled_path = ''; $promotion_transaction_open = false; try { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; $filename = $this->download_name($asset); // wp_handle_sideload() receives its file argument by reference. Keep the // array in a variable so PHP 8+ can pass it without throwing an Error. $sideload_file = array( 'name' => $filename, 'tmp_name' => $tmp_path, 'error' => UPLOAD_ERR_OK, 'size' => (int) filesize($tmp_path), ); $handled = wp_handle_sideload( $sideload_file, array( 'test_form' => false, 'mimes' => $this->promotion_mimes(), ), current_time('Y/m') ); if (!is_array($handled) || !empty($handled['error']) || empty($handled['file']) || empty($handled['type'])) { return Security::error('media_import_failed', __('The image could not be imported into the Media Library.', 'commentmate'), 503); } $handled_path = (string) $handled['file']; if (!hash_equals((string) $asset['mime_type'], (string) $handled['type']) || !is_file($handled_path) || !hash_equals((string) $asset['checksum'], (string) hash_file('sha256', $handled_path))) { $this->safe_unlink($handled_path); return Security::error('media_import_failed', __('The imported image did not pass verification.', 'commentmate'), 503); } $media_id = wp_insert_attachment( array( 'post_mime_type' => (string) $asset['mime_type'], 'post_title' => sanitize_text_field((string) pathinfo($filename, PATHINFO_FILENAME)), 'post_content' => '', 'post_status' => 'inherit', ), $handled_path, 0, true ); if (is_wp_error($media_id) || (int) $media_id < 1) { $this->safe_unlink($handled_path); return Security::error('media_import_failed', __('The image could not be added to the Media Library.', 'commentmate'), 503); } $media_id = (int) $media_id; $metadata = wp_generate_attachment_metadata($media_id, $handled_path); if (is_array($metadata)) { wp_update_attachment_metadata($media_id, $metadata); } // The media import intentionally happens before this short transaction so // cleanup is never held while WordPress generates thumbnails. The final // review-row lock serialises the semantic attachment transition with // cleanup's export snapshot and deletion manifest. global $wpdb; if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. wp_delete_attachment($media_id, true); return Security::error('attachment_unavailable', __('The attachment could not be promoted.', 'commentmate'), 503); } $promotion_transaction_open = true; $review_lock = $this->lock_owner_review_for_mutation($review_id); if (is_wp_error($review_lock)) { $this->rollback(); $promotion_transaction_open = false; wp_delete_attachment($media_id, true); return $review_lock; } $attachments = Database::table('commentmate_attachments'); $locked_asset = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT * FROM %i WHERE id = %d AND review_id = %d FOR UPDATE', $attachments, $attachment_id, $review_id), ARRAY_A ); if (!is_array($locked_asset)) { $this->rollback(); $promotion_transaction_open = false; wp_delete_attachment($media_id, true); return Security::error('asset_changed', __('The attachment changed before it could be promoted.', 'commentmate'), 409); } if ('promoted' === (string) $locked_asset['disposition'] && null === $locked_asset['deleted_at']) { if (!$this->commit()) { $this->rollback(); $promotion_transaction_open = false; wp_delete_attachment($media_id, true); return Security::error('attachment_unavailable', __('The attachment could not be promoted.', 'commentmate'), 503); } $promotion_transaction_open = false; wp_delete_attachment($media_id, true); return $this->asset_descriptor($locked_asset); } if ('transient' !== (string) $locked_asset['disposition'] || null !== $locked_asset['deleted_at'] || !hash_equals((string) $asset['checksum'], (string) $locked_asset['checksum']) || !hash_equals((string) $asset['storage_key'], (string) $locked_asset['storage_key']) || !$this->is_image_mime((string) $locked_asset['mime_type']) || 0 === strpos((string) $locked_asset['original_name'], self::SCREENSHOT_PREFIX)) { $this->rollback(); $promotion_transaction_open = false; wp_delete_attachment($media_id, true); return Security::error('asset_changed', __('The attachment changed before it could be promoted.', 'commentmate'), 409); } $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $attachments, array('disposition' => 'promoted', 'media_attachment_id' => $media_id), array( 'id' => $attachment_id, 'review_id' => $review_id, 'disposition' => 'transient', 'deleted_at' => null, ), array('%s', '%d'), array('%d', '%d', '%s', '%s') ); if (1 !== $updated || !$this->commit()) { $this->rollback(); $promotion_transaction_open = false; wp_delete_attachment($media_id, true); return Security::error('asset_changed', __('The attachment changed before it could be promoted.', 'commentmate'), false === $updated ? 503 : 409); } $promotion_transaction_open = false; } catch (\Throwable) { if ($promotion_transaction_open) { $this->rollback(); } if ($media_id > 0 && function_exists('wp_delete_attachment')) { wp_delete_attachment($media_id, true); } elseif ('' !== $handled_path) { $this->safe_unlink($handled_path); } return Security::error('media_import_failed', __('The image could not be imported into the Media Library.', 'commentmate'), 503); } finally { // wp_handle_sideload() moves the private temp file on success. If it did // not, remove it instead of leaving decrypted data in a temp location. $this->safe_unlink($tmp_path); } $this->delete_storage_file((string) $asset['storage_key']); $fresh = $this->find_row($review_id, $attachment_id, false); if (!is_array($fresh)) { return Security::error('asset_not_found', __('The promoted attachment could not be retrieved.', 'commentmate'), 503); } Audit::record($review_id, 'admin', get_current_user_id(), 'asset_promoted', array('attachment_id' => $attachment_id, 'media_attachment_id' => $media_id)); return $this->asset_descriptor($fresh); } /** * Soft-deletes one guest-owned review asset and retires its encrypted copy. * A promoted Media Library item is deliberately preserved. * * @param array $session A verified active guest session. * @return bool|\WP_Error True when the review record is no longer accessible. */ public function delete_for_session(array $session, int $attachment_id): bool|\WP_Error { $session = $this->fresh_guest_session($session); if (is_wp_error($session)) { return $session; } return $this->delete_asset((int) $session['review_id'], $attachment_id, (int) $session['id'], 'guest', (int) $session['id'], $session); } /** * Soft-deletes an owner-selected review asset while preserving a promoted * Media Library item. It is safe to retry after an interrupted private-file * unlink; purge_retired_files() only touches private random-name files. * * @return bool|\WP_Error True when the review record is no longer accessible. */ public function delete_for_owner(int $review_id, int $attachment_id): bool|\WP_Error { $owner = $this->require_owner(); if (is_wp_error($owner)) { return $owner; } $review_state = $this->ensure_unlocked_review($review_id); if (is_wp_error($review_state)) { return $review_state; } return $this->delete_asset($review_id, $attachment_id, null, 'admin', get_current_user_id()); } /** * Retries best-effort deletion of encrypted private files for assets already * marked `deleted` or `promoted`. It never follows symlinks and never deletes * Media Library files. Call it only from an owner-only cleanup workflow. * * @return int|\WP_Error Number of private files successfully removed. */ public function purge_retired_files(int $review_id, int $limit = 100): int|\WP_Error { $owner = $this->require_owner(); if (is_wp_error($owner)) { return $owner; } if ($review_id < 1) { return Security::error('invalid_review', __('A valid review is required.', 'commentmate'), 400); } $review_state = $this->ensure_unlocked_review($review_id); if (is_wp_error($review_state)) { return $review_state; } $limit = min(250, max(1, $limit)); global $wpdb; $rows = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT storage_key FROM %i WHERE review_id = %d AND disposition IN (\'deleted\', \'promoted\') ORDER BY id ASC LIMIT %d', Database::table('commentmate_attachments'), $review_id, $limit) ); $removed = 0; foreach (is_array($rows) ? $rows : array() as $storage_key) { if ($this->delete_storage_file((string) $storage_key)) { $removed++; } } if ($removed > 0) { Audit::record($review_id, 'admin', get_current_user_id(), 'retired_asset_files_purged', array('count' => $removed)); } return $removed; } /** * @param array $session * @param array{bytes:string,mime_type:string,original_name:string,checksum:string,extension:string,is_image:bool} $prepared * @return array|\WP_Error */ private function persist_guest_asset(array $session, int $comment_id, ?int $message_id, array $prepared, ?string $screenshot_state, ?Idempotency $idempotency = null, string $idempotency_route = '', string $idempotency_key = ''): array|\WP_Error { $session = $this->fresh_guest_session($session); if (is_wp_error($session)) { return $session; } if ($comment_id < 1 || (null !== $message_id && $message_id < 1)) { return Security::error('invalid_attachment', __('A valid attachment target is required.', 'commentmate'), 400); } if (!Private_Storage::ensure_directory()) { return Security::error('storage_unavailable', __('Private review storage is unavailable.', 'commentmate'), 503); } if (!$this->begin_guest_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $review_id = (int) $session['review_id']; $comment = $this->locked_comment($review_id, $comment_id); if (!is_array($comment)) { $this->rollback(); return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } // Context screenshots belong to the original comment, while ordinary // attachments may belong to a later reply. Do not accidentally require a // reply author to also own the root comment: that would make threaded // collaboration appear to work until a second reviewer adds a file. if (null === $message_id && (int) $comment['author_session_id'] !== (int) $session['id']) { $this->rollback(); return Security::error('comment_not_owned', __('Only the comment creator may add files here.', 'commentmate'), 403); } if (null !== $message_id) { $message = $this->locked_message($comment_id, $message_id); if (!is_array($message)) { $this->rollback(); return Security::error('message_not_found', __('That message no longer exists.', 'commentmate'), 404); } if ((int) $message['author_session_id'] !== (int) $session['id']) { $this->rollback(); return Security::error('message_not_owned', __('Only the message creator may add files.', 'commentmate'), 403); } } if (null !== $screenshot_state) { $existing_screenshot = $this->locked_screenshot($review_id, $comment_id); if (is_array($existing_screenshot)) { // A browser retry must not fill storage with duplicate context images. $payload = $this->asset_descriptor($existing_screenshot); $this->rollback(); if ($idempotency instanceof Idempotency && ('' === $idempotency_route || '' === $idempotency_key || !$idempotency->complete($session, $idempotency_route, $idempotency_key, 201, $payload))) { return Security::error('idempotency_unavailable', __('The screenshot could not be safely recorded. Please try again.', 'commentmate'), 503); } return $payload; } } if (null === $screenshot_state) { $quota = $this->attachment_quota_available($comment_id, $message_id, (string) $prepared['mime_type']); if (is_wp_error($quota)) { $this->rollback(); return $quota; } } $estimated_size = $this->encrypted_size_estimate(strlen((string) $prepared['bytes'])); $used = $this->locked_transient_bytes($review_id); if ($used + $estimated_size > Private_Storage::byte_limit()) { $this->rollback(); return Security::error('storage_limit_reached', __('Private review storage is full. Text comments are still available.', 'commentmate'), 413); } $key = $this->review_key($review_id); if (is_wp_error($key)) { $this->rollback(); return $key; } $storage_key = $this->new_storage_key($review_id); $storage_path = $this->storage_path($storage_key, true); if (is_wp_error($storage_path)) { $this->rollback(); return $storage_path; } $stored_size = $this->encrypt_bytes((string) $prepared['bytes'], $key, $storage_path); if (is_wp_error($stored_size)) { $this->rollback(); return $stored_size; } global $wpdb; $attachments = Database::table('commentmate_attachments'); $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $attachments, array( 'review_id' => $review_id, 'comment_id' => $comment_id, 'message_id' => $message_id, 'storage_key' => $storage_key, 'checksum' => (string) $prepared['checksum'], 'mime_type' => (string) $prepared['mime_type'], 'original_name' => (string) $prepared['original_name'], 'byte_size' => $stored_size, 'disposition' => 'transient', 'created_at' => current_time('mysql', true), ), array('%d', '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s') ); $attachment_id = (int) $wpdb->insert_id; if (false === $inserted || $attachment_id < 1) { $this->rollback(); $this->delete_storage_file($storage_key); return Security::error('attachment_not_saved', __('The attachment could not be saved.', 'commentmate'), 503); } if (null !== $screenshot_state) { $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. Database::table('commentmate_comments'), array( 'screenshot_state' => $screenshot_state, 'updated_at' => current_time('mysql', true), ), array('id' => $comment_id), array('%s', '%s'), array('%d') ); if (false === $updated) { $this->rollback(); $this->delete_storage_file($storage_key); return Security::error('screenshot_not_saved', __('The screenshot could not be saved.', 'commentmate'), 503); } } $asset = $this->find_row($review_id, $attachment_id, false); if (!is_array($asset)) { $this->rollback(); $this->delete_storage_file($storage_key); return Security::error('attachment_not_saved', __('The attachment could not be retrieved.', 'commentmate'), 503); } $payload = $this->asset_descriptor($asset); if ($idempotency instanceof Idempotency && ('' === $idempotency_route || '' === $idempotency_key || !$idempotency->complete($session, $idempotency_route, $idempotency_key, 201, $payload))) { $this->rollback(); $this->delete_storage_file($storage_key); return Security::error('idempotency_unavailable', __('The attachment could not be safely saved. Please try again.', 'commentmate'), 503); } if (!$this->commit()) { $this->rollback(); $this->delete_storage_file($storage_key); return Security::error('attachment_not_saved', __('The attachment could not be committed.', 'commentmate'), 503); } Audit::record( $review_id, 'guest', (int) $session['id'], null === $screenshot_state ? 'attachment_created' : 'screenshot_created', array('attachment_id' => $attachment_id, 'comment_id' => $comment_id, 'message_id' => $message_id) ); return $payload; } /** * @return array{bytes:string,mime:string}|\WP_Error */ private function decode_screenshot_payload(string $payload, ?string $declared_mime): array|\WP_Error { if (strlen($payload) > (self::MAX_INPUT_BYTES + 1) * 2) { return Security::error('file_too_large', __('The screenshot exceeds the 5 MB input limit.', 'commentmate'), 413); } $declared_mime = null === $declared_mime ? '' : strtolower(trim($declared_mime)); if (0 === strpos($payload, 'data:')) { if (!preg_match('/\Adata:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+\/=]+)\z/i', $payload, $matches)) { return Security::error('invalid_screenshot', __('The screenshot data is invalid.', 'commentmate'), 400); } $uri_mime = strtolower((string) $matches[1]); if ('' !== $declared_mime && !hash_equals($uri_mime, $declared_mime)) { return Security::error('mime_mismatch', __('The screenshot MIME type does not match its content.', 'commentmate'), 400); } $bytes = base64_decode((string) $matches[2], true); if (!is_string($bytes)) { return Security::error('invalid_screenshot', __('The screenshot data is invalid.', 'commentmate'), 400); } return array('bytes' => $bytes, 'mime' => $uri_mime); } return array('bytes' => $payload, 'mime' => $declared_mime); } /** * @return array{bytes:string,mime_type:string,original_name:string,checksum:string,extension:string,is_image:bool}|\WP_Error */ private function prepare_payload(string $bytes, string $original_name, string $declared_mime, bool $require_extension): array|\WP_Error { $length = strlen($bytes); if (0 === $length) { return Security::error('empty_file', __('The uploaded file is empty.', 'commentmate'), 400); } if ($length > self::MAX_INPUT_BYTES) { return Security::error('file_too_large', __('Files must be 5 MB or smaller.', 'commentmate'), 413); } $mime = $this->magic_mime($bytes); if (null === $mime) { return Security::error('unsupported_file', __('Only JPEG, PNG, WebP, or PDF files are allowed.', 'commentmate'), 400); } $declared_mime = strtolower(trim($declared_mime)); if ('' !== $declared_mime && !hash_equals($mime, $declared_mime)) { return Security::error('mime_mismatch', __('The file type does not match its decoded content.', 'commentmate'), 400); } if ($require_extension && !$this->filename_matches_mime($original_name, $mime)) { return Security::error('extension_mismatch', __('The file extension does not match its decoded content.', 'commentmate'), 400); } if ('application/pdf' === $mime) { $validated = $this->validate_pdf($bytes); if (is_wp_error($validated)) { return $validated; } $name = $this->normalised_output_name($original_name, $mime); return array( 'bytes' => $bytes, 'mime_type' => $mime, 'original_name' => $name, 'checksum' => hash('sha256', $bytes), 'extension' => 'pdf', 'is_image' => false, ); } $image = $this->sanitise_image($bytes, $mime); if (is_wp_error($image)) { return $image; } $name = $this->normalised_output_name($original_name, $mime); return array( 'bytes' => $image, 'mime_type' => $mime, 'original_name' => $name, 'checksum' => hash('sha256', $image), 'extension' => $this->extension_for_mime($mime), 'is_image' => true, ); } private function magic_mime(string $bytes): ?string { if (strlen($bytes) >= 3 && "\xFF\xD8\xFF" === substr($bytes, 0, 3)) { return 'image/jpeg'; } if (strlen($bytes) >= 8 && "\x89PNG\r\n\x1A\n" === substr($bytes, 0, 8)) { return 'image/png'; } if (strlen($bytes) >= 12 && 'RIFF' === substr($bytes, 0, 4) && 'WEBP' === substr($bytes, 8, 4)) { $riff_size = unpack('Vsize', substr($bytes, 4, 4)); if (is_array($riff_size) && isset($riff_size['size']) && (int) $riff_size['size'] + 8 === strlen($bytes)) { return 'image/webp'; } return null; } if (strlen($bytes) >= 8 && 1 === preg_match('/\A%PDF-(?:1\.[0-7]|2\.0)[\x09\x0A\x0C\x0D\x20]/', $bytes)) { return 'application/pdf'; } return null; } /** * Fully decodes an allowed image and re-encodes it, stripping metadata and * non-image payloads. It rejects animation and pixel bombs before GD is * asked to allocate a canvas. * * @return string|\WP_Error Sanitised bytes no larger than 1 MB. */ private function sanitise_image(string $bytes, string $mime): string|\WP_Error { if (!function_exists('getimagesizefromstring') || !function_exists('imagecreatefromstring')) { return Security::error('image_processor_unavailable', __('This server cannot safely process image uploads.', 'commentmate'), 503); } if ($this->is_animated_image($bytes, $mime)) { return Security::error('animated_image_denied', __('Animated images are not supported.', 'commentmate'), 400); } $info = @getimagesizefromstring($bytes); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed user image data may emit warnings. if (!is_array($info) || !isset($info[0], $info[1], $info[2])) { return Security::error('invalid_image', __('The image could not be decoded.', 'commentmate'), 400); } $width = (int) $info[0]; $height = (int) $info[1]; $image_type = (int) $info[2]; $expected_type = match ($mime) { 'image/jpeg' => IMAGETYPE_JPEG, 'image/png' => IMAGETYPE_PNG, 'image/webp' => IMAGETYPE_WEBP, default => 0, }; if ($width < 1 || $height < 1 || $image_type !== $expected_type || $width * $height > self::MAX_IMAGE_PIXELS) { return Security::error('invalid_image', __('The image dimensions are not allowed.', 'commentmate'), 400); } $image = @imagecreatefromstring($bytes); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed user image data may emit warnings. if (false === $image) { return Security::error('invalid_image', __('The image could not be decoded.', 'commentmate'), 400); } try { $encoded = $this->reencode_image_under_limit($image, $width, $height, $mime); } finally { unset($image); } return $encoded; } /** * @param mixed $source A GD image resource/GdImage. * @return string|\WP_Error */ private function reencode_image_under_limit(mixed $source, int $source_width, int $source_height, string $mime): string|\WP_Error { $width = $source_width; $height = $source_height; $quality = 88; for ($attempt = 0; $attempt < 11; $attempt++) { $canvas = $this->resized_canvas($source, $source_width, $source_height, $width, $height, $mime); if (is_wp_error($canvas)) { return $canvas; } $encoded = $this->encode_image($canvas, $mime, $quality); unset($canvas); if (is_wp_error($encoded)) { return $encoded; } if (strlen($encoded) <= self::MAX_IMAGE_OUTPUT_BYTES) { return $encoded; } if (1 === $width && 1 === $height) { break; } $width = max(1, (int) floor($width * 0.78)); $height = max(1, (int) floor($height * 0.78)); $quality = max(45, $quality - 7); } return Security::error('image_too_complex', __('The sanitised image could not be reduced below 1 MB.', 'commentmate'), 413); } /** * @param mixed $source A GD image resource/GdImage. * @return mixed|\WP_Error A true-colour GD image. */ private function resized_canvas(mixed $source, int $source_width, int $source_height, int $width, int $height, string $mime): mixed { $canvas = imagecreatetruecolor($width, $height); if (false === $canvas) { return Security::error('image_processor_unavailable', __('This server could not create a safe image canvas.', 'commentmate'), 503); } if ('image/jpeg' === $mime) { $background = imagecolorallocate($canvas, 255, 255, 255); imagefill($canvas, 0, 0, $background); } else { imagealphablending($canvas, false); imagesavealpha($canvas, true); $transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127); imagefill($canvas, 0, 0, $transparent); } if (!imagecopyresampled($canvas, $source, 0, 0, 0, 0, $width, $height, $source_width, $source_height)) { unset($canvas); return Security::error('invalid_image', __('The image could not be processed.', 'commentmate'), 400); } return $canvas; } /** * @param mixed $image A GD image resource/GdImage. * @return string|\WP_Error */ private function encode_image(mixed $image, string $mime, int $quality): string|\WP_Error { $level = ob_get_level(); ob_start(); try { $written = match ($mime) { 'image/jpeg' => imagejpeg($image, null, $quality), 'image/png' => imagepng($image, null, 9), 'image/webp' => function_exists('imagewebp') ? imagewebp($image, null, $quality) : false, default => false, }; } catch (\Throwable) { while (ob_get_level() > $level) { ob_end_clean(); } return Security::error('image_processor_unavailable', __('This server could not safely encode the image.', 'commentmate'), 503); } $encoded = ob_get_clean(); if (!$written || !is_string($encoded) || '' === $encoded) { return Security::error('image_processor_unavailable', __('This server could not safely encode the image.', 'commentmate'), 503); } return $encoded; } private function is_animated_image(string $bytes, string $mime): bool { if ('image/png' === $mime) { $offset = 8; $length = strlen($bytes); while ($offset + 12 <= $length) { $chunk = unpack('Nlength', substr($bytes, $offset, 4)); if (!is_array($chunk) || !isset($chunk['length'])) { return true; } $chunk_length = (int) $chunk['length']; if ($chunk_length < 0 || $offset + 12 + $chunk_length > $length) { return true; } $type = substr($bytes, $offset + 4, 4); if ('acTL' === $type) { return true; } $offset += 12 + $chunk_length; if ('IEND' === $type) { return $offset !== $length; } } return true; } if ('image/webp' === $mime) { $offset = 12; $length = strlen($bytes); while ($offset + 8 <= $length) { $chunk = unpack('Vlength', substr($bytes, $offset + 4, 4)); if (!is_array($chunk) || !isset($chunk['length'])) { return true; } $chunk_length = (int) $chunk['length']; $type = substr($bytes, $offset, 4); if ('ANIM' === $type || 'ANMF' === $type || $chunk_length < 0 || $offset + 8 + $chunk_length + ($chunk_length % 2) > $length) { return true; } $offset += 8 + $chunk_length + ($chunk_length % 2); } return $offset !== $length; } return false; } private function validate_pdf(string $bytes): bool|\WP_Error { if (!class_exists('finfo') || !defined('FILEINFO_MIME_TYPE')) { return Security::error('file_processor_unavailable', __('This server cannot safely validate PDF uploads.', 'commentmate'), 503); } try { $finfo = new \finfo(FILEINFO_MIME_TYPE); $detected = $finfo->buffer($bytes); } catch (\Throwable) { return Security::error('file_processor_unavailable', __('This server cannot safely validate PDF uploads.', 'commentmate'), 503); } if (!is_string($detected) || !hash_equals('application/pdf', strtolower($detected))) { return Security::error('mime_mismatch', __('The PDF MIME type could not be verified.', 'commentmate'), 400); } $trimmed = rtrim($bytes, "\x00\x09\x0A\x0C\x0D\x20"); if (!str_ends_with($trimmed, '%%EOF')) { return Security::error('invalid_pdf', __('The PDF structure is incomplete.', 'commentmate'), 400); } return true; } private function filename_matches_mime(string $filename, string $mime): bool { $extension = strtolower((string) pathinfo(wp_basename($filename), PATHINFO_EXTENSION)); return in_array($extension, $this->extensions_for_mime($mime), true); } /** * @return list */ private function extensions_for_mime(string $mime): array { return match ($mime) { 'image/jpeg' => array('jpg', 'jpeg'), 'image/png' => array('png'), 'image/webp' => array('webp'), 'application/pdf' => array('pdf'), default => array(), }; } private function extension_for_mime(string $mime): string { return match ($mime) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'application/pdf' => 'pdf', default => 'bin', }; } private function normalised_output_name(string $original_name, string $mime): string { $name = sanitize_file_name(wp_basename($original_name)); $stem = sanitize_text_field((string) pathinfo($name, PATHINFO_FILENAME)); $stem = trim(preg_replace('/[^A-Za-z0-9._ -]/', '-', $stem) ?? ''); if ('' === $stem) { $stem = 'attachment'; } $extension = $this->extension_for_mime($mime); $maximum = max(1, 255 - strlen($extension) - 1); if (function_exists('mb_substr')) { $stem = mb_substr($stem, 0, $maximum); } else { $stem = substr($stem, 0, $maximum); } return $stem . '.' . $extension; } private function is_image_mime(string $mime): bool { return in_array($mime, array('image/jpeg', 'image/png', 'image/webp'), true); } /** * @return string|\WP_Error */ private function read_limited_file(string $path): string|\WP_Error { $handle = @fopen($path, 'rb'); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded local stream or exclusive x+b creation is required for private encrypted IO; paths are validated and cannot use remote filesystem transports. if (false === $handle) { return Security::error('attachment_unavailable', __('The uploaded file could not be read.', 'commentmate'), 400); } try { $bytes = stream_get_contents($handle, self::MAX_INPUT_BYTES + 1); } finally { fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the local encrypted-file handle opened in this method, including all failure paths. } if (!is_string($bytes)) { return Security::error('attachment_unavailable', __('The uploaded file could not be read.', 'commentmate'), 400); } if (strlen($bytes) > self::MAX_INPUT_BYTES) { return Security::error('file_too_large', __('Files must be 5 MB or smaller.', 'commentmate'), 413); } return $bytes; } private function upload_error(int $error): \WP_Error { return match ($error) { UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => Security::error('file_too_large', __('Files must be 5 MB or smaller.', 'commentmate'), 413), UPLOAD_ERR_NO_FILE => Security::error('attachment_missing', __('Choose one file to upload.', 'commentmate'), 400), default => Security::error('attachment_unavailable', __('The uploaded file could not be received.', 'commentmate'), 400), }; } /** * @param array $session * @return array|\WP_Error */ private function fresh_guest_session(array $session): array|\WP_Error { $session_id = (int) ($session['id'] ?? 0); $review_id = (int) ($session['review_id'] ?? 0); if ($session_id < 1 || $review_id < 1) { return Security::error('session_required', __('Your review session has expired. Open the review link again.', 'commentmate'), 401); } // Owner requests have no guest cookie. The REST controller has verified // their nonce; the file transaction revalidates the active authorship row. if (isset($session['_owner_user_id']) && (int) $session['_owner_user_id'] === get_current_user_id() && Owner::can_manage_current_user()) { return $session; } $current = (new Session_Service())->current(); if (!is_array($current) || (int) $current['id'] !== $session_id || (int) $current['review_id'] !== $review_id) { return Security::error('session_required', __('Your review session has expired. Open the review link again.', 'commentmate'), 401); } return $current; } /** * Starts an InnoDB transaction and locks the current session/review pair so * pause, rotate, and end cannot race a file write. * * @param array $session */ private function begin_guest_mutation(array $session): bool { global $wpdb; if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return false; } if (!Session_Service::lock_current_for_mutation($session)) { $this->rollback(); return false; } return true; } private function commit(): bool { global $wpdb; return false !== $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. } private function rollback(): void { global $wpdb; $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. } /** * @return array|null */ private function locked_comment(int $review_id, int $comment_id): ?array { global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT id, author_session_id FROM %i WHERE id = %d AND review_id = %d AND deleted_at IS NULL FOR UPDATE', Database::table('commentmate_comments'), $comment_id, $review_id), ARRAY_A ); return is_array($row) ? $row : null; } /** * @return array|null */ private function locked_message(int $comment_id, int $message_id): ?array { global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT id, author_session_id FROM %i WHERE id = %d AND comment_id = %d AND deleted_at IS NULL FOR UPDATE', Database::table('commentmate_comment_messages'), $message_id, $comment_id), ARRAY_A ); return is_array($row) ? $row : null; } /** * @return array|null */ private function locked_screenshot(int $review_id, int $comment_id): ?array { global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT * FROM %i WHERE review_id = %d AND comment_id = %d AND deleted_at IS NULL AND original_name LIKE %s ORDER BY id DESC LIMIT 1 FOR UPDATE', Database::table('commentmate_attachments'), $review_id, $comment_id, $wpdb->esc_like(self::SCREENSHOT_PREFIX) . '%'), ARRAY_A ); return is_array($row) ? $row : null; } private function attachment_quota_available(int $comment_id, ?int $message_id, string $mime): bool|\WP_Error { if (!$this->is_image_mime($mime) && 'application/pdf' !== $mime) { return Security::error('unsupported_file', __('Only JPEG, PNG, WebP, or PDF files are allowed.', 'commentmate'), 400); } global $wpdb; $attachments = Database::table('commentmate_attachments'); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare( "SELECT mime_type, COUNT(*) AS total FROM %i WHERE comment_id = %d AND ((message_id = %d AND %d = 0) OR (message_id IS NULL AND %d = 1)) AND deleted_at IS NULL AND disposition IN ('transient', 'promoted') AND original_name NOT LIKE %s GROUP BY mime_type", $attachments, $comment_id, $message_id ?? 0, null === $message_id ? 1 : 0, null === $message_id ? 1 : 0, $wpdb->esc_like(self::SCREENSHOT_PREFIX) . '%' ), ARRAY_A ); if (null === $rows) { return Security::error('attachment_unavailable', __('Attachment limits could not be verified.', 'commentmate'), 503); } $image_count = 0; $pdf_count = 0; foreach ($rows as $row) { if ($this->is_image_mime((string) $row['mime_type'])) { $image_count += (int) $row['total']; } elseif ('application/pdf' === $row['mime_type']) { $pdf_count += (int) $row['total']; } } if ($this->is_image_mime($mime) && $image_count >= 3) { return Security::error('attachment_limit_reached', __('Each message can include up to three images.', 'commentmate'), 409); } if ('application/pdf' === $mime && $pdf_count >= 1) { return Security::error('attachment_limit_reached', __('Each message can include one PDF.', 'commentmate'), 409); } return true; } private function locked_transient_bytes(int $review_id): int { global $wpdb; $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT COALESCE(SUM(byte_size), 0) FROM %i WHERE review_id = %d AND disposition = \'transient\' AND deleted_at IS NULL', Database::table('commentmate_attachments'), $review_id) ); return max(0, (int) $value); } private function transient_bytes(int $review_id): int { return $this->locked_transient_bytes($review_id); } private function encrypted_size_estimate(int $plaintext_size): int { $frames = max(1, (int) ceil($plaintext_size / self::ENCRYPTION_CHUNK_BYTES)); return strlen(self::FILE_MAGIC) + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES + $plaintext_size + ($frames * (4 + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES)); } /** * @return string|\WP_Error A 32-byte Sodium secretstream key. */ private function review_key(int $review_id): string|\WP_Error { if ($review_id < 1 || !function_exists('sodium_base642bin')) { return Security::error('storage_unavailable', __('Encrypted review storage is unavailable.', 'commentmate'), 503); } $option = 'commentmate_review_key_' . $review_id; $material = get_option($option, ''); if (!is_string($material) || '' === $material) { // Review_Service::start() creates this non-autoloaded key before any // assets can be accepted. Recreating it here could leave a new orphan // key after verified cleanup has removed a review, so fail closed. return Security::error('storage_unavailable', __('The encrypted review storage key is unavailable.', 'commentmate'), 503); } try { $key = sodium_base642bin((string) $material, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); } catch (\Throwable) { return Security::error('storage_unavailable', __('The encrypted review storage key is unavailable.', 'commentmate'), 503); } if (SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES !== strlen($key)) { return Security::error('storage_unavailable', __('The encrypted review storage key is invalid.', 'commentmate'), 503); } return $key; } private function new_storage_key(int $review_id): string { return 'reviews/' . $review_id . '/commentmate-' . Security::token(32) . '.bin'; } /** * @return string|\WP_Error */ private function storage_path(string $storage_key, bool $create = false): string|\WP_Error { $root = $this->storage_root(); if (is_wp_error($root)) { return $root; } // Legacy flat keys remain readable/deletable for an in-place upgrade; all // new files use a review-scoped directory so cleanup can sweep orphaned // blobs and plaintext temps without touching another review. if (preg_match('/\Acommentmate-[A-Za-z0-9_-]{43}\.bin\z/', $storage_key)) { return $root . DIRECTORY_SEPARATOR . $storage_key; } if (!preg_match('/\Areviews\/([1-9][0-9]*)\/(commentmate-[A-Za-z0-9_-]{43}\.bin)\z/', $storage_key, $matches)) { return Security::error('storage_unavailable', __('The private storage path is invalid.', 'commentmate'), 503); } $review_id = (int) $matches[1]; $directory = $this->review_storage_directory($review_id, $create); if (is_wp_error($directory)) { return $directory; } return $directory . DIRECTORY_SEPARATOR . $matches[2]; } /** * @return string|\WP_Error */ private function review_storage_directory(int $review_id, bool $create): string|\WP_Error { if ($review_id < 1) { return Security::error('storage_unavailable', __('The private review storage path is invalid.', 'commentmate'), 503); } $root = $this->storage_root(); if (is_wp_error($root)) { return $root; } $reviews = $root . DIRECTORY_SEPARATOR . 'reviews'; if ($create && !is_dir($reviews) && !wp_mkdir_p($reviews)) { return Security::error('storage_unavailable', __('The private review storage directory is unavailable.', 'commentmate'), 503); } if (!is_dir($reviews) || is_link($reviews) || realpath($reviews) !== $reviews) { return Security::error('storage_unavailable', __('The private review storage directory is unsafe.', 'commentmate'), 503); } $directory = $reviews . DIRECTORY_SEPARATOR . $review_id; if ($create && !is_dir($directory) && !wp_mkdir_p($directory)) { return Security::error('storage_unavailable', __('The private review storage directory is unavailable.', 'commentmate'), 503); } if (!is_dir($directory) || is_link($directory) || realpath($directory) !== $directory) { return Security::error('storage_unavailable', __('The private review storage directory is unsafe.', 'commentmate'), 503); } @chmod($directory, 0700); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Enforce owner-only 0600 before private plaintext/encrypted bytes are written; failure aborts the operation. return $directory; } /** * @return string|\WP_Error */ private function storage_root(): string|\WP_Error { if (!Private_Storage::ensure_directory()) { return Security::error('storage_unavailable', __('Private review storage is unavailable.', 'commentmate'), 503); } $configured = untrailingslashit(Private_Storage::path()); if (is_link($configured)) { return Security::error('storage_unavailable', __('Private review storage is unavailable.', 'commentmate'), 503); } $root = realpath($configured); if (false === $root || !is_dir($root)) { return Security::error('storage_unavailable', __('Private review storage is unavailable.', 'commentmate'), 503); } return $root; } /** * @return int|\WP_Error Encrypted bytes written to the private file. */ private function encrypt_bytes(string $plaintext, string $key, string $path): int|\WP_Error { $handle = @fopen($path, 'x+b'); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded local stream or exclusive x+b creation is required for private encrypted IO; paths are validated and cannot use remote filesystem transports. if (false === $handle) { return Security::error('storage_unavailable', __('A private attachment file could not be created.', 'commentmate'), 503); } try { if (!@chmod($path, 0600)) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Enforce owner-only 0600 before private plaintext/encrypted bytes are written; failure aborts the operation. throw new \RuntimeException('private permissions'); } $permissions = @fileperms($path); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fail closed below. if (false === $permissions || 0 !== ($permissions & 0077)) { throw new \RuntimeException('private permissions'); } $push = sodium_crypto_secretstream_xchacha20poly1305_init_push($key); if (!is_array($push) || !isset($push[0], $push[1])) { throw new \RuntimeException('secretstream'); } $state = $push[0]; $header = $push[1]; if (!is_string($header) || SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES !== strlen($header)) { throw new \RuntimeException('secretstream'); } if (!$this->write_all($handle, self::FILE_MAGIC) || !$this->write_all($handle, $header)) { throw new \RuntimeException('write'); } $offset = 0; $length = strlen($plaintext); do { $chunk = $offset < $length ? substr($plaintext, $offset, self::ENCRYPTION_CHUNK_BYTES) : ''; $offset += strlen($chunk); $tag = $offset >= $length ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE; $ciphertext = sodium_crypto_secretstream_xchacha20poly1305_push($state, $chunk, '', $tag); if (!is_string($ciphertext) || !$this->write_all($handle, pack('N', strlen($ciphertext))) || !$this->write_all($handle, $ciphertext)) { throw new \RuntimeException('write'); } } while ($offset < $length); $stored_size = ftell($handle); if (false === $stored_size || !fflush($handle)) { throw new \RuntimeException('write'); } fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the local encrypted-file handle opened in this method, including all failure paths. return (int) $stored_size; } catch (\Throwable) { if (is_resource($handle)) { fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the local encrypted-file handle opened in this method, including all failure paths. } $this->safe_unlink($path); return Security::error('storage_unavailable', __('A private attachment file could not be saved.', 'commentmate'), 503); } } /** * @param resource $handle */ private function write_all($handle, string $bytes): bool { $length = strlen($bytes); $offset = 0; while ($offset < $length) { $written = fwrite($handle, substr($bytes, $offset)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Write all bytes to the exclusively created private handle, checking short writes before committing its metadata. if (false === $written || 0 === $written) { return false; } $offset += $written; } return true; } /** * @return array|null */ private function find_row(int $review_id, int $attachment_id, bool $include_deleted): ?array { if ($review_id < 1 || $attachment_id < 1) { return null; } global $wpdb; $attachments = Database::table('commentmate_attachments'); $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT a.* FROM %i AS a INNER JOIN %i AS c ON c.id = a.comment_id AND c.review_id = a.review_id LEFT JOIN %i AS m ON m.id = a.message_id AND m.comment_id = a.comment_id WHERE a.id = %d AND a.review_id = %d AND (%d = 1 OR (a.deleted_at IS NULL AND c.deleted_at IS NULL AND (a.message_id IS NULL OR (m.id IS NOT NULL AND m.deleted_at IS NULL)))) LIMIT 1', $attachments, $comments, $messages, $attachment_id, $review_id, (int) $include_deleted), ARRAY_A ); return is_array($row) ? $row : null; } /** * @return list> */ private function descriptors_for_comment(int $review_id, int $comment_id): array { if ($review_id < 1 || $comment_id < 1) { return array(); } global $wpdb; $attachments = Database::table('commentmate_attachments'); $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT a.* FROM %i AS a INNER JOIN %i AS c ON c.id = a.comment_id AND c.review_id = a.review_id AND c.deleted_at IS NULL LEFT JOIN %i AS m ON m.id = a.message_id AND m.comment_id = a.comment_id AND m.deleted_at IS NULL WHERE a.review_id = %d AND a.comment_id = %d AND a.deleted_at IS NULL AND (a.message_id IS NULL OR m.id IS NOT NULL) ORDER BY a.id ASC', $attachments, $comments, $messages, $review_id, $comment_id), ARRAY_A ); $result = array(); foreach (is_array($rows) ? $rows : array() as $row) { $result[] = $this->asset_descriptor($row); } return $result; } /** * @param array $asset * @return array */ private function asset_descriptor(array $asset): array { $original_name = (string) ($asset['original_name'] ?? 'attachment'); return array( 'id' => (int) $asset['id'], 'commentId' => (int) $asset['comment_id'], 'messageId' => null === $asset['message_id'] ? null : (int) $asset['message_id'], 'mimeType' => (string) $asset['mime_type'], 'originalName' => $original_name, 'byteSize' => (int) $asset['byte_size'], 'disposition' => (string) $asset['disposition'], 'mediaAttachmentId' => null === $asset['media_attachment_id'] ? null : (int) $asset['media_attachment_id'], // A Media Library URL is deliberately exposed only once the source has // been promoted. It is already public under normal WordPress media // semantics; transient review files retain their authenticated route. 'mediaUrl' => self::promoted_media_url($asset), 'createdAt' => (string) $asset['created_at'], 'kind' => 0 === strpos($original_name, self::SCREENSHOT_PREFIX) ? 'screenshot' : 'attachment', ); } /** * Returns a normal WordPress attachment URL only for a tracked, promoted * image. This allows UI callers to render a promoted image directly while * preserving private REST streaming for every transient review attachment. * * @param array $asset */ public static function promoted_media_url(array $asset): ?string { if ('promoted' !== (string) ($asset['disposition'] ?? '')) { return null; } $media_id = (int) ($asset['media_attachment_id'] ?? 0); if ($media_id < 1 || 'attachment' !== get_post_type($media_id) || !wp_attachment_is_image($media_id)) { return null; } $asset_mime = (string) ($asset['mime_type'] ?? ''); $media_mime = (string) get_post_mime_type($media_id); // WordPress image optimizers may legitimately convert the saved image. if (!in_array($asset_mime, array('image/jpeg', 'image/png', 'image/webp'), true) || !in_array($media_mime, array('image/jpeg', 'image/png', 'image/webp'), true)) { return null; } $url = wp_get_attachment_url($media_id); return is_string($url) && '' !== $url ? esc_url_raw($url) : null; } /** * @param array $asset * @return bool|\WP_Error */ private function stream_asset(array $asset): bool|\WP_Error { if ('promoted' === (string) ($asset['disposition'] ?? '')) { $media_url = self::promoted_media_url($asset); if (null === $media_url) { return Security::error('attachment_not_available', __('This promoted Media Library image is no longer available.', 'commentmate'), 410); } if (headers_sent()) { return Security::error('stream_unavailable', __('The attachment could not be streamed safely.', 'commentmate'), 503); } // The originating REST endpoint still authenticates the caller. The // destination is an existing WordPress Media Library image, which is // intentionally public after explicit owner promotion. Security::send_no_cache_headers(); header('Location: ' . $media_url, true, 302); return true; } if ('transient' !== $asset['disposition']) { return Security::error('attachment_not_available', __('This attachment is no longer available for private download.', 'commentmate'), 410); } if (headers_sent()) { return Security::error('stream_unavailable', __('The attachment could not be streamed safely.', 'commentmate'), 503); } $mime = (string) $asset['mime_type']; if (!$this->is_image_mime($mime) && 'application/pdf' !== $mime) { return Security::error('attachment_not_available', __('This attachment is unavailable.', 'commentmate'), 410); } Security::send_no_cache_headers(); $filename = $this->download_name($asset); $ascii_filename = preg_replace('/[^A-Za-z0-9._-]/', '_', $filename) ?? 'attachment'; header('Content-Type: ' . $mime); header('Content-Disposition: ' . ('application/pdf' === $mime ? 'attachment' : 'inline') . '; filename="' . $ascii_filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename)); header('X-Content-Type-Options: nosniff'); header('Cross-Origin-Resource-Policy: same-origin'); header('Content-Security-Policy: sandbox'); header('X-Download-Options: noopen'); $result = $this->decrypt_asset( $asset, static function (string $chunk): bool { echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Authenticated binary download with allowlisted MIME, nosniff and sandbox headers; HTML escaping would corrupt verified image/PDF bytes. return true; } ); return is_wp_error($result) ? $result : true; } /** * @param array $asset * @return string|\WP_Error Path to a 0600 temporary private plaintext file. */ private function decrypt_to_private_temp(array $asset): string|\WP_Error { $review_id = (int) ($asset['review_id'] ?? 0); // The encrypted asset already exists, so this directory must already be // present. Never recreate it here: cleanup may have removed the review // after the initial ownership check, and a failed promotion must not leave // an empty new directory behind after final verification. $root = $this->review_storage_directory($review_id, false); if (is_wp_error($root)) { return $root; } $path = $root . DIRECTORY_SEPARATOR . '.commentmate-tmp-' . Security::token(16); $handle = @fopen($path, 'x+b'); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded local stream or exclusive x+b creation is required for private encrypted IO; paths are validated and cannot use remote filesystem transports. if (false === $handle || !@chmod($path, 0600)) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Enforce owner-only 0600 before private plaintext/encrypted bytes are written; failure aborts the operation. if (is_resource($handle)) { fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the local encrypted-file handle opened in this method, including all failure paths. } $this->safe_unlink($path); return Security::error('storage_unavailable', __('A private temporary file could not be created.', 'commentmate'), 503); } try { $result = $this->decrypt_asset( $asset, function (string $chunk) use ($handle): bool { return $this->write_all($handle, $chunk); } ); if (is_wp_error($result) || !fflush($handle)) { throw new \RuntimeException('decrypt'); } fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the local encrypted-file handle opened in this method, including all failure paths. return $path; } catch (\Throwable) { if (is_resource($handle)) { fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the local encrypted-file handle opened in this method, including all failure paths. } $this->safe_unlink($path); return Security::error('attachment_corrupt', __('The private attachment could not be verified.', 'commentmate'), 503); } } /** * @param array $asset * @param callable(string):bool $sink * @return array{bytes:int,checksum:string}|\WP_Error */ private function decrypt_asset(array $asset, callable $sink): array|\WP_Error { $path = $this->storage_path((string) ($asset['storage_key'] ?? '')); if (is_wp_error($path) || is_link($path) || !is_file($path)) { return Security::error('attachment_not_available', __('The private attachment is unavailable.', 'commentmate'), 410); } clearstatcache(true, $path); if ((int) filesize($path) !== (int) $asset['byte_size']) { return Security::error('attachment_corrupt', __('The private attachment could not be verified.', 'commentmate'), 503); } $key = $this->review_key((int) $asset['review_id']); if (is_wp_error($key)) { return $key; } $handle = @fopen($path, 'rb'); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded local stream or exclusive x+b creation is required for private encrypted IO; paths are validated and cannot use remote filesystem transports. if (false === $handle) { return Security::error('attachment_not_available', __('The private attachment is unavailable.', 'commentmate'), 410); } try { $magic = $this->read_exact($handle, strlen(self::FILE_MAGIC)); $header = $this->read_exact($handle, SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES); if (!is_string($magic) || !hash_equals(self::FILE_MAGIC, $magic) || !is_string($header)) { throw new \RuntimeException('header'); } $state = sodium_crypto_secretstream_xchacha20poly1305_init_pull($header, $key); $hash = hash_init('sha256'); $total = 0; $seen_final = false; while (true) { $length_bytes = fread($handle, 4); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Read a bounded binary secretstream frame; WP_Filesystem whole-file reads cannot enforce this streaming memory bound. if ('' === $length_bytes && feof($handle)) { break; } if (false === $length_bytes || 4 !== strlen($length_bytes)) { throw new \RuntimeException('frame'); } $unpacked = unpack('Nlength', $length_bytes); $length = is_array($unpacked) && isset($unpacked['length']) ? (int) $unpacked['length'] : 0; if ($length < SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES || $length > self::ENCRYPTION_CHUNK_BYTES + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES) { throw new \RuntimeException('frame'); } $ciphertext = $this->read_exact($handle, $length); if (!is_string($ciphertext)) { throw new \RuntimeException('frame'); } $pulled = sodium_crypto_secretstream_xchacha20poly1305_pull($state, $ciphertext, ''); if (!is_array($pulled) || !isset($pulled[0], $pulled[1]) || !is_string($pulled[0])) { throw new \RuntimeException('ciphertext'); } $plaintext = $pulled[0]; $tag = (int) $pulled[1]; if (SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL === $tag) { $trailing = fread($handle, 1); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Read a bounded binary secretstream frame; WP_Filesystem whole-file reads cannot enforce this streaming memory bound. if (false === $trailing || '' !== $trailing) { throw new \RuntimeException('trailing'); } $seen_final = true; } elseif (SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE !== $tag) { throw new \RuntimeException('tag'); } hash_update($hash, $plaintext); $total += strlen($plaintext); if (self::MAX_INPUT_BYTES < $total || !$sink($plaintext)) { throw new \RuntimeException('sink'); } if ($seen_final) { break; } } if (!$seen_final) { throw new \RuntimeException('final'); } $checksum = hash_final($hash); if (!preg_match('/\A[a-f0-9]{64}\z/', (string) $asset['checksum']) || !hash_equals((string) $asset['checksum'], $checksum)) { throw new \RuntimeException('checksum'); } fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the local encrypted-file handle opened in this method, including all failure paths. return array('bytes' => $total, 'checksum' => $checksum); } catch (\Throwable) { if (is_resource($handle)) { fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the local encrypted-file handle opened in this method, including all failure paths. } return Security::error('attachment_corrupt', __('The private attachment could not be verified.', 'commentmate'), 503); } } /** * @param resource $handle * @return string|false */ private function read_exact($handle, int $length): string|false { $data = ''; while (strlen($data) < $length) { $chunk = fread($handle, $length - strlen($data)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Read a bounded binary secretstream frame; WP_Filesystem whole-file reads cannot enforce this streaming memory bound. if (false === $chunk || '' === $chunk) { return false; } $data .= $chunk; } return $data; } /** * @param array $asset */ private function download_name(array $asset): string { $name = sanitize_file_name((string) ($asset['original_name'] ?? 'attachment')); if ('' === $name) { $name = 'attachment.' . $this->extension_for_mime((string) ($asset['mime_type'] ?? '')); } return $name; } /** * @return array */ private function promotion_mimes(): array { return array( 'jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', ); } /** * @return bool|\WP_Error */ /** * @param array|null $guest_session * @return bool|\WP_Error */ private function delete_asset(int $review_id, int $attachment_id, ?int $required_session_id, string $actor_type, ?int $actor_id, ?array $guest_session = null): bool|\WP_Error { if ($review_id < 1 || $attachment_id < 1) { return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); } global $wpdb; if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('attachment_unavailable', __('The attachment could not be deleted.', 'commentmate'), 503); } if (is_array($guest_session) && !Session_Service::lock_current_for_mutation($guest_session)) { $this->rollback(); return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } if (!is_array($guest_session)) { $review_lock = $this->lock_owner_review_for_mutation($review_id); if (is_wp_error($review_lock)) { $this->rollback(); return $review_lock; } } $attachments = Database::table('commentmate_attachments'); $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT a.*, c.author_session_id AS comment_author_session_id, m.author_session_id AS message_author_session_id FROM %i AS a INNER JOIN %i AS c ON c.id = a.comment_id AND c.deleted_at IS NULL LEFT JOIN %i AS m ON m.id = a.message_id AND m.comment_id = a.comment_id WHERE a.id = %d AND a.review_id = %d FOR UPDATE', $attachments, $comments, $messages, $attachment_id, $review_id), ARRAY_A ); if (!is_array($row)) { $this->rollback(); return Security::error('attachment_not_found', __('That attachment is unavailable.', 'commentmate'), 404); } $asset_author_session_id = null === $row['message_id'] ? (int) $row['comment_author_session_id'] : (int) $row['message_author_session_id']; if (null !== $required_session_id && $asset_author_session_id !== $required_session_id) { $this->rollback(); return Security::error('attachment_not_owned', __('Only the attachment creator may delete this attachment.', 'commentmate'), 403); } if (null !== $row['deleted_at'] || 'deleted' === $row['disposition']) { $this->rollback(); return true; } $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('UPDATE %i SET disposition = \'deleted\', deleted_at = %s WHERE id = %d AND review_id = %d AND deleted_at IS NULL', $attachments, current_time('mysql', true), $attachment_id, $review_id) ); if (1 !== $updated || !$this->commit()) { $this->rollback(); return Security::error('attachment_unavailable', __('The attachment could not be deleted.', 'commentmate'), 503); } $file_removed = 'transient' !== $row['disposition'] || $this->delete_storage_file((string) $row['storage_key']); Audit::record( $review_id, $actor_type, $actor_id, 'attachment_deleted', array('attachment_id' => $attachment_id, 'private_file_removed' => $file_removed, 'media_preserved' => 'promoted' === $row['disposition']) ); return true; } private function delete_storage_file(string $storage_key): bool { $path = $this->storage_path($storage_key); if (is_wp_error($path)) { return false; } if (!file_exists($path)) { return true; } if (is_link($path) || !is_file($path)) { return false; } wp_delete_file($path); return !file_exists($path); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- file may race a successful earlier cleanup. } private function safe_unlink(string $path): void { if ('' !== $path && is_file($path) && !is_link($path)) { wp_delete_file($path); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort disposal of a controlled temporary path. } } /** * @return true|\WP_Error */ private function ensure_unlocked_review(int $review_id): bool|\WP_Error { if ($review_id < 1) { return Security::error('review_not_found', __('That review is unavailable.', 'commentmate'), 404); } global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT id, cleanup_locked FROM %i WHERE id = %d LIMIT 1', Database::table('commentmate_reviews'), $review_id), ARRAY_A ); if (!is_array($row)) { return Security::error('review_not_found', __('That review is unavailable.', 'commentmate'), 404); } if ((int) $row['cleanup_locked'] !== 0) { return Security::error('review_cleanup_locked', __('This review is locked for verified cleanup.', 'commentmate'), 409); } return true; } /** * Locks the parent review for an owner-side asset mutation. The caller must * already hold an explicit transaction so cleanup cannot lock, snapshot, or * erase the review between an owner asset read and its final child-row write. * * @return true|\WP_Error */ private function lock_owner_review_for_mutation(int $review_id): bool|\WP_Error { if ($review_id < 1) { return Security::error('review_not_found', __('That review is unavailable.', 'commentmate'), 404); } global $wpdb; $review = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private asset metadata and quota/ownership state live in plugin tables; fresh reads prevent stale access or quota decisions. $wpdb->prepare('SELECT id, cleanup_locked FROM %i WHERE id = %d FOR UPDATE', Database::table('commentmate_reviews'), $review_id), ARRAY_A ); if (!is_array($review)) { return Security::error('review_not_found', __('That review is unavailable.', 'commentmate'), 404); } if ((int) $review['cleanup_locked'] !== 0) { return Security::error('review_cleanup_locked', __('This review is locked for verified cleanup.', 'commentmate'), 409); } return true; } /** * @return true|\WP_Error */ private function require_owner(): bool|\WP_Error { if (!Owner::can_manage_current_user()) { return Security::error('admin_forbidden', __('You do not have permission to manage CommentMate attachments.', 'commentmate'), 403); } return true; } }