PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.0
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / addons / link-guard / frontend.php

frontend.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.13.0, at addons/link-guard/frontend.php

253 lines 7.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace ABlocksLinkGuard;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit; // Exit if accessed directly.
7 }
8
9 use ABlocks\Classes\CacheBackend;
10
11 /**
12 * Takes links to unpublished posts out of rendered content.
13 *
14 * ## What it costs
15 *
16 * Resolving a URL to a post is a rewrite-rule walk plus a query, so the answer
17 * is stored on the post that contains the link (`META`), keyed by URL. Post
18 * meta arrives with the post in the main query, so a warm render costs one
19 * query at most — priming the linked posts it does not already have.
20 *
21 * Only the URL → ID mapping is stored, never "hidden or not". Whether a link
22 * shows is decided on every render from the target's live status, so a stored
23 * map can never keep a published post's link hidden.
24 *
25 * ## "Not a post" goes stale; "post #42" does not
26 *
27 * A positive answer is stable: post #42 stays post #42 whatever happens to its
28 * status. A 0 is not — a writer may link to `/next-weeks-post/` before that
29 * post exists. Each map records the generation it was built in, and a 0 from an
30 * older generation is resolved again. `Invalidation` advances the generation
31 * whenever a post appears or changes slug.
32 */
33 class Frontend {
34
35 /** URL → post ID map for the links inside a post. */
36 const META = '_ablocks_link_guard';
37
38 /** One row per post this post links to; the reverse index Invalidation reads. */
39 const TARGET_META = '_ablocks_link_guard_target';
40
41 /** Maps larger than this stop growing; everything still works, uncached. */
42 const MAX_ENTRIES = 500;
43
44 const MARKER = 'data-ablocks-link-guard';
45
46 /**
47 * Resolutions made during this request, shared across content filters.
48 *
49 * @var array<string, int>
50 */
51 private $memo = [];
52
53 public static function init() {
54 $self = new self();
55 // Late: after blocks, shortcodes and other content filters have added
56 // whatever links they add.
57 add_filter( 'the_content', [ $self, 'filter_content' ], 99 );
58 }
59
60 public function filter_content( $content ) {
61 if ( ! is_string( $content ) || false === stripos( $content, '<a' ) ) {
62 return $content;
63 }
64
65 return $this->filter_html( $content, (int) get_the_ID() );
66 }
67
68 /**
69 * @param string $html Rendered HTML.
70 * @param int $post_id Post the HTML belongs to, where the lookup map is
71 * cached. 0 resolves without caching.
72 * @return string
73 */
74 public function filter_html( $html, $post_id = 0 ) {
75 $urls = [];
76 $processor = new \WP_HTML_Tag_Processor( $html );
77
78 while ( $processor->next_tag( 'a' ) ) {
79 $url = Resolver::normalize( $processor->get_attribute( 'href' ) );
80 if ( '' !== $url ) {
81 $urls[ $url ] = true;
82 }
83 }
84
85 if ( ! $urls ) {
86 return $html;
87 }
88
89 $ids = $this->lookup( array_keys( $urls ), $post_id );
90 _prime_post_caches( array_values( array_unique( array_filter( $ids ) ) ), false, false );
91
92 $hidden = [];
93 foreach ( $ids as $url => $id ) {
94 if ( $id && $id !== $post_id && ! self::is_linkable( $id ) ) {
95 $hidden[ $url ] = $id;
96 }
97 }
98
99 if ( ! $hidden ) {
100 return $html;
101 }
102
103 $marked = false;
104 $processor = new \WP_HTML_Tag_Processor( $html );
105
106 while ( $processor->next_tag( 'a' ) ) {
107 $href = $processor->get_attribute( 'href' );
108 $url = Resolver::normalize( $href );
109
110 if ( ! isset( $hidden[ $url ] ) ) {
111 continue;
112 }
113
114 /**
115 * What to do with a link to a post visitors cannot see.
116 *
117 * - `unwrap` (default): drop the `<a>`, keep its contents, so the
118 * text still shows. Button-styled links included: their styling
119 * lives on the `<a>`, so the label shows as plain text.
120 * - `remove`: drop the link and its contents.
121 * - `keep`: leave the link alone.
122 *
123 * @param string $action unwrap|remove|keep.
124 * @param int $target_id The unpublished post.
125 * @param string $href The link as written.
126 * @param int $post_id The post being rendered.
127 */
128 $action = apply_filters( 'ablocks/link_guard/action', 'unwrap', $hidden[ $url ], $href, $post_id );
129
130 if ( in_array( $action, [ 'unwrap', 'remove' ], true ) ) {
131 $processor->set_attribute( self::MARKER, $action );
132 $marked = true;
133 }
134 }//end while
135
136 if ( ! $marked ) {
137 return $html;
138 }
139
140 // The tag processor cannot delete a tag and keep its children, so it
141 // marks the anchors and this removes them. Attribute values are matched
142 // as whole quoted strings, so a `>` inside a title cannot end the tag
143 // early. Anchors cannot nest, so the first `</a>` closes the one opened.
144 $attr = '(?:[^>"\']|"[^"]*"|\'[^\']*\')';
145 $pattern = '#<a\b' . $attr . '*?\s' . self::MARKER . '="(unwrap|remove)"' . $attr . '*>(.*?)</a\s*>#is';
146
147 $result = preg_replace_callback(
148 $pattern,
149 static function ( $match ) {
150 return 'remove' === $match[1] ? '' : $match[2];
151 },
152 $processor->get_updated_html()
153 );
154
155 return null === $result ? $html : $result;
156 }
157
158 /**
159 * Whether visitors can open a post.
160 *
161 * @param int $post_id Target post.
162 * @return bool
163 */
164 public static function is_linkable( $post_id ) {
165 $post = get_post( $post_id );
166
167 if ( ! $post ) {
168 $linkable = false;
169 } elseif ( 'attachment' === $post->post_type ) {
170 // Attachments inherit their status, and an attachment page is not
171 // what a writer is waiting to publish.
172 $linkable = true;
173 } else {
174 $linkable = is_post_publicly_viewable( $post );
175 }
176
177 /**
178 * @param bool $linkable Whether the link should be shown.
179 * @param int $post_id Target post ID.
180 * @param \WP_Post|null $post Target post, null when it no longer exists.
181 */
182 return (bool) apply_filters( 'ablocks/link_guard/is_linkable', $linkable, (int) $post_id, $post );
183 }
184
185 /**
186 * Post ID for each URL, from the request memo, the post's stored map, or a
187 * fresh resolve — in that order. Fresh answers are written back.
188 *
189 * @param string[] $urls Normalized URLs.
190 * @param int $post_id Owner of the stored map, or 0.
191 * @return array<string, int>
192 */
193 private function lookup( array $urls, $post_id ) {
194 $generation = CacheBackend::generation( ABLOCKS_LINK_GUARD_GENERATION_OPTION );
195 $stored = $post_id ? get_post_meta( $post_id, self::META, true ) : [];
196 $map = ( is_array( $stored ) && isset( $stored['map'] ) && is_array( $stored['map'] ) ) ? $stored['map'] : [];
197 $fresh = is_array( $stored ) && isset( $stored['generation'] ) && (int) $stored['generation'] === $generation;
198
199 $result = [];
200 $dirty = false;
201
202 foreach ( $urls as $url ) {
203 if ( isset( $this->memo[ $url ] ) ) {
204 $id = $this->memo[ $url ];
205 $dirty = $dirty || ! isset( $map[ $url ] );
206 } elseif ( isset( $map[ $url ] ) && ( (int) $map[ $url ] > 0 || $fresh ) ) {
207 $id = (int) $map[ $url ];
208 } else {
209 $id = Resolver::resolve( $url );
210 $dirty = true;
211 }
212
213 $this->memo[ $url ] = $id;
214 $result[ $url ] = $id;
215 }
216
217 if ( $dirty && $post_id && ! wp_is_post_revision( $post_id ) ) {
218 $this->store( $post_id, $map, $fresh, $result, $generation );
219 }
220
221 return $result;
222 }
223
224 private function store( $post_id, array $map, $fresh, array $result, $generation ) {
225 if ( ! $fresh ) {
226 // Old zeros that were not re-checked in this render would otherwise
227 // be stamped with the current generation and trusted again.
228 $map = array_filter( $map );
229 }
230
231 $map = array_merge( $map, $result );
232 if ( count( $map ) > self::MAX_ENTRIES ) {
233 return;
234 }
235
236 update_post_meta(
237 $post_id,
238 self::META,
239 [
240 'generation' => $generation,
241 'map' => $map,
242 ]
243 );
244
245 $indexed = array_map( 'intval', get_post_meta( $post_id, self::TARGET_META ) );
246 foreach ( array_unique( array_filter( $result ) ) as $target_id ) {
247 if ( $target_id !== $post_id && ! in_array( $target_id, $indexed, true ) ) {
248 add_post_meta( $post_id, self::TARGET_META, $target_id );
249 }
250 }
251 }
252 }
253