๐Ÿ“ฆ EqualifyEverything / equalify-iris-wp

๐Ÿ“„ class-post-type.php ยท 389 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389<?php
/**
 * WHAT IS THIS FILE?
 *
 * The hidden post type that holds each converted document, and the URL it lives at.
 *
 * WHY DOES IT EXIST?
 *
 * The converted HTML needs a permanent public address, a title, and a slug. In
 * WordPress, a thing with an address, a title and a slug is a post. So rather
 * than inventing a storage mechanism and a routing mechanism, we register a post
 * type and get both for free โ€” along with permalinks, sitemaps, and every theme's
 * existing styling.
 *
 * WHAT "HIDDEN" MEANS, EXACTLY
 *
 * This is the part to read carefully, because getting one flag wrong either
 * breaks the feature or leaks content.
 *
 * Hidden from EDITORS: no admin menu, no list table, not in the block editor, not
 * in the REST API, not in site search results, no archive page. Editors have no
 * job to do with these posts, so putting them in the admin would only be clutter
 * and a chance to break something.
 *
 * NOT hidden from VISITORS. The page must be publicly reachable โ€” that is the
 * entire point of the plugin. `public` is therefore true. If anyone ever sets it
 * to false to make the post type feel more private, every accessible document on
 * the network goes offline at once.
 *
 * SHOULD SEARCH ENGINES INDEX THESE?
 *
 * Yes, and we do nothing to stop them. An accessible HTML version of a document
 * is more useful in search results than the PDF it came from, and indexing it
 * helps people who would never see the icon on the original page. It is excluded
 * from a site's OWN search only so an editor searching for their content is not
 * wading through converted documents.
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class Equalify_Iris_Post_Type {

	/** The post type name. Kept short because it appears in every query. */
	const POST_TYPE = 'equalify_iris_doc';

	/** The first segment of the public URL. */
	const URL_BASE = 'equalify-iris';

	/** Meta key: which PDF attachment this page was made from. */
	const META_ATTACHMENT = '_equalify_iris_attachment_id';

	/** Meta key: the Iris session that produced it, for tracing a problem back. */
	const META_SESSION = '_equalify_iris_session_id';

	/** Meta key: the SHA-256 of the PDF this was converted from. */
	const META_HASH = '_equalify_iris_source_hash';

	/** Meta key: when it was converted, as a Unix timestamp. */
	const META_CONVERTED_AT = '_equalify_iris_converted_at';

	/** Meta key: the table of contents, stored so we do not rebuild it per view. */
	const META_HEADINGS = '_equalify_iris_headings';

	/** Meta key: the original PDF's page count, shown on the page. */
	const META_PAGE_COUNT = '_equalify_iris_page_count';

	/** Meta key: the original PDF's size in bytes, shown on the page. */
	const META_FILE_BYTES = '_equalify_iris_file_bytes';

	/**
	 * Meta key: how many of the PDF's pages Equalify Iris could not read.
	 *
	 * Nearly always 0. When it is not, this page is a real improvement on the PDF
	 * and still missing part of it, and that is a difference worth being able to
	 * find later โ€” by eye in the activity log, or by query across the network.
	 */
	const META_PAGES_MISSING = '_equalify_iris_pages_missing';

	public function init(): void {
		add_action( 'init', array( $this, 'register' ) );
		add_action( 'init', array( $this, 'add_rewrite_rule' ) );
		add_filter( 'post_type_link', array( $this, 'filter_permalink' ), 10, 2 );
		add_action( 'template_redirect', array( $this, 'redirect_to_canonical_url' ) );
		add_filter( 'template_include', array( $this, 'use_our_template' ) );

		// Converted documents are not something an editor should be able to edit,
		// delete, or find. Belt and braces on top of `show_ui => false`.
		add_filter( 'map_meta_cap', array( $this, 'block_editing' ), 10, 4 );
	}

	/**
	 * Register the post type.
	 */
	public function register(): void {
		register_post_type(
			self::POST_TYPE,
			array(
				'labels'              => array(
					'name'          => __( 'Accessible Documents', 'equalify-iris' ),
					'singular_name' => __( 'Accessible Document', 'equalify-iris' ),
				),

				// Visitors: yes. Editors: no. See the note at the top of the file.
				'public'              => true,
				'publicly_queryable'  => true,
				'show_ui'             => false,
				'show_in_menu'        => false,
				'show_in_nav_menus'   => false,
				'show_in_admin_bar'   => false,
				'show_in_rest'        => false,
				'exclude_from_search' => true,
				'has_archive'         => false,

				// We add our own rule in add_rewrite_rule() to get the two-segment
				// URL, so WordPress should not also add a one-segment rule that
				// would resolve the same content at a second address.
				'rewrite'             => false,

				// `title` and `editor` because we store a title and HTML content.
				// `custom-fields` because everything else about the document lives
				// in post meta.
				'supports'            => array( 'title', 'editor', 'custom-fields' ),

				// Not hierarchical: these are flat documents with no parents.
				'hierarchical'        => false,

				// Let the sitemap include them. They are real, useful pages.
				'capability_type'     => 'post',
				'map_meta_cap'        => true,
			)
		);
	}

	/**
	 * Teach WordPress the two-segment URL.
	 *
	 * The public address of a document is:
	 *
	 *     /equalify-iris/1184/annual-report/
	 *                    ^^^^  ^^^^^^^^^^^^
	 *            attachment id   title slug
	 *
	 * A post type's built-in rewrite can only give us ONE segment after the base,
	 * so a two-segment URL needs a rule of our own.
	 *
	 * THE TRICK THAT MAKES THIS SIMPLE
	 *
	 * The post's real slug is the two segments joined with a hyphen โ€”
	 * `1184-annual-report`. So this rule does not have to look anything up in the
	 * database: it just glues the two captured segments back together with a
	 * hyphen and hands WordPress a slug. No meta query, no extra work per request,
	 * and no chance of two documents colliding, because attachment ids are unique
	 * within a site.
	 */
	public function add_rewrite_rule(): void {
		add_rewrite_rule(
			'^' . self::URL_BASE . '/([0-9]+)/([^/]+)/?$',
			'index.php?' . self::POST_TYPE . '=$matches[1]-$matches[2]',
			'top'
		);
	}

	/**
	 * Build the pretty URL for a document.
	 *
	 * Without this, get_permalink() would return the ugly `?equalify_iris_doc=...`
	 * form, and every place in the plugin that needs a URL would have to assemble
	 * the pretty one by hand. Doing it here means the rest of the code just calls
	 * get_permalink() like it would for any post.
	 */
	public function filter_permalink( string $url, WP_Post $post ): string {
		if ( self::POST_TYPE !== $post->post_type ) {
			return $url;
		}

		$attachment_id = (int) get_post_meta( $post->ID, self::META_ATTACHMENT, true );

		if ( ! $attachment_id ) {
			return $url;
		}

		// The slug is "1184-annual-report"; strip the leading id to get the title
		// part back, then write it out as two segments.
		$slug = preg_replace( '#^' . $attachment_id . '-#', '', $post->post_name );

		if ( ! $slug ) {
			return $url;
		}

		// Sites without pretty permalinks have no rewrite rules at all, so the
		// two-segment URL would 404. Fall back to the query form for them.
		if ( ! get_option( 'permalink_structure' ) ) {
			return add_query_arg( self::POST_TYPE, $post->post_name, home_url( '/' ) );
		}

		return home_url( '/' . self::URL_BASE . '/' . $attachment_id . '/' . $slug . '/' );
	}

	/**
	 * Send visitors to the pretty URL when they arrive by any other route.
	 *
	 * In practice this means the query form, `?equalify_iris_doc=1184-annual-report`,
	 * which WordPress resolves happily and which is a second working address for a
	 * page that already has one. One canonical URL per document is worth having, so
	 * we redirect permanently to it.
	 *
	 * WHAT THIS DOES NOT COVER
	 *
	 * Not old slugs. If a PDF is retitled and reconverted, the document's slug
	 * changes and previously published links 404 rather than landing here โ€” the
	 * rewrite rule in add_rewrite_rule() turns the two URL segments into an exact
	 * post_name, so an address whose slug no longer exists never resolves to a post
	 * at all. Core's wp_old_slug_redirect() does not help either: it reads the
	 * `name` query var, and our rule sets `equalify_iris_doc`. Worth fixing, but it
	 * is a separate job from this one.
	 */
	public function redirect_to_canonical_url(): void {
		if ( ! is_singular( self::POST_TYPE ) ) {
			return;
		}

		$post = get_queried_object();

		if ( ! $post instanceof WP_Post ) {
			return;
		}

		$canonical = get_permalink( $post );

		// Compare paths only. Comparing whole URLs would fire a redirect on a
		// harmless difference like http vs https behind a proxy, which is a
		// redirect loop waiting to happen.
		//
		// add_query_arg( array() ) is WordPress's way of saying "the current
		// request path". It is already absolute and already includes the site's
		// own path segment, so it must NOT be passed through home_url(): on a
		// subdirectory multisite that prepends `/research` to a path that starts
		// with `/research`, the doubled path never matches the canonical one, and
		// every document page on every sub-site 301s to itself for ever. The main
		// site is unaffected because its home path is `/`, which is exactly what
		// makes this the kind of bug that ships.
		$canonical_path = wp_parse_url( $canonical, PHP_URL_PATH );
		$requested_path = wp_parse_url( add_query_arg( array() ), PHP_URL_PATH );

		if ( $canonical_path && $requested_path && untrailingslashit( $canonical_path ) !== untrailingslashit( $requested_path ) ) {
			wp_safe_redirect( $canonical, 301 );
			exit;
		}
	}

	/**
	 * Use the plugin's template for a document page, unless the theme has its own.
	 *
	 * A theme can override this simply by adding `single-equalify_iris_doc.php`,
	 * which is the standard WordPress way and needs no documentation of ours.
	 */
	public function use_our_template( string $template ): string {
		if ( ! is_singular( self::POST_TYPE ) ) {
			return $template;
		}

		$theme_template = locate_template( array( 'single-' . self::POST_TYPE . '.php' ) );

		if ( $theme_template ) {
			return $theme_template;
		}

		return EQUALIFY_IRIS_PATH . 'templates/single-document.php';
	}

	/**
	 * Refuse edit and delete permissions on converted documents, for everyone.
	 *
	 * `show_ui => false` hides these posts from the admin, but hiding is not the
	 * same as preventing. This closes the gap: nobody edits a converted document
	 * by hand, because the next conversion would overwrite their changes anyway
	 * and they would rightly consider that a bug.
	 */
	public function block_editing( array $caps, string $cap, int $user_id, array $args ): array {
		$editing_caps = array( 'edit_post', 'delete_post' );

		if ( ! in_array( $cap, $editing_caps, true ) || empty( $args[0] ) ) {
			return $caps;
		}

		$post = get_post( $args[0] );

		if ( $post && self::POST_TYPE === $post->post_type ) {
			// `do_not_allow` is the capability nobody has, including super admins.
			return array( 'do_not_allow' );
		}

		return $caps;
	}

	/**
	 * The slug a document should have.
	 *
	 * Putting the attachment id first is what makes the two-segment URL work
	 * without a database lookup, and what guarantees uniqueness.
	 */
	public static function build_slug( int $attachment_id, string $title ): string {
		$title_slug = sanitize_title( $title );

		if ( '' === $title_slug ) {
			$title_slug = 'document';
		}

		return $attachment_id . '-' . $title_slug;
	}

	/**
	 * Find the document page for a PDF on the current site, if there is one.
	 *
	 * @return WP_Post|null
	 */
	public static function find_for_attachment( int $attachment_id ): ?WP_Post {
		$posts = get_posts(
			array(
				'post_type'        => self::POST_TYPE,
				'post_status'      => array( 'publish', 'draft' ),
				'posts_per_page'   => 1,
				'meta_key'         => self::META_ATTACHMENT, // phpcs:ignore WordPress.DB.SlowDBQuery
				'meta_value'       => $attachment_id,        // phpcs:ignore WordPress.DB.SlowDBQuery
				'suppress_filters' => false,
				'no_found_rows'    => true,
			)
		);

		return $posts ? $posts[0] : null;
	}

	/**
	 * Ask every site in the network to rebuild its URL rules.
	 *
	 * Rewrite rules are cached, so our rule does nothing at all until they are
	 * regenerated โ€” and until then every converted page is a 404 while the icon
	 * next to the PDF links confidently at it. That is the worst possible failure
	 * for this plugin, so the mechanism below is worth understanding before
	 * changing it.
	 *
	 * WHY A TOKEN AND NOT A FLAG?
	 *
	 * Because rewrite rules are PER SITE and this plugin is network-activated.
	 * flush_rewrite_rules() rebuilds the `rewrite_rules` option of the one site
	 * it is called on, and nothing else. A single network-wide "please flush"
	 * flag therefore cannot work: whichever site loads first deletes the flag,
	 * flushes itself, and every other site in the network keeps its stale rules
	 * forever. On a 200-site network that is 199 sites of 404s.
	 *
	 * So instead: this bumps a token stored network-wide, and each site records
	 * the token it last flushed for in its own option. Every site notices
	 * independently that it is behind, once, on its next request. New sites added
	 * later are covered for free, because a site that has never flushed has no
	 * recorded token at all.
	 */
	public static function schedule_rewrite_flush(): void {
		update_site_option( 'equalify_iris_rewrite_token', (string) time() );
	}

	/**
	 * Rebuild this site's URL rules if it has not caught up with the token yet.
	 *
	 * Runs on `init` at priority 99 โ€” after register() and add_rewrite_rule() have
	 * put our rule in place, because flushing before that would faithfully cache
	 * a rule set without it.
	 */
	public static function maybe_flush_rewrites(): void {
		$wanted = (string) get_site_option( 'equalify_iris_rewrite_token', '' );

		if ( '' === $wanted ) {
			return;
		}

		if ( (string) get_option( 'equalify_iris_rewrite_token', '' ) === $wanted ) {
			return;
		}

		// Recorded before flushing, not after. If the flush itself fails or the
		// request dies halfway through, this site has still had its one attempt โ€”
		// the alternative is an expensive rebuild on every single page load.
		update_option( 'equalify_iris_rewrite_token', $wanted );

		flush_rewrite_rules( false );
	}
}