/invite WordPress « set your password » e-mail (users created here only) * POST agentsia/v1/site-members/authors//role { role } (users created here only) * POST agentsia/v1/site-members/categories { name, parent?, slug? } * GET agentsia/v1/site-members/page-types public post types, and whether the caller may create one * GET agentsia/v1/site-members/page-fields ?post_type= : ACF fields of that type an admin may fill * POST agentsia/v1/site-members/pages { post_type, title, slug?, status, author_id?, fields? } * POST agentsia/v1/site-members/pages/link { post_id | url, author_id } * POST agentsia/v1/site-members/photo { attachment_id, post_id? | url?, author_id? } member's photo (1.8.0) * * Every route does one bounded thing: the dedicated Agentsia user gets no new capability (never `create_users` * nor `manage_categories`). Nothing is ever deleted, and an existing user, category or page is returned as it * is: found by e-mail, slug, or slug within the type. Replaying a request creates nothing twice. * * Roles. A user created (or changed) here never gets more rights than the caller, so that a stolen Agentsia * key cannot open the site: a role is given only when none of its capabilities is in FORBIDDEN_CAPS and all of * them are held by the caller (or only manage the user's own content, OWN_CONTENT_CAPS; `level_*` ignored). * Two exceptions: « Éditeur » only when an administrator of the site ticked « Autoriser Agentsia à créer des * éditeurs » (settings of the plugin), « Administrateur » never. Filter `agentsia_site_member_assignable_roles` * ( $roles ) to narrow the list further. * * The page of a member keeps the id of the member's user in `_agentsia_member_author_id`: the « Derniers * articles » block of that page lists that author's articles by default (see Latest_Posts). * * Photo (1.8.0). An image already in the media library (uploaded by the Agentsia account) is set on the * member's page — in the first ACF image field of its type, else as its featured image — and/or as the avatar * of the member's user (users created by Agentsia only, like invitation and role): the `pre_get_avatar_data` * filter then serves it wherever WordPress shows that user's avatar, Yoast's author schema included. */ final class Site_Members { /** Signs the articles the agent publishes; can do nothing once logged in. */ const SIGNATAIRE_ROLE = 'agentsia_signataire'; /** Post meta of a member's page: id of the member's user. */ const META_PAGE_AUTHOR = '_agentsia_member_author_id'; /** User meta: created by Agentsia (only those can be invited or have their role changed from Agentsia). */ const META_USER_CREATED = '_agentsia_created'; const META_USER_INVITED = '_agentsia_invited_at'; /** Setting of the plugin (Réglages › Agentsia Agents), administrators of the site only. */ const SETTING_ALLOW_EDITOR = 'allow_editor_creation'; /** User meta: media library id of the member's photo, served as their avatar. */ const META_USER_AVATAR = '_agentsia_avatar_id'; private const CALLER_CAPABILITY = 'agentsia_seo'; private const DEDICATED_ROLE = 'agentsia_seo'; private const ROLE_VERSION = '1'; private const OPT_ROLE_VER = 'agentsia_signataire_role_version'; private const MAX_FIELD_TEXT = 5000; private const MAX_NAME = 200; private const FIELD_TYPES = [ 'text', 'textarea', 'wysiwyg', 'email', 'url' ]; /** ACF field types that hold a media library image id: where the member's photo goes on their page. */ private const IMAGE_FIELD_TYPES = [ 'image' ]; private const PAGE_STATUSES = [ 'draft', 'publish' ]; /** Never given, whoever calls: users, plugins, themes, settings, files, raw HTML or uploads, network. */ private const FORBIDDEN_CAPS = [ 'manage_options', 'promote_users', 'create_users', 'add_users', 'edit_users', 'delete_users', 'list_users', 'remove_users', 'install_plugins', 'activate_plugins', 'edit_plugins', 'delete_plugins', 'update_plugins', 'install_themes', 'edit_themes', 'switch_themes', 'delete_themes', 'update_themes', 'edit_theme_options', 'customize', 'update_core', 'edit_files', 'import', 'export', 'unfiltered_html', 'unfiltered_upload', 'manage_network', 'manage_sites', 'manage_network_users', 'manage_network_plugins', 'manage_network_themes', 'manage_network_options', 'setup_network', 'delete_site', 'upgrade_network', ]; /** A user handling their own articles only: allowed even when the caller lacks them. */ private const OWN_CONTENT_CAPS = [ 'read', 'edit_posts', 'delete_posts', 'edit_published_posts', 'delete_published_posts', 'publish_posts', 'upload_files' ]; private static ?self $instance = null; public static function instance(): self { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } public function register(): void { add_action( 'init', [ $this, 'sync_role' ] ); add_action( 'rest_api_init', [ $this, 'register_routes' ] ); add_filter( 'pre_get_avatar_data', [ $this, 'avatar_data' ], 10, 2 ); } /** The « Signataire Agentsia » role, `read` only; kept equal to its declaration (roles live in the database). */ public function sync_role(): void { if ( get_option( self::OPT_ROLE_VER ) === self::ROLE_VERSION && get_role( self::SIGNATAIRE_ROLE ) ) { return; } $role = get_role( self::SIGNATAIRE_ROLE ); if ( ! $role ) { add_role( self::SIGNATAIRE_ROLE, __( 'Signataire Agentsia', 'agentsia-agents' ), [ 'read' => true ] ); } else { foreach ( array_keys( $role->capabilities ) as $cap ) { if ( $cap !== 'read' ) { $role->remove_cap( $cap ); } } $role->add_cap( 'read', true ); } update_option( self::OPT_ROLE_VER, self::ROLE_VERSION, true ); } public function register_routes(): void { $ns = Seo_Meta::REST_NAMESPACE; $people = static function () { return current_user_can( self::CALLER_CAPABILITY ) || current_user_can( 'create_users' ); }; $terms = static function () { return current_user_can( self::CALLER_CAPABILITY ) || current_user_can( 'manage_categories' ); }; $pages = static function () { return current_user_can( self::CALLER_CAPABILITY ) || current_user_can( 'edit_posts' ); }; register_rest_route( $ns, '/site-members/roles', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_roles' ], 'permission_callback' => $people ] ); register_rest_route( $ns, '/site-members/authors', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_author' ], 'permission_callback' => $people ] ); register_rest_route( $ns, '/site-members/authors/(?P\d+)/invite', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_invite' ], 'permission_callback' => $people ] ); register_rest_route( $ns, '/site-members/authors/(?P\d+)/role', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_role_change' ], 'permission_callback' => $people ] ); register_rest_route( $ns, '/site-members/categories', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_category' ], 'permission_callback' => $terms ] ); register_rest_route( $ns, '/site-members/page-types', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_page_types' ], 'permission_callback' => $pages ] ); register_rest_route( $ns, '/site-members/page-fields', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_page_fields' ], 'permission_callback' => $pages ] ); register_rest_route( $ns, '/site-members/pages', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_page' ], 'permission_callback' => $pages ] ); register_rest_route( $ns, '/site-members/pages/link', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_page_link' ], 'permission_callback' => $pages ] ); register_rest_route( $ns, '/site-members/photo', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_photo' ], 'permission_callback' => $pages ] ); } // ── Roles ─────────────────────────────────────────────────────────────────── public function handle_roles(): \WP_REST_Response { $out = []; foreach ( $this->roles() as $name => $role ) { $check = $this->assignability( $name, $role['capabilities'] ); $caps = $role['capabilities']; $out[] = array_filter( [ 'role' => $name, 'label' => translate_user_role( (string) $role['name'] ), 'assignable' => $check === null, 'reason' => $check, 'can_write' => ! empty( $caps['edit_posts'] ), 'can_publish' => ! empty( $caps['publish_posts'] ), 'can_edit_others' => ! empty( $caps['edit_others_posts'] ), ], static function ( $v ) { return $v !== null; } ); } return new \WP_REST_Response( $out, 200 ); } /** @return array Roles of the site, fewest rights first. */ private function roles(): array { $roles = wp_roles()->roles; $roles = is_array( $roles ) ? $roles : []; uasort( $roles, static function ( $a, $b ) { return count( array_filter( (array) ( $a['capabilities'] ?? [] ) ) ) <=> count( array_filter( (array) ( $b['capabilities'] ?? [] ) ) ); } ); $out = []; foreach ( $roles as $name => $role ) { $out[ (string) $name ] = [ 'name' => (string) ( $role['name'] ?? $name ), 'capabilities' => (array) ( $role['capabilities'] ?? [] ) ]; } return $out; } /** * Why a role cannot be given by the caller (null = it can): `administrator`, `reserved` (the dedicated * Agentsia role), `editor_not_allowed`, `exceeds_caller`, `filtered` (removed by the filter). */ private function assignability( string $name, array $caps ): ?string { if ( $name === 'administrator' ) { return 'administrator'; } if ( $name === self::DEDICATED_ROLE ) { return 'reserved'; } $allowed = apply_filters( 'agentsia_site_member_assignable_roles', null ); if ( is_array( $allowed ) && ! in_array( $name, $allowed, true ) ) { return 'filtered'; } if ( $name === 'editor' ) { return ! empty( Plugin::get_settings()[ self::SETTING_ALLOW_EDITOR ] ) ? null : 'editor_not_allowed'; } $caller = wp_get_current_user(); foreach ( $caps as $cap => $granted ) { $cap = (string) $cap; if ( ! $granted || strpos( $cap, 'level_' ) === 0 ) { continue; } if ( in_array( $cap, self::FORBIDDEN_CAPS, true ) ) { return 'exceeds_caller'; } if ( ! in_array( $cap, self::OWN_CONTENT_CAPS, true ) && ! $caller->has_cap( $cap ) ) { return 'exceeds_caller'; } } return null; } /** @return true|\WP_Error */ private function check_role( $role ) { $role = is_string( $role ) ? $role : ''; $roles = $this->roles(); if ( $role === '' || ! isset( $roles[ $role ] ) ) { return $this->error( 'role_unknown', __( 'Rôle inconnu sur ce site.', 'agentsia-agents' ), 400 ); } $reason = $this->assignability( $role, $roles[ $role ]['capabilities'] ); if ( $reason !== null ) { return $this->error( 'role_not_assignable', __( 'Agentsia ne peut pas donner ce rôle sur ce site.', 'agentsia-agents' ), 400, [ 'reason' => $reason ] ); } return true; } // ── Authors ───────────────────────────────────────────────────────────────── public function handle_author( \WP_REST_Request $request ) { if ( is_multisite() ) { return $this->error( 'multisite', __( 'Création d\'auteurs non prise en charge sur un réseau de sites.', 'agentsia-agents' ), 501 ); } $email = sanitize_email( (string) $request->get_param( 'email' ) ); $display = $this->clean_name( $request->get_param( 'display_name' ) ); if ( $email === '' || ! is_email( $email ) ) { return $this->error( 'invalid', __( 'Adresse e-mail invalide.', 'agentsia-agents' ), 400 ); } if ( $display === '' ) { return $this->error( 'invalid', __( 'Nom affiché manquant ou trop long.', 'agentsia-agents' ), 400 ); } $role = $request->get_param( 'role' ); $check = $this->check_role( $role ); if ( is_wp_error( $check ) ) { return $check; } $existing = get_user_by( 'email', $email ); if ( $existing instanceof \WP_User ) { // Linked as it is: never another role, never an e-mail. return new \WP_REST_Response( $this->author_view( $existing, false ), 200 ); } $user_id = wp_insert_user( [ 'user_login' => $this->unique_login( $display, $email ), 'user_pass' => wp_generate_password( 32, true, true ), // never given: the member sets their own by the invitation 'user_email' => $email, 'display_name' => $display, 'nickname' => $display, 'first_name' => $this->clean_name( $request->get_param( 'first_name' ) ), 'last_name' => $this->clean_name( $request->get_param( 'last_name' ) ), 'role' => (string) $role, ] ); if ( is_wp_error( $user_id ) ) { return $this->error( 'site', $user_id->get_error_message(), 500 ); } update_user_meta( (int) $user_id, self::META_USER_CREATED, 1 ); $user = get_userdata( (int) $user_id ); if ( ! empty( $request->get_param( 'invite' ) ) ) { $this->invite( $user ); } return new \WP_REST_Response( $this->author_view( $user, true ), 201 ); } public function handle_invite( \WP_REST_Request $request ) { $user = $this->created_user( (int) $request['id'] ); if ( is_wp_error( $user ) ) { return $user; } $this->invite( $user ); return new \WP_REST_Response( $this->author_view( $user, false ), 200 ); } public function handle_role_change( \WP_REST_Request $request ) { $user = $this->created_user( (int) $request['id'] ); if ( is_wp_error( $user ) ) { return $user; } $role = $request->get_param( 'role' ); $check = $this->check_role( $role ); if ( is_wp_error( $check ) ) { return $check; } $user->set_role( (string) $role ); return new \WP_REST_Response( $this->author_view( get_userdata( $user->ID ), false ), 200 ); } /** WordPress's own « set your password » e-mail (sent by the site, never a link given to Agentsia). */ private function invite( \WP_User $user ): void { wp_new_user_notification( $user->ID, null, 'user' ); update_user_meta( $user->ID, self::META_USER_INVITED, time() ); } /** @return \WP_User|\WP_Error A user created by Agentsia (others are managed in WordPress only). */ private function created_user( int $id ) { $user = $id > 0 ? get_userdata( $id ) : false; if ( ! $user instanceof \WP_User ) { return $this->error( 'user_not_found', __( 'Utilisateur introuvable sur le site.', 'agentsia-agents' ), 404 ); } if ( ! get_user_meta( $user->ID, self::META_USER_CREATED, true ) ) { return $this->error( 'not_created_by_agentsia', __( 'Ce compte n\'a pas été créé par Agentsia : il se gère dans WordPress.', 'agentsia-agents' ), 403 ); } return $user; } /** Never the e-mail: an existing user's address is not Agentsia's business. */ private function author_view( \WP_User $user, bool $created ): array { $invited = (int) get_user_meta( $user->ID, self::META_USER_INVITED, true ); $roles = array_values( (array) $user->roles ); return [ 'id' => (int) $user->ID, 'login' => (string) $user->user_login, 'slug' => (string) $user->user_nicename, 'display_name' => (string) $user->display_name, 'role' => (string) ( $roles[0] ?? '' ), 'created' => $created, 'created_by_agentsia' => (bool) get_user_meta( $user->ID, self::META_USER_CREATED, true ), 'invited_at' => $invited > 0 ? gmdate( 'c', $invited ) : null, 'can_publish' => user_can( $user, 'publish_posts' ), ]; } /** Login from the name (« Charles Nehme » → charles-nehme), else from the e-mail; -2, -3… when taken. */ private function unique_login( string $display, string $email ): string { $base = sanitize_user( strtolower( remove_accents( str_replace( ' ', '-', $display ) ) ), true ); $base = trim( (string) preg_replace( '/[^a-z0-9_.-]+/', '-', $base ), '-.' ); if ( $base === '' ) { $base = sanitize_user( strtolower( (string) strstr( $email, '@', true ) ), true ); } $base = substr( $base !== '' ? $base : 'auteur', 0, 50 ); $login = $base; for ( $i = 2; username_exists( $login ) || $login === 'admin'; $i++ ) { $login = $base . '-' . $i; } return $login; } // ── Categories ────────────────────────────────────────────────────────────── public function handle_category( \WP_REST_Request $request ) { $name = $this->clean_name( $request->get_param( 'name' ) ); if ( $name === '' ) { return $this->error( 'invalid', __( 'Nom de catégorie manquant ou trop long.', 'agentsia-agents' ), 400 ); } $parent = absint( $request->get_param( 'parent' ) ); if ( $parent > 0 && ! get_term( $parent, 'category' ) instanceof \WP_Term ) { return $this->error( 'invalid', __( 'Catégorie parente introuvable.', 'agentsia-agents' ), 400 ); } $slug = sanitize_title( (string) ( $request->get_param( 'slug' ) ?: $name ) ); $term = get_term_by( 'slug', $slug, 'category' ); $made = false; if ( ! $term instanceof \WP_Term ) { $inserted = wp_insert_term( $name, 'category', [ 'parent' => $parent, 'slug' => $slug ] ); if ( is_wp_error( $inserted ) ) { return $this->error( 'site', $inserted->get_error_message(), 500 ); } $term = get_term( (int) $inserted['term_id'], 'category' ); $made = true; } return new \WP_REST_Response( [ 'id' => (int) $term->term_id, 'name' => (string) $term->name, 'slug' => (string) $term->slug, 'parent' => (int) $term->parent, 'created' => $made, ], $made ? 201 : 200 ); } // ── Pages of the members ──────────────────────────────────────────────────── public function handle_page_types(): \WP_REST_Response { $out = []; foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $type ) { if ( $type->name === 'attachment' ) { continue; } $out[] = [ 'name' => (string) $type->name, 'label' => (string) ( $type->labels->singular_name ?? $type->label ), 'can_create' => current_user_can( $type->cap->create_posts ), 'can_publish' => current_user_can( $type->cap->publish_posts ), ]; } return new \WP_REST_Response( $out, 200 ); } public function handle_page_fields( \WP_REST_Request $request ) { $type = $this->public_type( $request->get_param( 'post_type' ) ); if ( is_wp_error( $type ) ) { return $type; } $fields = []; foreach ( $this->type_fields( $type->name ) as $field ) { $fields[] = [ 'name' => $field['name'], 'label' => $field['label'], 'type' => $field['type'], 'required' => $field['required'] ]; } $photo = $this->photo_field( $type->name ); return new \WP_REST_Response( [ 'acf' => self::acf_active(), 'fields' => $fields, 'photo_field' => $photo ? $photo['name'] : null ], 200 ); } public function handle_page( \WP_REST_Request $request ) { $type = $this->public_type( $request->get_param( 'post_type' ) ); if ( is_wp_error( $type ) ) { return $type; } $status = (string) $request->get_param( 'status' ); $status = in_array( $status, self::PAGE_STATUSES, true ) ? $status : 'draft'; if ( ! current_user_can( $type->cap->create_posts ) || ( $status === 'publish' && ! current_user_can( $type->cap->publish_posts ) ) ) { return $this->error( 'forbidden_on_site', __( 'Le compte Agentsia ne peut pas créer ce type de page sur ce site.', 'agentsia-agents' ), 403 ); } $title = $this->clean_name( $request->get_param( 'title' ) ); if ( $title === '' ) { return $this->error( 'invalid', __( 'Titre de la page manquant ou trop long.', 'agentsia-agents' ), 400 ); } $slug = sanitize_title( (string) ( $request->get_param( 'slug' ) ?: $title ) ); $author = $this->author_param( $request->get_param( 'author_id' ) ); if ( is_wp_error( $author ) ) { return $author; } // Everything is checked before the first write. $writes = $this->field_writes( $type->name, $request->get_param( 'fields' ) ); if ( is_wp_error( $writes ) ) { return $writes; } $existing = get_page_by_path( $slug, OBJECT, $type->name ); if ( $existing instanceof \WP_Post && $existing->post_status !== 'trash' ) { // Never overwritten: the page is returned as it is (linking the author is a separate call). return new \WP_REST_Response( $this->page_view( $existing, false, [] ), 200 ); } $post_id = wp_insert_post( [ 'post_type' => $type->name, 'post_title' => $title, 'post_name' => $slug, 'post_status' => $status, 'post_content' => '', 'post_author' => $author > 0 ? $author : get_current_user_id(), ], true ); if ( is_wp_error( $post_id ) ) { return $this->error( 'site', $post_id->get_error_message(), 500 ); } $fields = $this->type_fields( $type->name ); foreach ( $writes as $name => $value ) { update_field( $fields[ $name ]['key'], wp_slash( $value ), (int) $post_id ); } if ( $author > 0 ) { update_post_meta( (int) $post_id, self::META_PAGE_AUTHOR, $author ); } return new \WP_REST_Response( $this->page_view( get_post( (int) $post_id ), true, array_keys( $writes ) ), 201 ); } public function handle_page_link( \WP_REST_Request $request ) { $post_id = absint( $request->get_param( 'post_id' ) ); if ( $post_id === 0 ) { $url = esc_url_raw( (string) $request->get_param( 'url' ) ); $post_id = $url !== '' ? url_to_postid( $url ) : 0; $post_id = $post_id > 0 || $url === '' ? $post_id : self::unpublished_post_at( $url ); } $post = $post_id > 0 ? get_post( $post_id ) : null; if ( ! $post instanceof \WP_Post || $post->post_status === 'trash' ) { return $this->error( 'page_not_found', __( 'Page introuvable sur le site.', 'agentsia-agents' ), 404 ); } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return $this->error( 'forbidden_on_site', __( 'Le compte Agentsia ne peut pas modifier cette page.', 'agentsia-agents' ), 403 ); } $author = $this->author_param( $request->get_param( 'author_id' ) ); if ( is_wp_error( $author ) ) { return $author; } if ( $author === 0 ) { return $this->error( 'invalid', __( 'Auteur manquant.', 'agentsia-agents' ), 400 ); } update_post_meta( $post->ID, self::META_PAGE_AUTHOR, $author ); return new \WP_REST_Response( $this->page_view( $post, false, [] ), 200 ); } // ── Photo ─────────────────────────────────────────────────────────────────── /** * The member's photo: an image of the media library set on their page and/or as their user's avatar. Each * part is checked before anything is written; a part that fails leaves the other untouched (nothing written). */ public function handle_photo( \WP_REST_Request $request ) { $attachment_id = absint( $request->get_param( 'attachment_id' ) ); $attachment = $attachment_id > 0 ? get_post( $attachment_id ) : null; if ( ! $attachment instanceof \WP_Post || $attachment->post_type !== 'attachment' || ! wp_attachment_is_image( $attachment_id ) ) { return $this->error( 'invalid', __( 'Image introuvable dans la médiathèque.', 'agentsia-agents' ), 400 ); } $post_id = absint( $request->get_param( 'post_id' ) ); $url = esc_url_raw( (string) $request->get_param( 'url' ) ); $author_id = absint( $request->get_param( 'author_id' ) ); if ( $post_id === 0 && $url !== '' ) { // Same resolution as pages/link: published by address, else a draft by its final address. $post_id = url_to_postid( $url ); $post_id = $post_id > 0 ? $post_id : self::unpublished_post_at( $url ); if ( $post_id === 0 ) { return $this->error( 'page_not_found', __( 'Page introuvable sur le site.', 'agentsia-agents' ), 404 ); } } if ( $post_id === 0 && $author_id === 0 ) { return $this->error( 'invalid', __( 'Indiquez la fiche et/ou l\'auteur.', 'agentsia-agents' ), 400 ); } $post = null; if ( $post_id > 0 ) { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post || $post->post_status === 'trash' || $post->post_type === 'attachment' ) { return $this->error( 'page_not_found', __( 'Page introuvable sur le site.', 'agentsia-agents' ), 404 ); } $type = $this->public_type( $post->post_type ); if ( is_wp_error( $type ) ) { return $type; } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return $this->error( 'forbidden_on_site', __( 'Le compte Agentsia ne peut pas modifier cette page.', 'agentsia-agents' ), 403 ); } } $user = null; if ( $author_id > 0 ) { $user = $this->created_user( $author_id ); if ( is_wp_error( $user ) ) { return $user; } } $page_view = null; if ( $post instanceof \WP_Post ) { $field = $this->photo_field( $post->post_type ); if ( $field ) { update_field( $field['key'], $attachment_id, $post->ID ); $target = 'field:' . $field['name']; } else { set_post_thumbnail( $post->ID, $attachment_id ); $target = 'featured_image'; } $page_view = [ 'id' => (int) $post->ID, 'target' => $target ]; } $author_view = null; if ( $user instanceof \WP_User ) { update_user_meta( $user->ID, self::META_USER_AVATAR, $attachment_id ); $author_view = [ 'id' => (int) $user->ID, 'avatar_url' => (string) wp_get_attachment_image_url( $attachment_id, 'thumbnail' ) ]; } return new \WP_REST_Response( [ 'attachment_id' => $attachment_id, 'page' => $page_view, 'author' => $author_view ], 200 ); } /** First ACF image field of a type (where the member's photo goes), or null. */ private function photo_field( string $post_type ): ?array { if ( ! self::acf_active() ) { return null; } foreach ( (array) acf_get_field_groups( [ 'post_type' => $post_type ] ) as $group ) { foreach ( (array) acf_get_fields( $group ) as $field ) { $name = is_array( $field ) ? (string) ( $field['name'] ?? '' ) : ''; $type = is_array( $field ) ? (string) ( $field['type'] ?? '' ) : ''; if ( $name !== '' && $name[0] !== '_' && in_array( $type, self::IMAGE_FIELD_TYPES, true ) && ! empty( $field['key'] ) ) { return [ 'key' => (string) $field['key'], 'name' => $name ]; } } } return null; } /** * The member's photo as their avatar (set from Agentsia). Leaves every other user to WordPress (Gravatar, * other plugins), and a photo removed from the media library falls back the same way. * * @param array $args * @param mixed $id_or_email */ public function avatar_data( $args, $id_or_email ) { $user_id = 0; if ( is_numeric( $id_or_email ) ) { $user_id = (int) $id_or_email; } elseif ( $id_or_email instanceof \WP_User ) { $user_id = (int) $id_or_email->ID; } elseif ( $id_or_email instanceof \WP_Post ) { $user_id = (int) $id_or_email->post_author; } elseif ( $id_or_email instanceof \WP_Comment ) { $user_id = (int) $id_or_email->user_id; } elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) { $found = get_user_by( 'email', $id_or_email ); $user_id = $found instanceof \WP_User ? (int) $found->ID : 0; } if ( $user_id <= 0 ) { return $args; } $attachment_id = (int) get_user_meta( $user_id, self::META_USER_AVATAR, true ); if ( $attachment_id <= 0 ) { return $args; } $size = isset( $args['size'] ) ? max( 1, (int) $args['size'] ) : 96; $url = wp_get_attachment_image_url( $attachment_id, [ $size, $size ] ); if ( ! $url ) { return $args; } $args['url'] = $url; $args['found_avatar'] = true; return $args; } /** * `link`: the address of the page today (`?post_type=…&p=…` for a draft); `permalink`: its final address, * the one to keep (the same once published). */ private function page_view( \WP_Post $post, bool $created, array $fields_written ): array { return [ 'id' => (int) $post->ID, 'post_type' => (string) $post->post_type, 'slug' => (string) $post->post_name, 'status' => (string) $post->post_status, 'link' => (string) get_permalink( $post ), 'permalink' => self::final_permalink( $post ), 'author_id' => (int) get_post_meta( $post->ID, self::META_PAGE_AUTHOR, true ), 'created' => $created, 'fields_written' => $fields_written, ]; } public static function final_permalink( \WP_Post $post ): string { if ( $post->post_status === 'publish' ) { return (string) get_permalink( $post ); } if ( ! function_exists( 'get_sample_permalink' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } [ $template, $name ] = get_sample_permalink( $post->ID ); return (string) str_replace( [ '%postname%', '%pagename%' ], $name, (string) $template ); } /** * An unpublished entry (draft, pending, scheduled) whose final address is $url: url_to_postid() only finds * published ones, so a member's page created as a draft could not be read nor edited by its address. * 0 = none. Only public types, and only when the final address matches exactly. */ public static function unpublished_post_at( string $url ): int { $path = (string) wp_parse_url( $url, PHP_URL_PATH ); $slug = sanitize_title( basename( untrailingslashit( $path ) ) ); if ( $slug === '' ) { return 0; } $types = array_values( array_diff( get_post_types( [ 'public' => true ] ), [ 'attachment' ] ) ); $posts = get_posts( [ 'name' => $slug, 'post_type' => $types, 'post_status' => [ 'draft', 'pending', 'future' ], 'numberposts' => 5, 'suppress_filters' => true, ] ); foreach ( $posts as $post ) { if ( untrailingslashit( self::final_permalink( $post ) ) === untrailingslashit( $url ) ) { return (int) $post->ID; } } return 0; } /** @return \WP_Post_Type|\WP_Error */ private function public_type( $name ) { $name = is_string( $name ) ? $name : ''; $type = $name !== '' && $name !== 'attachment' ? get_post_type_object( $name ) : null; if ( ! $type || ! $type->public ) { return $this->error( 'invalid', __( 'Type de page inconnu ou non public sur ce site.', 'agentsia-agents' ), 400 ); } return $type; } /** @return int|\WP_Error 0 = none. */ private function author_param( $value ) { $id = absint( $value ); if ( $id > 0 && ! get_userdata( $id ) instanceof \WP_User ) { return $this->error( 'invalid', __( 'Auteur introuvable sur le site.', 'agentsia-agents' ), 400 ); } return $id; } private static function acf_active(): bool { return function_exists( 'acf_get_field_groups' ) && function_exists( 'acf_get_fields' ) && function_exists( 'update_field' ); } /** @return array ACF fields an admin may fill. */ private function type_fields( string $post_type ): array { if ( ! self::acf_active() ) { return []; } $out = []; foreach ( (array) acf_get_field_groups( [ 'post_type' => $post_type ] ) as $group ) { foreach ( (array) acf_get_fields( $group ) as $field ) { $name = is_array( $field ) ? (string) ( $field['name'] ?? '' ) : ''; $type = is_array( $field ) ? (string) ( $field['type'] ?? '' ) : ''; if ( ! preg_match( '/^[a-z0-9_-]{1,40}$/', $name ) || $name[0] === '_' || isset( $out[ $name ] ) || ! in_array( $type, self::FIELD_TYPES, true ) || empty( $field['key'] ) ) { continue; } $out[ $name ] = [ 'key' => (string) $field['key'], 'name' => $name, 'label' => is_string( $field['label'] ?? null ) ? $field['label'] : $name, 'type' => $type, 'required' => ! empty( $field['required'] ), ]; } } return $out; } /** @return array|\WP_Error Values cleaned for their type; unknown field = refused. */ private function field_writes( string $post_type, $values ) { if ( $values === null || $values === '' || $values === [] ) { return []; } if ( ! is_array( $values ) ) { return $this->error( 'invalid', __( 'Champs invalides.', 'agentsia-agents' ), 400 ); } $fields = $this->type_fields( $post_type ); $writes = []; foreach ( $values as $name => $value ) { $name = (string) $name; if ( ! isset( $fields[ $name ] ) ) { /* translators: %s: field name. */ return $this->error( 'invalid', sprintf( __( 'Champ « %s » inconnu pour ce type de page.', 'agentsia-agents' ), $name ), 400 ); } if ( ! is_string( $value ) || mb_strlen( $value ) > self::MAX_FIELD_TEXT ) { /* translators: %s: field name. */ return $this->error( 'invalid', sprintf( __( 'Valeur du champ « %s » invalide ou trop longue.', 'agentsia-agents' ), $name ), 400 ); } $clean = $this->clean_value( $fields[ $name ]['type'], wp_check_invalid_utf8( $value ) ); if ( $clean === null ) { /* translators: %s: field name. */ return $this->error( 'invalid', sprintf( __( 'Valeur du champ « %s » invalide.', 'agentsia-agents' ), $name ), 400 ); } if ( $clean !== '' ) { $writes[ $name ] = $clean; } } return $writes; } /** null = refused (an e-mail or address that is not one). */ private function clean_value( string $type, string $text ): ?string { $text = trim( $text ); if ( $text === '' ) { return ''; } switch ( $type ) { case 'text': return sanitize_text_field( (string) preg_replace( '/[\r\n]+/', ' ', $text ) ); case 'textarea': return sanitize_textarea_field( $text ); case 'email': $email = sanitize_email( $text ); return is_email( $email ) ? $email : null; case 'url': $url = esc_url_raw( $text, [ 'http', 'https' ] ); return $url !== '' ? $url : null; default: // wysiwyg return trim( wp_kses_post( wpautop( $text ) ) ); } } // ── Helpers ───────────────────────────────────────────────────────────────── /** One line, at most MAX_NAME characters ('' = missing or too long). */ private function clean_name( $value ): string { $text = is_string( $value ) ? sanitize_text_field( $value ) : ''; return mb_strlen( $text ) <= self::MAX_NAME ? $text : ''; } private function error( string $code, string $message, int $status, array $data = [] ): \WP_Error { return new \WP_Error( 'agentsia_site_members_' . $code, $message, array_merge( [ 'status' => $status ], $data ) ); } }