From 6f6ce57da74e926cea0aa3c8331c9021a9829f69 Mon Sep 17 00:00:00 2001 From: dchiamp Date: Tue, 21 Jul 2026 10:48:21 -0700 Subject: [PATCH 1/5] Add iCal feed endpoint for Events CPT Serves GET /wp-json/fuxt/v1/events.ics for the frontend calendar (section-events-calendar.vue) to subscribe to via FullCalendar's icalendar plugin, Google Calendar, or any webcal-compatible app. --- includes/class-plugin.php | 1 + includes/class-rest-ical-controller.php | 291 ++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 includes/class-rest-ical-controller.php diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 5fc9279..39e7bb3 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -24,6 +24,7 @@ public function init() { ( new REST_Acf_Controller() )->init(); ( new REST_Posts_Controller() )->init(); ( new REST_User_Controller() )->init(); + ( new REST_Ical_Controller() )->init(); $this->update_check(); } diff --git a/includes/class-rest-ical-controller.php b/includes/class-rest-ical-controller.php new file mode 100644 index 0000000..c1adf55 --- /dev/null +++ b/includes/class-rest-ical-controller.php @@ -0,0 +1,291 @@ + \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => '__return_true', + ), + ) + ); + } + + public function get_item( $request ) { + // ?debug=1 dumps raw post/meta data as JSON — admin only. + if ( ! empty( $request['debug'] ) && current_user_can( 'manage_options' ) ) { + $this->output_debug(); + } + + $ics = $this->generate_ics(); + + // Hook into rest_pre_serve_request instead of calling exit() directly. + // Priority 15 runs after rest_send_cors_headers (priority 10), so our + // wildcard header wins even if WordPress narrowed it to a specific origin. + add_filter( + 'rest_pre_serve_request', + function () use ( $ics ) { + header( 'Content-Type: text/calendar; charset=utf-8' ); + header( 'Content-Disposition: inline; filename="pearl-events.ics"' ); + header( 'Cache-Control: no-cache, must-revalidate' ); + header( 'Access-Control-Allow-Origin: *' ); + header( 'Access-Control-Allow-Methods: GET' ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $ics; + return true; // tells WP_REST_Server we handled the response; skips JSON output. + }, + 15 + ); + + return new \WP_REST_Response( null, 200 ); + } + + private function output_debug() { + $events = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => 3, + ) + ); + + $out = array( + 'post_type' => self::POST_TYPE, + 'event_count' => count( $events ), + 'field_keys' => array( + 'start' => self::FIELD_START_DATE, + 'end' => self::FIELD_END_DATE, + 'venue' => self::FIELD_VENUE_NAME, + 'addr' => self::FIELD_VENUE_ADDR, + ), + 'events' => array(), + ); + + foreach ( $events as $event ) { + $all_meta = get_post_meta( $event->ID ); + // Strip ACF field-key entries (underscore-prefixed) to keep output readable. + $readable_meta = array(); + foreach ( $all_meta as $key => $values ) { + if ( strpos( $key, '_' ) !== 0 ) { + $readable_meta[ $key ] = $values[0]; + } + } + + $out['events'][] = array( + 'id' => $event->ID, + 'title' => $event->post_title, + 'start_raw' => get_post_meta( $event->ID, self::FIELD_START_DATE, true ), + 'end_raw' => get_post_meta( $event->ID, self::FIELD_END_DATE, true ), + 'venue_name_raw' => get_post_meta( $event->ID, self::FIELD_VENUE_NAME, true ), + 'addr_raw' => get_post_meta( $event->ID, self::FIELD_VENUE_ADDR, true ), + 'all_meta_keys' => $readable_meta, + ); + } + + header( 'Content-Type: application/json; charset=utf-8' ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo wp_json_encode( $out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); + exit; + } + + private function generate_ics() { + $events = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'ASC', + ) + ); + + $tz = wp_timezone(); + + // wp_timezone_string() returns a UTC offset (e.g. "+00:00") when WordPress is + // set to a manual offset rather than an IANA city name. RFC 5545 requires an + // IANA timezone identifier for TZID, so fall back to the PHP DateTimeZone name. + $tz_str = $tz->getName(); + if ( str_starts_with( $tz_str, '+' ) || str_starts_with( $tz_str, '-' ) ) { + $tz_str = 'UTC'; + } + + $domain = wp_parse_url( home_url(), PHP_URL_HOST ); + + $lines = array( + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//' . get_bloginfo( 'name' ) . '//Events//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:' . $this->escape_text( get_bloginfo( 'name' ) . ' Events' ), + 'X-WR-TIMEZONE:' . $tz_str, + ); + + foreach ( $events as $event ) { + $start_raw = get_post_meta( $event->ID, self::FIELD_START_DATE, true ); + $end_raw = get_post_meta( $event->ID, self::FIELD_END_DATE, true ); + $venue_name = get_post_meta( $event->ID, self::FIELD_VENUE_NAME, true ); + $venue_addr = get_post_meta( $event->ID, self::FIELD_VENUE_ADDR, true ); + + if ( ! $start_raw ) { + continue; + } + + $start_dt = $this->parse_datetime( $start_raw, $tz ); + if ( ! $start_dt ) { + continue; + } + + $end_dt = $end_raw ? $this->parse_datetime( $end_raw, $tz ) : null; + + // Build LOCATION from venue name + address. + $location_parts = array_filter( array( $venue_name, $venue_addr ) ); + $location = implode( ', ', $location_parts ); + + $uid = 'event-' . $event->ID . '@' . $domain; + $dtstamp = gmdate( 'Ymd\THis\Z' ); + + // For UTC, RFC 5545 requires the Z suffix with no TZID parameter. + $is_utc = ( 'UTC' === $tz_str ); + $dt_format = $is_utc ? 'Ymd\THis\Z' : 'Ymd\THis'; + $dtstart_key = $is_utc ? 'DTSTART' : 'DTSTART;TZID=' . $tz_str; + $dtend_key = $is_utc ? 'DTEND' : 'DTEND;TZID=' . $tz_str; + + $vevent_lines = array( + 'BEGIN:VEVENT', + 'UID:' . $uid, + 'DTSTAMP:' . $dtstamp, + $dtstart_key . ':' . $start_dt->format( $dt_format ), + ); + + if ( $end_dt ) { + $vevent_lines[] = $dtend_key . ':' . $end_dt->format( $dt_format ); + } + + $vevent_lines[] = 'SUMMARY:' . $this->escape_text( $event->post_title ); + + if ( $location ) { + $vevent_lines[] = 'LOCATION:' . $this->escape_text( $location ); + } + + $url = get_permalink( $event->ID ); + if ( $url ) { + $vevent_lines[] = 'URL:' . $url; + } + + $vevent_lines[] = 'END:VEVENT'; + + $lines = array_merge( $lines, $vevent_lines ); + } + + $lines[] = 'END:VCALENDAR'; + + $folded = array_map( array( $this, 'fold_line' ), $lines ); + + return implode( "\r\n", $folded ) . "\r\n"; + } + + /** + * Parse a datetime string in any common ACF format. + * ACF datetime pickers can store/return in various formats depending on field config. + */ + private function parse_datetime( $raw, $tz ) { + $formats = array( + 'm/d/Y g:i a', // "06/06/2026 9:00 am" — ACF US datetime return format + 'm/d/Y G:i', // "06/06/2026 9:00" + 'Y-m-d H:i:s', // "2026-06-06 09:00:00" — ACF default save format + 'Y-m-d H:i', // "2026-06-06 09:00" + 'Ymd\THis', // "20260606T090000" + ); + + foreach ( $formats as $format ) { + $dt = \DateTime::createFromFormat( $format, $raw, $tz ); + if ( $dt !== false ) { + return $dt; + } + } + + // Last resort — let PHP try to parse it (handles ISO 8601 and others). + $ts = strtotime( $raw ); + if ( $ts !== false ) { + $dt = new \DateTime( '@' . $ts ); + $dt->setTimezone( $tz ); + return $dt; + } + + return null; + } + + /** + * Escape special characters per RFC 5545 §3.3.11 (TEXT). + */ + private function escape_text( $text ) { + $text = str_replace( '\\', '\\\\', $text ); + $text = str_replace( ';', '\\;', $text ); + $text = str_replace( ',', '\\,', $text ); + $text = str_replace( "\r\n", '\\n', $text ); + $text = str_replace( "\n", '\\n', $text ); + $text = str_replace( "\r", '', $text ); + return $text; + } + + /** + * Fold a long iCalendar content line at 75 octets per RFC 5545 §3.1. + * Continuation lines begin with a single space. + */ + private function fold_line( $line ) { + if ( strlen( $line ) <= 75 ) { + return $line; + } + + $output = ''; + while ( strlen( $line ) > 75 ) { + $output .= substr( $line, 0, 75 ) . "\r\n "; + $line = substr( $line, 75 ); + } + + return $output . $line; + } +} From 0ecc43f5c105d8ad749a7a4c4cd381c67e933e3d Mon Sep 17 00:00:00 2001 From: dchiamp Date: Tue, 21 Jul 2026 10:54:36 -0700 Subject: [PATCH 2/5] Extend Posts endpoint: multi-term filtering, term_operator, priority ordering, include-by-ID Adds taxonomy-scoped term_slug lookups (comma-separated, IN/AND via term_operator), priority_term_slug for pinning matching posts first, and an include param for ID-list lookups across all exposed post types. --- includes/class-rest-posts-controller.php | 22 +- includes/utils/class-post.php | 262 +++++++++++++++++++++-- 2 files changed, 261 insertions(+), 23 deletions(-) diff --git a/includes/class-rest-posts-controller.php b/includes/class-rest-posts-controller.php index b0f3c7b..24bf979 100644 --- a/includes/class-rest-posts-controller.php +++ b/includes/class-rest-posts-controller.php @@ -83,7 +83,21 @@ public function get_collection_params() { 'type' => 'string', ), 'term_slug' => array( - 'description' => __( 'Terms slug', 'fuxt-api' ), + 'description' => __( 'Term slug(s). Comma-separated for multiple.', 'fuxt-api' ), + 'type' => 'string', + ), + 'taxonomy' => array( + 'description' => __( 'Limit term_slug lookups to specific taxonomies (comma separated). Needed when a slug exists in multiple taxonomies.', 'fuxt-api' ), + 'type' => 'string', + ), + 'term_operator' => array( + 'description' => __( 'How multiple term_slug values within one taxonomy combine: IN (any) or AND (all).', 'fuxt-api' ), + 'type' => 'string', + 'default' => 'IN', + 'enum' => array( 'IN', 'AND' ), + ), + 'priority_term_slug' => array( + 'description' => __( 'Terms slug used for priority ordering. Matching posts are returned first, then remaining posts that match all other filters.', 'fuxt-api' ), 'type' => 'string', ), 'orderby' => array( @@ -122,6 +136,12 @@ public function get_collection_params() { 'description' => __( 'Page number', 'fuxt-api' ), 'type' => 'integer', ), + 'include' => array( + 'description' => __( 'Limit result set to specific post IDs (comma separated). Spans all exposed post types unless post_type is given; results preserve the given ID order.', 'fuxt-api' ), + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'sanitize_callback' => 'wp_parse_id_list', + ), 'post_type' => array( 'description' => __( 'Post type', 'fuxt-api' ), 'type' => 'string', diff --git a/includes/utils/class-post.php b/includes/utils/class-post.php index 68b0d65..430ccd1 100644 --- a/includes/utils/class-post.php +++ b/includes/utils/class-post.php @@ -329,12 +329,15 @@ public static function can_user_read_post( $user_id, $post_id ) { /** * Get posts. * - * @param \WP_REST_Request $params Parameters + * @param \WP_REST_Request $params Parameters. + * @param array $additional_fields Additional fields. + * @param array $query_options Optional query options. * - * @return array + * @return array|null */ - public static function get_posts( $params, $additional_fields ) { + public static function get_posts( $params, $additional_fields, $query_options = array() ) { $query_params = array(); + $parent_post = null; if ( isset( $params['post_parent_uri'] ) ) { $parent_post = self::get_post_by_uri( $params['post_parent_uri'] ); @@ -354,41 +357,166 @@ public static function get_posts( $params, $additional_fields ) { } if ( ! empty( $params['term_slug'] ) ) { + if ( isset( $params['post_type'] ) ) { + $requested_types = array_map( 'trim', explode( ',', $params['post_type'] ) ); + $valid_types = Utils::get_post_types(); + $validated_types = array_values( array_filter( $requested_types, fn( $t ) => in_array( $t, $valid_types, true ) ) ); + if ( empty( $validated_types ) ) { + return null; + } + $searchable_taxonomies = array_unique( array_merge( ...array_map( 'get_object_taxonomies', $validated_types ) ) ); + } else { + $searchable_taxonomies = get_taxonomies(); + } + + // Optional comma-separated `taxonomy` param narrows term lookups -- + // required when the same slug exists in multiple taxonomies (e.g. + // price-1 lives in both `pearl-feature` and `price`). + if ( ! empty( $params['taxonomy'] ) ) { + $requested_taxonomies = array_filter( array_map( 'trim', explode( ',', $params['taxonomy'] ) ) ); + $searchable_taxonomies = array_values( array_intersect( (array) $searchable_taxonomies, $requested_taxonomies ) ); + if ( empty( $searchable_taxonomies ) ) { + return null; + } + } + + $term_slugs = array_values( + array_unique( + array_filter( array_map( 'trim', explode( ',', $params['term_slug'] ) ) ) + ) + ); + if ( empty( $term_slugs ) ) { + return null; + } + + // `term_operator=AND` requires posts to have every listed term + // within a taxonomy; default `IN` matches any of them. + $term_operator = 'IN'; + if ( isset( $params['term_operator'] ) && 'AND' === strtoupper( (string) $params['term_operator'] ) ) { + $term_operator = 'AND'; + } + $terms = get_terms( array( - 'taxonomy' => get_taxonomies(), - 'slug' => $params['term_slug'], + 'taxonomy' => $searchable_taxonomies, + 'slug' => $term_slugs, + 'hide_empty' => false, ) ); - if ( ! empty( $terms ) ) { - $query_params['tax_query'] = array( - array( - 'taxonomy' => $terms[0]->taxonomy, - 'field' => 'slug', - 'terms' => $terms[0]->slug, - ), + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return null; + } + + $by_taxonomy = array(); + foreach ( $terms as $term ) { + $by_taxonomy[ $term->taxonomy ][] = $term->slug; + } + + // One clause per taxonomy; multiple taxonomies always combine with AND. + $clauses = array(); + foreach ( $by_taxonomy as $taxonomy => $slugs ) { + $clauses[] = array( + 'taxonomy' => $taxonomy, + 'field' => 'slug', + 'terms' => array_values( array_unique( $slugs ) ), + 'operator' => $term_operator, ); + } - $taxonomy = get_taxonomy( $terms[0]->taxonomy ); - $query_params['post_type'] = $taxonomy->object_type; + if ( count( $clauses ) > 1 ) { + $query_params['tax_query'] = array_merge( + array( 'relation' => 'AND' ), + $clauses + ); + } else { + $query_params['tax_query'] = $clauses; + } - // Default order is menu_order for hierarchical post types such as page. - if ( is_post_type_hierarchical( $parent_post->post_type ) ) { - $query_params['orderby'] = 'menu_order'; - $query_params['order'] = 'ASC'; + if ( ! isset( $params['post_type'] ) ) { + $taxonomy_obj = get_taxonomy( array_key_first( $by_taxonomy ) ); + if ( $taxonomy_obj && ! empty( $taxonomy_obj->object_type ) ) { + $query_params['post_type'] = count( $taxonomy_obj->object_type ) === 1 + ? reset( $taxonomy_obj->object_type ) + : array_values( $taxonomy_obj->object_type ); + } else { + $query_params['post_type'] = 'post'; + } + } + + if ( $parent_post instanceof \WP_Post && is_post_type_hierarchical( $parent_post->post_type ) ) { + $query_params['orderby'] = 'menu_order'; + $query_params['order'] = 'ASC'; + } + } + + $priority_by_taxonomy = array(); + if ( ! empty( $params['priority_term_slug'] ) ) { + $priority_post_type = null; + if ( isset( $params['post_type'] ) ) { + $priority_post_type = $params['post_type']; + } elseif ( isset( $query_params['post_type'] ) ) { + $priority_post_type = is_array( $query_params['post_type'] ) + ? implode( ',', $query_params['post_type'] ) + : $query_params['post_type']; + } + + if ( null !== $priority_post_type ) { + $requested_types = array_map( 'trim', explode( ',', (string) $priority_post_type ) ); + $valid_types = Utils::get_post_types(); + $validated_types = array_values( array_filter( $requested_types, fn( $t ) => in_array( $t, $valid_types, true ) ) ); + if ( empty( $validated_types ) ) { + return null; } + $searchable_taxonomies = array_unique( array_merge( ...array_map( 'get_object_taxonomies', $validated_types ) ) ); } else { + $searchable_taxonomies = get_taxonomies(); + } + + $priority_slugs = array_values( + array_unique( + array_filter( array_map( 'trim', explode( ',', $params['priority_term_slug'] ) ) ) + ) + ); + if ( empty( $priority_slugs ) ) { + return null; + } + + $priority_terms = get_terms( + array( + 'taxonomy' => $searchable_taxonomies, + 'slug' => $priority_slugs, + 'hide_empty' => false, + ) + ); + if ( is_wp_error( $priority_terms ) || empty( $priority_terms ) ) { return null; } + + foreach ( $priority_terms as $priority_term ) { + $priority_by_taxonomy[ $priority_term->taxonomy ][] = $priority_term->slug; + } + foreach ( $priority_by_taxonomy as $taxonomy => $slugs ) { + $priority_by_taxonomy[ $taxonomy ] = array_values( array_unique( $slugs ) ); + } } if ( ! isset( $query_params['post_type'] ) ) { if ( isset( $params['post_type'] ) ) { - if ( ! in_array( $params['post_type'], Utils::get_post_types() ) ) { + $requested_types = array_map( 'trim', explode( ',', $params['post_type'] ) ); + $valid_types = Utils::get_post_types(); + $validated_types = array_filter( $requested_types, fn( $t ) => in_array( $t, $valid_types ) ); + + if ( empty( $validated_types ) ) { return null; } - $query_params['post_type'] = $params['post_type']; + + $query_params['post_type'] = count( $validated_types ) === 1 + ? reset( $validated_types ) + : array_values( $validated_types ); + } elseif ( ! empty( $params['include'] ) ) { + // ID lookups span every exposed post type by default. + $query_params['post_type'] = array_values( Utils::get_post_types() ); } else { $query_params['post_type'] = 'post'; } @@ -410,8 +538,98 @@ public static function get_posts( $params, $additional_fields ) { $query_params['order'] = $params['order']; } - $posts_query = new \WP_Query(); - $posts = $posts_query->query( $query_params ); + if ( ! empty( $params['include'] ) ) { + $include_ids = array_slice( array_filter( wp_parse_id_list( $params['include'] ) ), 0, 100 ); + + if ( empty( $include_ids ) ) { + return null; + } + + $query_params['post__in'] = $include_ids; + $query_params['posts_per_page'] = count( $include_ids ); + $query_params['ignore_sticky_posts'] = true; + $query_params['post_status'] = 'publish'; + + // Preserve requested ID order unless the caller explicitly set an orderby. + $explicit_params = $params instanceof \WP_REST_Request ? $params->get_query_params() : array(); + if ( ! isset( $explicit_params['orderby'] ) ) { + $query_params['orderby'] = 'post__in'; + } + } + + $featured_image = isset( $query_options['featured_image'] ) ? (string) $query_options['featured_image'] : 'any'; + if ( ! in_array( $featured_image, array( 'any', 'priority' ), true ) ) { + $featured_image = 'any'; + } + + $priority_term_orderby = ''; + if ( ! empty( $priority_by_taxonomy ) ) { + global $wpdb; + $priority_taxonomy_clauses = array(); + foreach ( $priority_by_taxonomy as $taxonomy => $slugs ) { + $placeholders = implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ); + $priority_taxonomy_clauses[] = $wpdb->prepare( + "(tt.taxonomy = %s AND t.slug IN ({$placeholders}))", + array_merge( array( $taxonomy ), $slugs ) + ); + } + + if ( ! empty( $priority_taxonomy_clauses ) ) { + $required_taxonomy_count = count( $priority_taxonomy_clauses ); + $priority_term_orderby = "(CASE WHEN (SELECT COUNT(DISTINCT tt.taxonomy) FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id WHERE tr.object_id = {$wpdb->posts}.ID AND (" . implode( ' OR ', $priority_taxonomy_clauses ) . ")) = {$required_taxonomy_count} THEN 0 ELSE 1 END) ASC"; + } + } + + $priority_featured = ( 'priority' === $featured_image ); + $join_filter = null; + $orderby_filter = null; + $posts_query = new \WP_Query(); + $alias = 'fuxt_featimg_sort'; + + if ( $priority_featured ) { + global $wpdb; + + $join_filter = static function ( $join, $query ) use ( $wpdb, $alias, $posts_query ) { + if ( $query !== $posts_query ) { + return $join; + } + $join .= " LEFT JOIN {$wpdb->postmeta} AS {$alias} ON ({$wpdb->posts}.ID = {$alias}.post_id AND {$alias}.meta_key = '_thumbnail_id') "; + return $join; + }; + add_filter( 'posts_join', $join_filter, 10, 2 ); + } + + if ( $priority_featured || '' !== $priority_term_orderby ) { + $orderby_filter = static function ( $orderby, $query ) use ( $alias, $posts_query, $priority_featured, $priority_term_orderby ) { + if ( $query !== $posts_query ) { + return $orderby; + } + $pieces = array(); + if ( $priority_featured ) { + $pieces[] = "(CASE WHEN {$alias}.meta_id IS NOT NULL AND {$alias}.meta_value IS NOT NULL AND {$alias}.meta_value != '' AND {$alias}.meta_value != '0' THEN 0 ELSE 1 END) ASC"; + } + if ( '' !== $priority_term_orderby ) { + $pieces[] = $priority_term_orderby; + } + + if ( empty( $pieces ) ) { + return $orderby; + } + + $priority_orderby = implode( ', ', $pieces ); + return $orderby ? $priority_orderby . ', ' . $orderby : $priority_orderby; + }; + add_filter( 'posts_orderby', $orderby_filter, 10, 2 ); + } + + $posts = $posts_query->query( $query_params ); + + if ( $priority_featured && $join_filter ) { + remove_filter( 'posts_join', $join_filter, 10 ); + } + if ( $orderby_filter ) { + remove_filter( 'posts_orderby', $orderby_filter, 10 ); + } $post_list = array(); $post_params = array(); From 08011002740e0c7b66c7478a0b16c20cf956fd3e Mon Sep 17 00:00:00 2001 From: dchiamp Date: Tue, 21 Jul 2026 10:55:01 -0700 Subject: [PATCH 3/5] Add Project Images upload endpoint POST /wp-json/fuxt/v1/project-images accepts a single image (API-key auth via X-Fuxt-Api-Key or Bearer token, rate-limited per IP), tagging it for the submissions_only filter. GET lists recent images, paginated, public read. --- includes/class-plugin.php | 1 + .../class-rest-project-images-controller.php | 453 ++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 includes/class-rest-project-images-controller.php diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 39e7bb3..9709087 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -25,6 +25,7 @@ public function init() { ( new REST_Posts_Controller() )->init(); ( new REST_User_Controller() )->init(); ( new REST_Ical_Controller() )->init(); + ( new REST_Project_Images_Controller() )->init(); $this->update_check(); } diff --git a/includes/class-rest-project-images-controller.php b/includes/class-rest-project-images-controller.php new file mode 100644 index 0000000..bf29933 --- /dev/null +++ b/includes/class-rest-project-images-controller.php @@ -0,0 +1,453 @@ + \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'upload_item' ), + 'permission_callback' => array( $this, 'upload_permissions_check' ), + 'args' => array(), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) + ); + } + + /** + * Register the API key setting. + */ + public function register_setting() { + register_setting( + 'fuxt_project_images', + self::OPTION_API_KEY, + array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + } + + /** + * Add settings page for API key. + */ + public function add_settings_page() { + add_options_page( + __( 'Project Images API', 'fuxt-api' ), + __( 'Project Images API', 'fuxt-api' ), + 'manage_options', + 'fuxt-project-images', + array( $this, 'render_settings_page' ) + ); + } + + /** + * Render the settings page. + */ + public function render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + if ( isset( $_GET['settings-updated'] ) ) { + add_settings_error( + 'fuxt_project_images_messages', + 'fuxt_project_images_message', + __( 'Settings saved.', 'fuxt-api' ), + 'success' + ); + } + settings_errors( 'fuxt_project_images_messages' ); + $api_key = $this->get_api_key(); + ?> +
+

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

+ +

+
+ +
+

+ + POST +

+

+ + GET +

+
+ get_api_key(); + if ( $configured === '' ) { + return false; + } + $header = $request->get_header( 'X-Fuxt-Api-Key' ); + if ( $header !== null && $header !== '' && hash_equals( $configured, $header ) ) { + return true; + } + $auth = $request->get_header( 'Authorization' ); + if ( $auth && preg_match( '/^\s*Bearer\s+(.+)$/i', $auth, $m ) ) { + return hash_equals( $configured, trim( $m[1] ) ); + } + return false; + } + + /** + * Rate limit by IP. Returns true if under limit, false if exceeded. + * + * @param string $ip Client IP. + * @return bool + */ + protected function check_rate_limit( $ip ) { + $key = self::RATE_LIMIT_TRANSIENT_PREFIX . md5( $ip ); + $count = (int) get_transient( $key ); + if ( $count >= self::RATE_LIMIT_MAX_UPLOADS ) { + return false; + } + if ( $count === 0 ) { + set_transient( $key, 1, self::RATE_LIMIT_WINDOW_SECONDS ); + } else { + set_transient( $key, $count + 1, self::RATE_LIMIT_WINDOW_SECONDS ); + } + return true; + } + + /** + * Permission check for upload: valid API key + rate limit. + * + * @param \WP_REST_Request $request Request. + * @return bool|\WP_Error + */ + public function upload_permissions_check( $request ) { + if ( ! $this->validate_api_key( $request ) ) { + return new \WP_Error( + 'fuxt_rest_project_images_unauthorized', + __( 'Invalid or missing API key. Send X-Fuxt-Api-Key header or Authorization: Bearer <key>.', 'fuxt-api' ), + array( 'status' => 401 ) + ); + } + $ip = $this->get_client_ip( $request ); + if ( ! $this->check_rate_limit( $ip ) ) { + return new \WP_Error( + 'fuxt_rest_project_images_rate_limited', + __( 'Too many uploads. Please try again later.', 'fuxt-api' ), + array( 'status' => 429 ) + ); + } + return true; + } + + /** + * Permission check for listing images (public read; no key required). + * + * @param \WP_REST_Request $request Request. + * @return true + */ + public function get_items_permissions_check( $request ) { + return true; + } + + /** + * Get client IP from request. + * + * @param \WP_REST_Request $request Request. + * @return string + */ + protected function get_client_ip( $request ) { + $ip = $request->get_header( 'X-Forwarded-For' ); + if ( $ip ) { + $ip = trim( explode( ',', $ip )[0] ); + } + if ( empty( $ip ) && isset( $_SERVER['REMOTE_ADDR'] ) ) { + $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); + } + return $ip ? $ip : '0.0.0.0'; + } + + /** + * Collection params for GET. + * + * @return array + */ + public function get_collection_params() { + return array( + 'per_page' => array( + 'description' => __( 'Maximum number of items to return.', 'fuxt-api' ), + 'type' => 'integer', + 'default' => 20, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + ), + 'page' => array( + 'description' => __( 'Page number.', 'fuxt-api' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + 'submissions_only' => array( + 'description' => __( 'If true or 1, return only images uploaded via the project-images POST endpoint.', 'fuxt-api' ), + 'type' => 'boolean', + 'default' => false, + 'sanitize_callback' => function ( $value ) { + return rest_sanitize_boolean( $value ); + }, + ), + ); + } + + /** + * Schema for project image. + * + * @return array + */ + public function get_item_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'fuxt_project_image', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Attachment ID.', 'fuxt-api' ), + 'type' => 'integer', + ), + 'url' => array( + 'description' => __( 'Full URL to the image.', 'fuxt-api' ), + 'type' => 'string', + 'format' => 'uri', + ), + 'alt' => array( + 'description' => __( 'Alt text.', 'fuxt-api' ), + 'type' => 'string', + ), + 'width' => array( + 'description' => __( 'Image width in pixels.', 'fuxt-api' ), + 'type' => 'integer', + ), + 'height' => array( + 'description' => __( 'Image height in pixels.', 'fuxt-api' ), + 'type' => 'integer', + ), + 'mime_type' => array( + 'description' => __( 'MIME type.', 'fuxt-api' ), + 'type' => 'string', + ), + ), + ); + } + + /** + * Handle POST: upload one image (form field "image" or "file"). + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response|\WP_Error + */ + public function upload_item( $request ) { + $files = $request->get_file_params(); + if ( empty( $files ) && ! empty( $_FILES ) ) { + $files = $_FILES; + } + $file = null; + $key = null; + foreach ( array( 'image', 'file' ) as $field ) { + if ( ! empty( $files[ $field ] ) && ! empty( $files[ $field ]['tmp_name'] ) && is_uploaded_file( $files[ $field ]['tmp_name'] ) ) { + $file = $files[ $field ]; + $key = $field; + break; + } + } + if ( ! $file ) { + return new \WP_Error( + 'fuxt_rest_project_images_no_file', + __( 'No image file provided. Send multipart/form-data with field "image" or "file".', 'fuxt-api' ), + array( 'status' => 400 ) + ); + } + $mime = $file['type']; + if ( ! in_array( $mime, self::ALLOWED_MIME_TYPES, true ) ) { + return new \WP_Error( + 'fuxt_rest_project_images_invalid_type', + __( 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP.', 'fuxt-api' ), + array( 'status' => 400 ) + ); + } + if ( (int) $file['size'] > self::MAX_FILE_SIZE_BYTES ) { + return new \WP_Error( + 'fuxt_rest_project_images_too_large', + __( 'File too large. Maximum 5 MB.', 'fuxt-api' ), + array( 'status' => 400 ) + ); + } + require_once ABSPATH . 'wp-admin/includes/image.php'; + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + $attachment_id = media_handle_upload( $key, 0 ); + if ( is_wp_error( $attachment_id ) ) { + return new \WP_Error( + 'fuxt_rest_project_images_upload_failed', + $attachment_id->get_error_message(), + array( 'status' => 500 ) + ); + } + $attachment_id = (int) $attachment_id; + update_post_meta( $attachment_id, self::META_PROJECT_SUBMISSION, '1' ); + return rest_ensure_response( $this->format_attachment_response( $attachment_id ) ); + } + + /** + * Format attachment as response item. + * + * @param int $attachment_id Attachment ID. + * @return array + */ + protected function format_attachment_response( $attachment_id ) { + $url = wp_get_attachment_image_url( $attachment_id, 'full' ); + $meta = wp_get_attachment_metadata( $attachment_id ); + return array( + 'id' => $attachment_id, + 'url' => $url ? $url : wp_get_attachment_url( $attachment_id ), + 'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), + 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : 0, + 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : 0, + 'mime_type' => get_post_mime_type( $attachment_id ), + ); + } + + /** + * Handle GET: list recent project images (attachments). + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response + */ + public function get_items( $request ) { + $per_page = $request->get_param( 'per_page' ); + $page = $request->get_param( 'page' ); + $submissions_only = $request->get_param( 'submissions_only' ); + if ( is_string( $submissions_only ) ) { + $submissions_only = rest_sanitize_boolean( $submissions_only ); + } + $query_args = array( + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'post_mime_type' => 'image', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'orderby' => 'date', + 'order' => 'DESC', + ); + if ( $submissions_only ) { + $query_args['meta_query'] = array( + array( + 'key' => self::META_PROJECT_SUBMISSION, + 'compare' => 'EXISTS', + ), + ); + } + $query = new \WP_Query( $query_args ); + $items = array(); + foreach ( $query->posts as $post ) { + $items[] = $this->format_attachment_response( (int) $post->ID ); + } + $response = rest_ensure_response( $items ); + $response->header( 'X-WP-Total', (int) $query->found_posts ); + $response->header( 'X-WP-TotalPages', (int) $query->max_num_pages ); + return $response; + } +} From bf29f95b2866aa35c09c10471a68019f044b9ca7 Mon Sep 17 00:00:00 2001 From: dchiamp Date: Tue, 21 Jul 2026 10:57:16 -0700 Subject: [PATCH 4/5] Add .gitignore (ignore .DS_Store) --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store From 25654177d9b23913d533d3291210de532c89d7c7 Mon Sep 17 00:00:00 2001 From: dchiamp Date: Tue, 21 Jul 2026 11:29:32 -0700 Subject: [PATCH 5/5] Harden project-images upload: verify real file size/type Client-supplied $file['size'] and $file['type'] can be spoofed. Now checks filesize() on disk against the 5MB cap, and getimagesize() to confirm the upload is actually a decodable image of an allowed type before handing it to media_handle_upload(). --- .../class-rest-project-images-controller.php | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/includes/class-rest-project-images-controller.php b/includes/class-rest-project-images-controller.php index bf29933..852b13d 100644 --- a/includes/class-rest-project-images-controller.php +++ b/includes/class-rest-project-images-controller.php @@ -360,18 +360,24 @@ public function upload_item( $request ) { array( 'status' => 400 ) ); } - $mime = $file['type']; - if ( ! in_array( $mime, self::ALLOWED_MIME_TYPES, true ) ) { + // Check real file size on disk -- the client-supplied $file['size'] is untrusted. + $actual_size = filesize( $file['tmp_name'] ); + if ( false === $actual_size || $actual_size > self::MAX_FILE_SIZE_BYTES ) { return new \WP_Error( - 'fuxt_rest_project_images_invalid_type', - __( 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP.', 'fuxt-api' ), + 'fuxt_rest_project_images_too_large', + __( 'File too large. Maximum 5 MB.', 'fuxt-api' ), array( 'status' => 400 ) ); } - if ( (int) $file['size'] > self::MAX_FILE_SIZE_BYTES ) { + + // Verify the file is actually a readable image of an allowed type -- + // the client-supplied MIME type/extension can be spoofed, so this + // re-checks the real file contents instead of trusting $file['type']. + $image_info = @getimagesize( $file['tmp_name'] ); + if ( false === $image_info || empty( $image_info['mime'] ) || ! in_array( $image_info['mime'], self::ALLOWED_MIME_TYPES, true ) ) { return new \WP_Error( - 'fuxt_rest_project_images_too_large', - __( 'File too large. Maximum 5 MB.', 'fuxt-api' ), + 'fuxt_rest_project_images_invalid_type', + __( 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP.', 'fuxt-api' ), array( 'status' => 400 ) ); }