api = $api; $this->repository = $repository; } /** * The URL of the list of the account's maps. */ public static function url(): string { return add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ); } /** * The URL of the editor for one map. * * @param string $map_uuid The Mapsemble map id. */ public static function editor_url( string $map_uuid ): string { return add_query_arg( array( 'page' => self::PAGE_SLUG, 'map' => rawurlencode( $map_uuid ), ), admin_url( 'admin.php' ) ); } /** * Hooks the screen up. */ public function register(): void { add_action( 'admin_menu', array( $this, 'add_page' ), 12 ); add_action( 'admin_post_' . self::OPEN_ACTION, array( $this, 'handle_open' ) ); } /** * Adds the submenu. * * Priority 12, after Menu::add_menu() at 11, so the parent exists. The * entry is titled `Maps` — it is the account's maps — and Menu puts it * first, above the post type's `Built from this site`. */ public function add_page(): void { $hook = add_submenu_page( Menu::SLUG, __( 'Maps', 'mapsemble' ), __( 'Maps', 'mapsemble' ), Capabilities::EDIT_MAP_DATA, self::PAGE_SLUG, array( $this, 'render' ) ); if ( is_string( $hook ) ) { add_action( 'load-' . $hook, array( $this, 'guard_request' ) ); } } /** * Refuses or redirects the request before any output. * * This runs on `load-{$hook}`, before admin-header.php prints anything. * Refusals issued later run after output has started, and WordPress * skips status_header() once output is under way — so their status * codes never reach the browser. Deciding here, while headers are * still unsent, means the status code, the styled wp_die() page and * the legacy redirect all land. The render-time guards stay as * backstops for direct calls. */ public function guard_request(): void { if ( ! current_user_can( Capabilities::EDIT_MAP_DATA ) ) { wp_die( esc_html__( 'You are not allowed to edit map data.', 'mapsemble' ), '', array( 'response' => 403 ) ); } // The list's search form is a GET form, and WP_List_Table prints its // bulk-action nonce and referer into it; nothing here verifies them. // Drop them as core's own list screens do, so a search leaves a // clean, shareable URL rather than a nonce in the address bar. Only // the list has that form; the editor's URLs are left alone. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is being discarded, not trusted. if ( empty( $_GET['map'] ) && ! empty( $_GET['_wp_http_referer'] ) && isset( $_SERVER['REQUEST_URI'] ) ) { wp_safe_redirect( remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) ); exit; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen selection. $map = isset( $_GET['map'] ) ? sanitize_text_field( wp_unslash( $_GET['map'] ) ) : ''; if ( '' === $map ) { return; } // Pre-0.3 links carried the config post id. Old bookmarks and // row-action links keep working by redirecting to the UUID form. if ( ctype_digit( $map ) ) { $config = $this->repository->get( (int) $map ); if ( null === $config || '' === $config->map_id ) { wp_die( esc_html__( 'That map does not exist.', 'mapsemble' ), '', array( 'response' => 404 ) ); } wp_safe_redirect( self::editor_url( $config->map_id ) ); exit; } $this->refuse_malformed( $map ); $this->refuse_site_fed( $map ); } /** * Dies when a `?map=` is not a map id at all. * * The same answer as a legacy post id that leads nowhere: no map has * such an id. Refused before anything puts it into a Mapsemble URL. * * @param string $map_uuid The candidate map id. */ private function refuse_malformed( string $map_uuid ): void { if ( ! MapId::is_valid( $map_uuid ) ) { wp_die( esc_html__( 'That map does not exist.', 'mapsemble' ), '', array( 'response' => 404 ) ); } } /** * Dies when a map id is bound to a site-fed configuration. * * @param string $map_uuid The Mapsemble map id. */ private function refuse_site_fed( string $map_uuid ): void { $config = $this->repository->find_by_map_id( $map_uuid ); if ( null !== $config && ! $config->is_hosted() ) { // Pointing the Mapsemble editor at it would edit rows nothing // reads. wp_die( esc_html__( 'That map is built from this site\'s posts; its locations are the posts themselves.', 'mapsemble' ), '', array( 'response' => 404 ) ); } } /** * Renders the list or the editor, depending on `?map=`. */ public function render(): void { if ( ! current_user_can( Capabilities::EDIT_MAP_DATA ) ) { wp_die( esc_html__( 'You are not allowed to edit map data.', 'mapsemble' ), '', array( 'response' => 403 ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen selection. $map = isset( $_GET['map'] ) ? sanitize_text_field( wp_unslash( $_GET['map'] ) ) : ''; echo '
' . esc_html__( 'Your Mapsemble account has no maps yet. Create one on Mapsemble.', 'mapsemble' ) . '
'; return; } printf( '%s
', sprintf( /* translators: %s: link to the Mapsemble → Built from this site screen. */ esc_html__( 'Your Mapsemble account has no maps yet. Create one on Mapsemble, or build one from this site\'s posts under %s.', 'mapsemble' ), sprintf( '%s', esc_url( MapPostType::list_url() ), esc_html__( 'Mapsemble → Built from this site', 'mapsemble' ) ) ) ); } /** * Lists the connected account's maps. * * This exists so that an account holding only `edit_mapsemble_map_data` * has a way in at all: the `Built from this site` list is gated on the * post type's own capabilities, which such an account deliberately does * not have. */ private function render_list(): void { echo '' . esc_html( $error ) . '
'; } else { $this->empty_account_notice(); } return; } // One pass over the config posts, so each row can say whether its // map is fed by this site — the list payload itself cannot (it // carries no dataSource), and asking per map would be a request each. $bound = array(); foreach ( $this->repository->all() as $config ) { if ( '' !== $config->map_id ) { $bound[ $config->map_id ] = $config; } } echo '' . esc_html__( 'The maps on your Mapsemble account. Locations for maps hosted on Mapsemble are added and changed here.', 'mapsemble' ) . '
'; $table = new MapListTable( $remote_maps, $bound, static fn ( MapConfig $config ): bool => current_user_can( 'edit_post', $config->id ) ); $table->prepare_items(); $table->views(); echo ''; } /** * Renders the editor for one map, or the reason it cannot be shown. * * @param string $map_uuid The Mapsemble map id. */ private function render_editor( string $map_uuid ): void { $this->refuse_malformed( $map_uuid ); $this->refuse_site_fed( $map_uuid ); // Named from the account list — usually cached; one list request // otherwise — and no request at all without credentials. A map missing from the list — // just created, or the list unreachable — falls back to its id, // which is printed under the heading either way. $remote_maps = $this->api->get_remote_maps(); $name = MapChoices::label( $map_uuid, $remote_maps[ $map_uuid ] ?? null ); printf( '%s
%s
%s
', esc_html__( 'Locations for this map are edited on Mapsemble. The editor opens in a new tab, signed in for this map only, with the map beside it.', 'mapsemble' ) ); // A form, not a link: the nonce is single-use, so one baked into a // link would be spent by the first open and dead for the second. The // handler mints it at the moment of the click. printf( '', esc_attr( self::OPEN_ACTION ), esc_attr( $map_uuid ), esc_html__( 'Open the location editor', 'mapsemble' ), esc_html__( '(opens in a new tab)', 'mapsemble' ) ); // Always offered, not only on failure: it still works when this // site's credentials are the thing that is broken. Note that /map/ is // outside the confined session a redeemed nonce leaves behind, so a // browser that used the button first is asked to log in here. printf( '', esc_url( $this->api->builder_url( $map_uuid, 'content' ) ), esc_html__( 'Or edit this map on Mapsemble with your own Mapsemble login', 'mapsemble' ), esc_html__( '(opens in a new tab)', 'mapsemble' ) ); } /** * Mints the single-use editor nonce and sends the new tab to Mapsemble. * * The tab lands on /integrations/wordpress/{map}?token=…, where Mapsemble * redeems the token into a session confined to that one map's editor. */ public function handle_open(): void { check_admin_referer( self::OPEN_ACTION ); if ( ! current_user_can( Capabilities::EDIT_MAP_DATA ) ) { wp_die( esc_html__( 'You are not allowed to edit map locations.', 'mapsemble' ), '', array( 'response' => 403 ) ); } $map_uuid = isset( $_POST['map'] ) ? sanitize_text_field( wp_unslash( $_POST['map'] ) ) : ''; $this->refuse_malformed( $map_uuid ); $this->refuse_site_fed( $map_uuid ); $nonce = $this->api->create_editor_nonce( $map_uuid ); if ( '' === $nonce ) { wp_die( wp_kses( sprintf( /* translators: 1: why the editor could not be opened, 2: URL of the map on Mapsemble. */ __( 'Could not open the editor. %1$s Edit this map on Mapsemble with your own Mapsemble login instead.', 'mapsemble' ), esc_html( $this->api->get_last_error() ), esc_url( $this->api->builder_url( $map_uuid, 'content' ) ) ), array( 'a' => array( 'href' => array() ) ) ), '', array( 'back_link' => true ) ); } $target = add_query_arg( 'token', $nonce, $this->api->get_app_base() . '/integrations/wordpress/' . rawurlencode( $map_uuid ) ); $host = wp_parse_url( $this->api->get_app_base(), PHP_URL_HOST ); add_filter( 'allowed_redirect_hosts', static function ( array $hosts ) use ( $host ): array { return is_string( $host ) ? array_merge( $hosts, array( $host ) ) : $hosts; } ); wp_safe_redirect( $target ); exit; } /** * Explains missing credentials, with a way out only for those who hold it. * * A pure data editor deliberately cannot open the settings screen, so * linking them there would be a dead end wearing a button. */ private function credentials_notice(): void { if ( current_user_can( Capabilities::MANAGE ) ) { $this->notice( __( 'Mapsemble credentials are not configured, so your account\'s maps cannot be listed and the editor cannot sign in.', 'mapsemble' ), array( SettingsPage::url() => __( 'Configure them', 'mapsemble' ) ) ); return; } $this->notice( __( 'Mapsemble credentials are not configured. Ask an administrator to add them under Mapsemble → Settings.', 'mapsemble' ) ); } /** * Renders an explanation with the ways out of it. * * @param string $message The explanation. * @param array%s %s