| 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 |
|