PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / site-editor-views / Provenance.php

Provenance.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/site-editor-views/Provenance.php

334 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The provenance record for imported content (spec 054).
4 *
5 * Records where an item came from, when, and how its content came to be. It is
6 * a record of ORIGIN, not of state: later edits never update it, because the
7 * question it answers is "where did this page come from?" and the answer does
8 * not change when someone rewrites a heading.
9 *
10 * Two properties are worth stating before the code:
11 *
12 * **It is written for everything the import creates, not only what a screen
13 * shows.** The Pages screen is the only consumer today. Recording media, custom
14 * types and terms as well costs nothing — the pipeline already stamps every one
15 * of them through a single meta bag — and it is what lets a later surface be
16 * added without the backfill spec 054 rules out (FR-001a, FR-005).
17 *
18 * **The generated-vs-curated answer is per RUN, not per item** (FR-001b). No
19 * per-item marker exists anywhere in the import pipeline, so a per-item claim
20 * would be a precision we cannot substantiate. A run where only some items were
21 * generated reports at run granularity, and says so.
22 *
23 * PHP 7.2 SYNTAX ONLY (Constitution XV).
24 *
25 * @package Templately
26 */
27
28 namespace Templately\Modules\SiteEditorViews;
29
30 defined( 'ABSPATH' ) || exit;
31
32 class Provenance {
33
34 /** The pack an item came from. Opaque string — never treated as a number. */
35 const KEY_PACK = '_templately_pack_id';
36
37 /** When it arrived. UTC, ISO 8601, stored as a string so the zone is unambiguous. */
38 const KEY_IMPORTED_AT = '_templately_imported_at';
39
40 /** How the content came to be: cloud | ai | curated. */
41 const KEY_SOURCE = '_templately_source';
42
43 /**
44 * The import run responsible.
45 *
46 * Already written by the pipeline as `BaseRunner::META_SESSION_KEY`; this
47 * module adopts that key rather than storing the same fact twice.
48 */
49 const KEY_SESSION = '_templately_import_session_id';
50
51 /** Content assembled from a pack as authored. */
52 const SOURCE_CURATED = 'curated';
53
54 /** Content generated for this site. */
55 const SOURCE_AI = 'ai';
56
57 /** Content that came from the cloud without generation. Displays as curated. */
58 const SOURCE_CLOUD = 'cloud';
59
60 /**
61 * Register the meta so it is readable wherever the item is.
62 *
63 * Registered for BOTH posts and terms, because the import creates both.
64 *
65 * @return void
66 */
67 public function register(): void {
68 foreach ( $this->definitions() as $key => $definition ) {
69 register_post_meta( '', $key, $definition );
70 register_term_meta( '', $key, $definition );
71 }
72
73 add_filter( 'rest_request_before_callbacks', [ $this, 'drop_write_attempts' ], 10, 3 );
74 }
75
76 /**
77 * Remove our keys from any incoming REST `meta` bag, before core reads it.
78 *
79 * WITHOUT THIS, `auth_callback => false` MAKES EVERY UNTAGGED POST UNSAVABLE
80 * IN THE BLOCK EDITOR. The chain, all of it core's:
81 *
82 * 1. `show_in_rest` puts all four keys in the GET response for EVERY post,
83 * as `''` when no row exists — FR-004/FR-013 require that exposure.
84 * 2. The block editor holds `meta` as one entity property. Dirty ANY meta
85 * (`footnotes` alone is enough) and it PUTs the whole bag back, ours
86 * included, at the empty values it was handed.
87 * 3. `WP_REST_Meta_Fields::update_meta_value()` skips the cap check only when
88 * `1 === count( get_metadata_raw( ... ) )` and the value is unchanged. A
89 * post with NO row has no old value, so the short-circuit does not apply,
90 * `current_user_can( 'edit_post_meta' )` runs, our auth callback refuses,
91 * and the save 403s with "Sorry, you are not allowed to edit the
92 * _templately_pack_id custom field."
93 *
94 * So the failure lands on content we never touched — an IMPORTED page has a
95 * stored value, matches, and short-circuits; every hand-authored page does
96 * not. Reproduced on WP 7.1-beta4: 129 of 129 posts and pages unsavable,
97 * which also makes the editor's own "Attempt recovery" a dead end, since the
98 * recovered block can never be saved.
99 *
100 * `readonly` in a meta schema would be the obvious fix and does not exist —
101 * `class-wp-rest-meta-fields.php` never reads it. Dropping the keys is the
102 * available one, and it is a STRICTER answer than the 403 it replaces: a
103 * genuine forgery attempt is discarded rather than argued with, and a
104 * round-trip is a no-op. `auth_callback` stays false as the backstop for any
105 * path that does not come through here.
106 *
107 * @param WP_REST_Response|WP_Error|null $response Unmodified, always.
108 * @param array $handler Route handler.
109 * @param WP_REST_Request $request Mutated in place.
110 * @return WP_REST_Response|WP_Error|null
111 */
112 public function drop_write_attempts( $response, $handler, $request ) {
113 if ( ! $request instanceof \WP_REST_Request ) {
114 return $response;
115 }
116
117 $meta = $request['meta'];
118
119 if ( ! is_array( $meta ) ) {
120 return $response;
121 }
122
123 $stripped = array_diff_key( $meta, $this->definitions() );
124
125 if ( count( $stripped ) !== count( $meta ) ) {
126 $request['meta'] = $stripped;
127 }
128
129 return $response;
130 }
131
132 /**
133 * The four keys and how each is registered.
134 *
135 * `show_in_rest` is required, not incidental: the screen renders these from
136 * the record it already fetched, and without REST exposure it would need a
137 * request per row — the per-item work FR-013 forbids.
138 *
139 * `auth_callback` refuses WRITES outright. The pipeline writes these
140 * directly during import; nothing should be able to set them over REST, and
141 * a provenance record a user can forge is not a record. Reads are governed
142 * by the item's own permissions, which is what "readable by anyone already
143 * permitted to read the item" means in practice (FR-004).
144 *
145 * @return array<string, array<string, mixed>>
146 */
147 public function definitions(): array {
148 $refuse_writes = function () {
149 return false;
150 };
151
152 return [
153 self::KEY_PACK => [
154 'single' => true,
155 'type' => 'string',
156 'show_in_rest' => true,
157 'sanitize_callback' => 'sanitize_text_field',
158 'auth_callback' => $refuse_writes,
159 'description' => __( 'The Templately pack this content was imported from.', 'templately' ),
160 ],
161 self::KEY_IMPORTED_AT => [
162 'single' => true,
163 'type' => 'string',
164 'show_in_rest' => true,
165 'sanitize_callback' => 'sanitize_text_field',
166 'auth_callback' => $refuse_writes,
167 'description' => __( 'When this content was imported, in UTC.', 'templately' ),
168 ],
169 self::KEY_SOURCE => [
170 'single' => true,
171 'type' => 'string',
172 'show_in_rest' => true,
173 'sanitize_callback' => 'sanitize_key',
174 'auth_callback' => $refuse_writes,
175 'description' => __( 'Whether this content was generated or curated.', 'templately' ),
176 ],
177 self::KEY_SESSION => [
178 'single' => true,
179 'type' => 'string',
180 'show_in_rest' => true,
181 'sanitize_callback' => 'sanitize_text_field',
182 'auth_callback' => $refuse_writes,
183 'description' => __( 'The import run that created this content.', 'templately' ),
184 ],
185 ];
186 }
187
188 /**
189 * Contribute provenance to every item an import creates.
190 *
191 * Attached to the pipeline's per-item meta seam, so this module never names
192 * a runner and no runner names this module.
193 *
194 * The session key is deliberately absent from what we add: the pipeline owns
195 * it and re-applies it after this filter, and writing it here as well would
196 * mean two places could disagree about the same fact.
197 *
198 * @param array $meta Meta the pipeline will apply to each created item.
199 * @param string $entity 'post' or 'term'.
200 * @param string $session_id The import run.
201 * @return array
202 */
203 public function contribute( $meta, $entity = 'post', $session_id = '' ) {
204 if ( ! is_array( $meta ) ) {
205 return $meta;
206 }
207
208 $meta[ self::KEY_PACK ] = $this->pack_for_run( $session_id );
209 $meta[ self::KEY_IMPORTED_AT ] = gmdate( 'c' );
210 $meta[ self::KEY_SOURCE ] = $this->source_for_run( $session_id );
211
212 return $meta;
213 }
214
215 /**
216 * The pack this run is importing.
217 *
218 * @param string $session_id
219 * @return string Empty when unknown — an empty field is honest, a guess is not.
220 */
221 protected function pack_for_run( $session_id ) {
222 /**
223 * Filters the pack id recorded against content this run creates.
224 *
225 * @since 3.8.0
226 *
227 * @param string $pack_id
228 * @param string $session_id
229 */
230 return (string) apply_filters( 'templately_provenance_pack_id', '', $session_id );
231 }
232
233 /**
234 * How this run's content came to be — decided ONCE per run (FR-001b).
235 *
236 * @param string $session_id
237 * @return string One of the SOURCE_* constants.
238 */
239 protected function source_for_run( $session_id ) {
240 /**
241 * Filters whether this run's content is generated or curated.
242 *
243 * Answered by the AI import flow when it is the one running; the default
244 * is the honest answer for every other path.
245 *
246 * @since 3.8.0
247 *
248 * @param string $source
249 * @param string $session_id
250 */
251 $source = apply_filters( 'templately_provenance_source', self::SOURCE_CLOUD, $session_id );
252
253 $allowed = [ self::SOURCE_CLOUD, self::SOURCE_AI, self::SOURCE_CURATED ];
254
255 return in_array( $source, $allowed, true ) ? $source : self::SOURCE_CLOUD;
256 }
257
258 /**
259 * Stamp posts a SINGLE-template import just wrote (spec 060 FR-002a).
260 *
261 * A full-site import stamps through the pipeline's item-meta bag, and the session key is
262 * the pipeline's to write there. A single import has no pipeline and no session, so this
263 * is the one place that key is written outside it — with a `single-` prefix so a reader can
264 * tell the two apart. The pack is recorded EMPTY: a single template is not a pack, and an
265 * empty field is honest where a template id would be a lie.
266 *
267 * Idempotent, and never overwrites: a post that already carries a session id (an FSI post
268 * re-written by a later single insert, or a second stamp) keeps what it has.
269 *
270 * @param int[]|int $post_ids Posts that now hold imported block markup.
271 * @param string $context Which import produced them — recorded nowhere, kept for the hook signature.
272 * @return void
273 */
274 public function stamp_single( $post_ids, $context = 'single' ) {
275 foreach ( (array) $post_ids as $post_id ) {
276 $post_id = (int) $post_id;
277
278 if ( $post_id <= 0 || ! get_post( $post_id ) ) {
279 continue;
280 }
281
282 if ( '' !== $this->read( $post_id, self::KEY_SESSION ) ) {
283 continue;
284 }
285
286 update_post_meta( $post_id, self::KEY_SESSION, 'single-' . uniqid( '', true ) );
287 update_post_meta( $post_id, self::KEY_PACK, '' );
288 update_post_meta( $post_id, self::KEY_IMPORTED_AT, gmdate( 'c' ) );
289 update_post_meta( $post_id, self::KEY_SOURCE, self::SOURCE_CLOUD );
290 }
291 }
292
293 /**
294 * Whether an item carries provenance.
295 *
296 * Keyed on the import TIMESTAMP, not the session id, and the difference is
297 * load-bearing. The session id is written by the import pipeline itself and
298 * has been for far longer than this feature has existed, so every item from
299 * every past import already carries one. Keying on it would mark content
300 * imported before this feature as tagged — content that has no pack, no
301 * time and no source to show — and those items would then appear in the
302 * saved views with three empty fields. That is the opposite of FR-005, and
303 * it is a defect an integration test caught after the unit tests missed it:
304 * the unit fixture had no session id at all, so the two predicates looked
305 * identical there.
306 *
307 * The timestamp is written only by this module, so it means exactly
308 * "provenance was recorded", which is the question being asked.
309 *
310 * @param int $id
311 * @param string $entity 'post' or 'term'.
312 * @return bool
313 */
314 public function has( $id, $entity = 'post' ) {
315 return '' !== (string) $this->read( $id, self::KEY_IMPORTED_AT, $entity );
316 }
317
318 /**
319 * Read one provenance value.
320 *
321 * @param int $id
322 * @param string $key One of the KEY_* constants.
323 * @param string $entity 'post' or 'term'.
324 * @return string Empty string when absent — never a zero, never a 1970 date.
325 */
326 public function read( $id, $key, $entity = 'post' ) {
327 $value = 'term' === $entity
328 ? get_term_meta( (int) $id, $key, true )
329 : get_post_meta( (int) $id, $key, true );
330
331 return is_scalar( $value ) ? (string) $value : '';
332 }
333 }
334