WP_REST_Server::READABLE, 'permission_callback' => 'coverground_rest_permission', 'callback' => function () { return rest_ensure_response( coverground_rest_result( coverground_get_routes() ) ); }, ) ); register_rest_route( 'coverground/v1', '/routes/(?P[a-z0-9-]+)/(?P[a-z0-9-]+)/card', array( 'methods' => WP_REST_Server::READABLE, 'permission_callback' => 'coverground_rest_permission', 'callback' => function ( WP_REST_Request $request ) { // The company segment keeps refs readable, but the token already // pins the subscriber — the slug alone addresses the route. $body = coverground_api_get( '/routes/' . $request['slug'] . '/card' ); // The cover photo is copied into the media library (attachment // id + local URL), so the published card serves its own image // and keeps working when Coverground is unreachable. There is no // hotlink fallback: without a copy the card gets no image, and // media_status tells the editor why (copied | none | // no_permission | failed). if ( ! is_wp_error( $body ) ) { $body['media'] = null; $body['media_status'] = 'none'; if ( ! empty( $body['thumbnail_url'] ) ) { if ( ! current_user_can( 'upload_files' ) ) { $body['media_status'] = 'no_permission'; } else { $body['media'] = coverground_import_thumbnail( $body ); $body['media_status'] = $body['media'] ? 'copied' : 'failed'; } } } return rest_ensure_response( coverground_rest_result( $body ) ); }, ) ); register_rest_route( 'coverground/v1', '/status', array( 'methods' => WP_REST_Server::READABLE, 'permission_callback' => 'coverground_rest_permission', 'callback' => function () { return rest_ensure_response( coverground_rest_result( coverground_api_get( '/status' ) ) ); }, ) ); } add_action( 'rest_api_init', 'coverground_register_rest_routes' ); function coverground_rest_permission(): bool { return current_user_can( 'edit_posts' ); } /** WP_Error passes through as-is — its stable code drives the editor copy. */ function coverground_rest_result( $result ) { return $result; } /** * Copy a card's cover photo into the media library and return what the block * needs to reference it, or null when the copy failed. Identical bytes * for the same route reuse the existing attachment (dedupe by ref + content * hash); a changed photo gets a fresh attachment, so an older card's image * keeps rendering untouched. The route's alt rides along onto the attachment * — empty stays empty, the deliberate decorative signal. * * @param array $payload The card payload from Coverground. * @return array{id: int, url: string}|null */ function coverground_import_thumbnail( array $payload ): ?array { if ( empty( $payload['thumbnail_url'] ) || empty( $payload['ref'] ) || ! current_user_can( 'upload_files' ) ) { return null; } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $tmp = download_url( $payload['thumbnail_url'], 15 ); if ( is_wp_error( $tmp ) ) { return null; } $hash = md5_file( $tmp ); $existing = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one narrow dedupe lookup per card load. array( 'key' => '_coverground_ref', 'value' => $payload['ref'] ), array( 'key' => '_coverground_hash', 'value' => $hash ), ), ) ); if ( $existing ) { wp_delete_file( $tmp ); $id = (int) $existing[0]; } else { $file = array( 'name' => sanitize_file_name( str_replace( '/', '-', $payload['ref'] ) . '.jpg' ), 'tmp_name' => $tmp, ); $id = media_handle_sideload( $file, 0, (string) ( $payload['title'] ?? '' ) ); if ( is_wp_error( $id ) ) { wp_delete_file( $tmp ); return null; } update_post_meta( $id, '_coverground_ref', $payload['ref'] ); update_post_meta( $id, '_coverground_hash', $hash ); } // Also on reuse: the owner may have changed the alt on Coverground without // touching the photo bytes — the library copy's alt must follow. update_post_meta( $id, '_wp_attachment_image_alt', (string) ( $payload['thumbnail_alt'] ?? '' ) ); $url = wp_get_attachment_url( $id ); return $url ? array( 'id' => $id, 'url' => $url ) : null; }