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 '
'; if ( '' !== $map && ! ctype_digit( $map ) ) { $this->render_editor( $map ); } else { $this->render_list(); } echo '
'; } /** * Says the account has no maps, and where one can come from. * * The site-fed list is linked only for someone who can open it; a pure * data editor cannot, so they are not sent there. */ private function empty_account_notice(): void { if ( ! current_user_can( Capabilities::EDIT_MAPS ) ) { 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__( 'Maps', 'mapsemble' ) . '

'; if ( ! $this->api->has_credentials() ) { $this->credentials_notice(); return; } $remote_maps = $this->api->get_remote_maps(); if ( array() === $remote_maps ) { $error = $this->api->get_last_error(); if ( '' !== $error ) { 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 '
'; printf( '', esc_attr( self::PAGE_SLUG ) ); $origin = MapListTable::origin(); if ( '' !== $origin ) { // A search keeps the view it was made from. printf( '', esc_attr( $origin ) ); } $table->search_box( __( 'Search maps', 'mapsemble' ), 'mapsemble-map' ); $table->display(); 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_url( self::url() ), /* translators: "←" points back to the list; use "→" in right-to-left languages. */ esc_html__( '← All maps', 'mapsemble' ), esc_html( sprintf( /* translators: %s: the map's name. */ __( 'Locations — %s', 'mapsemble' ), $name ) ), esc_html( $map_uuid ) ); if ( ! $this->api->has_credentials() ) { $this->credentials_notice(); return; } // A map fed from somewhere else entirely: warn, do not refuse. // Opening its editor edits rows nothing currently reads — but // "about to switch it to hosted" is a legitimate reason to be here, // and the check costs one memoised request. $remote = $this->api->get_remote_map( $map_uuid ); if ( is_array( $remote ) && 'remote' === (string) ( $remote['dataSource']['mode'] ?? '' ) ) { printf( '

%s

', esc_html__( 'This map currently takes its data from an external feed, so the locations edited here are not what it shows. They appear on the map once its data source is switched to Mapsemble in the map\'s settings on Mapsemble.', 'mapsemble' ) ); } printf( '

%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_url( admin_url( 'admin-post.php' ) ) ); wp_nonce_field( self::OPEN_ACTION ); 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( '

%s %s

', 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 $links URL => link text. */ private function notice( string $message, array $links = array() ): void { $rendered = array(); foreach ( $links as $url => $text ) { $rendered[] = sprintf( '%s', esc_url( (string) $url ), esc_html( $text ) ); } printf( '

%s %s

', esc_html( $message ), wp_kses( implode( ' · ', $rendered ), array( 'a' => array( 'href' => array() ) ) ) ); } }