PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 All 202 releases
← All changes | includes/Core/Request.php +97 -5 4.9.1 → 4.9.4 View file →
@@ -912,8 +912,17 @@
912 912 if ( $this->invalid_request_query_vars !== null ) {
913 913 return;
914 914 }
915 915
916 + // A single-doc request whose main query returned nothing (private or
917 + // draft doc for a visitor who can't read it, a doc in another/inactive
918 + // WPML language) must stay a 404. Marking it singular here would render
919 + // the single template against a null post and expose the doc's title
920 + // through the queried object. See betterdocs/betterdocs#173.
921 + if ( $this->is_singular_docs_request() && ! $this->main_query_has_posts() ) {
922 + return;
923 + }
924 +
916 925 // Check if we have doc_category or knowledge_base in query vars
917 926 if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) {
918 927 // If this is already identified as singular, don't override it
919 928 if ( $wp_query->is_singular() || $wp_query->is_singular ) {
@@ -948,14 +957,40 @@
948 957
949 958 // Check if we have 'docs' query var (alternative to 'name')
950 959 if ( isset( $wp_query->query_vars['docs'] ) && ! empty( $wp_query->query_vars['docs'] ) ) {
951 960
961 + // Verify the requested doc actually exists (and is visible to the
962 + // current user) before forcing a single-doc render. Category-in-path
963 + // permalinks put the doc slug in the `docs` var alongside a valid
964 + // doc_category; without this guard a missing slug under a real
965 + // category rendered the single template against a null post — an
966 + // HTTP 200 soft-404 plus "read property on null" warnings on every
967 + // (often bot) hit. See betterdocs/betterdocs#169.
968 + $doc_post = get_page_by_path( $wp_query->query_vars['docs'], OBJECT, 'docs' );
969 + $doc_visible = $doc_post
970 + && ( 'private' !== $doc_post->post_status || current_user_can( 'read_private_docs' ) );
971 +
972 + if ( ! $doc_visible ) {
973 + // No such doc (or private and not permitted) — serve a genuine
974 + // 404 instead of a single render against a null post.
975 + $wp_query->set_404();
976 + status_header( 404 );
977 + nocache_headers();
978 + add_filter( 'template_include', function( $template ) {
979 + $not_found = get_404_template();
980 + return $not_found ? $not_found : $template;
981 + }, 999 );
982 + return;
983 + }
984 +
952 985 // Explicitly set this as a single post
953 - $wp_query->is_single = true;
954 - $wp_query->is_singular = true;
955 - $wp_query->is_404 = false;
956 - $wp_query->is_archive = false;
957 - $wp_query->is_tax = false;
986 + $wp_query->is_single = true;
987 + $wp_query->is_singular = true;
988 + $wp_query->is_404 = false;
989 + $wp_query->is_archive = false;
990 + $wp_query->is_tax = false;
991 + $wp_query->queried_object = $doc_post;
992 + $wp_query->queried_object_id = $doc_post->ID;
958 993 return;
959 994 }
960 995
961 996 // If 'name' is set, check if a post with that name exists
@@ -1054,8 +1089,16 @@
1054 1089 if ( $this->invalid_request_query_vars !== null ) {
1055 1090 return $status_header;
1056 1091 }
1057 1092
1093 + // A single-doc request whose main query returned nothing is a genuine
1094 + // 404 — whatever the slug/term lookups below would find. Checked before
1095 + // both overrides, so neither can turn it into a soft-404 200.
1096 + // See betterdocs/betterdocs#173.
1097 + if ( 404 === (int) $code && $this->is_singular_docs_request() && ! $this->main_query_has_posts() ) {
1098 + return $status_header;
1099 + }
1100 +
1058 1101 // If a 404 is being sent but the queried object is a valid single docs post,
1059 1102 // override with 200. This guards against false 404s on single docs pages.
1060 1103 if ( $code == 404 &&
1061 1104 isset( $wp_query->queried_object ) &&
@@ -1075,8 +1118,24 @@
1075 1118 if ( $code == 404 && (
1076 1119 (isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category'])) ||
1077 1120 (isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']))
1078 1121 ) ) {
1122 + // A single-doc request (category-in-path permalinks carry the doc slug
1123 + // in the `docs`/`name` var alongside doc_category) must resolve to a real
1124 + // doc. A valid doc is already served 200 by the queried-object branch
1125 + // above; if we reach here with a single-doc indicator but no resolvable
1126 + // post, the doc does not exist — keep the genuine 404 rather than forcing
1127 + // a soft-404 200 off the category term alone. See betterdocs/betterdocs#169.
1128 + $single_doc_slug = '';
1129 + if ( isset( $wp_query->query_vars['docs'] ) && ! empty( $wp_query->query_vars['docs'] ) ) {
1130 + $single_doc_slug = $wp_query->query_vars['docs'];
1131 + } elseif ( isset( $wp_query->query_vars['name'] ) && ! empty( $wp_query->query_vars['name'] ) ) {
1132 + $single_doc_slug = $wp_query->query_vars['name'];
1133 + }
1134 + if ( '' !== $single_doc_slug && ! get_page_by_path( $single_doc_slug, OBJECT, 'docs' ) ) {
1135 + return $status_header;
1136 + }
1137 +
1079 1138 // Validate existence before forcing 200
1080 1139 // Use encoded fallback so Bengali/Arabic/CJK slugs are found correctly.
1081 1140 if ( isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category']) ) {
1082 1141 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
@@ -1100,8 +1159,41 @@
1100 1159 return 'HTTP/1.1 200 OK';
1101 1160 }
1102 1161
1103 1162 return $status_header;
1163 + }
1164 +
1165 + /**
1166 + * Whether the main query targets a single doc (`name`, `docs` or `p`),
1167 + * as opposed to an archive.
1168 + *
1169 + * @return bool
1170 + */
1171 + protected function is_singular_docs_request() {
1172 + global $wp_query;
1173 +
1174 + if ( ! $wp_query instanceof \WP_Query ) {
1175 + return false;
1176 + }
1177 +
1178 + $query_vars = $wp_query->query_vars;
1179 +
1180 + return ! empty( $query_vars['name'] )
1181 + || ! empty( $query_vars['docs'] )
1182 + || ( isset( $query_vars['p'] ) && (int) $query_vars['p'] > 0 );
1183 + }
1184 +
1185 + /**
1186 + * Whether the main query actually returned posts. Slug and term lookups
1187 + * only prove something exists; this is what the visitor can see — it
1188 + * already honours post status, capabilities and WPML's language filter.
1189 + *
1190 + * @return bool
1191 + */
1192 + protected function main_query_has_posts() {
1193 + global $wp_query;
1194 +
1195 + return $wp_query instanceof \WP_Query && ! empty( $wp_query->posts );
1104 1196 }
1105 1197
1106 1198 /**
1107 1199 * Ensure tax_query is always initialized as an object