| 1 |
<?php |
| 2 |
|
| 3 |
namespace JFB_Modules\Media_Cleanup; |
| 4 |
|
| 5 |
use Jet_Form_Builder\Classes\Tools; |
| 6 |
use JFB_Components\Module\Base_Module_It; |
| 7 |
|
| 8 |
// If this file is called directly, abort. |
| 9 |
if ( ! defined( 'WPINC' ) ) { |
| 10 |
die; |
| 11 |
} |
| 12 |
|
| 13 |
class Module implements Base_Module_It { |
| 14 |
|
| 15 |
/** @var Media_Ownership_Migration_Notice */ |
| 16 |
private $media_ownership_migration_notice; |
| 17 |
|
| 18 |
public function rep_item_id() { |
| 19 |
return 'media-cleanup'; |
| 20 |
} |
| 21 |
|
| 22 |
public function condition(): bool { |
| 23 |
return true; |
| 24 |
} |
| 25 |
|
| 26 |
public function init_hooks() { |
| 27 |
$this->media_ownership_migration_notice = new Media_Ownership_Migration_Notice(); |
| 28 |
$this->media_ownership_migration_notice->init_hooks(); |
| 29 |
} |
| 30 |
|
| 31 |
public static function collect_post_meta_attachment_ids( int $post_id, array $meta_keys ): array { |
| 32 |
$attachment_ids = array(); |
| 33 |
|
| 34 |
foreach ( $meta_keys as $meta_key ) { |
| 35 |
$attachment_ids = array_merge( |
| 36 |
$attachment_ids, |
| 37 |
self::normalize_attachment_ids( |
| 38 |
get_post_meta( $post_id, $meta_key, true ) |
| 39 |
) |
| 40 |
); |
| 41 |
} |
| 42 |
|
| 43 |
return self::normalize_attachment_ids( $attachment_ids ); |
| 44 |
} |
| 45 |
|
| 46 |
public static function maybe_delete_attachments( |
| 47 |
array $old_attachment_ids, |
| 48 |
array $new_attachment_ids = array(), |
| 49 |
int $context_post_id = 0 |
| 50 |
): void { |
| 51 |
$attachment_ids_to_delete = self::diff_attachment_ids( |
| 52 |
$old_attachment_ids, |
| 53 |
$new_attachment_ids |
| 54 |
); |
| 55 |
if ( empty( $attachment_ids_to_delete ) ) { |
| 56 |
return; |
| 57 |
} |
| 58 |
self::delete_attachments( $attachment_ids_to_delete, $context_post_id ); |
| 59 |
} |
| 60 |
|
| 61 |
public static function diff_attachment_ids( array $old_attachment_ids, array $new_attachment_ids ): array { |
| 62 |
$old_attachment_ids = self::normalize_attachment_ids( $old_attachment_ids ); |
| 63 |
$new_attachment_ids = self::normalize_attachment_ids( $new_attachment_ids ); |
| 64 |
return array_values( |
| 65 |
array_diff( $old_attachment_ids, $new_attachment_ids ) |
| 66 |
); |
| 67 |
} |
| 68 |
|
| 69 |
public static function normalize_attachment_ids( $value ): array { |
| 70 |
if ( empty( $value ) ) { |
| 71 |
return array(); |
| 72 |
} |
| 73 |
|
| 74 |
if ( is_numeric( $value ) ) { |
| 75 |
return array( absint( $value ) ); |
| 76 |
} |
| 77 |
|
| 78 |
if ( is_string( $value ) ) { |
| 79 |
$value = array_map( 'trim', explode( ',', $value ) ); |
| 80 |
} |
| 81 |
|
| 82 |
if ( ! is_array( $value ) ) { |
| 83 |
return array(); |
| 84 |
} |
| 85 |
|
| 86 |
$ids = array(); |
| 87 |
|
| 88 |
foreach ( $value as $item ) { |
| 89 |
if ( is_numeric( $item ) ) { |
| 90 |
$ids[] = absint( $item ); |
| 91 |
continue; |
| 92 |
} |
| 93 |
|
| 94 |
if ( is_array( $item ) && isset( $item['id'] ) && is_numeric( $item['id'] ) ) { |
| 95 |
$ids[] = absint( $item['id'] ); |
| 96 |
} |
| 97 |
} |
| 98 |
|
| 99 |
return array_values( |
| 100 |
array_unique( |
| 101 |
array_filter( |
| 102 |
array_map( 'absint', $ids ) |
| 103 |
) |
| 104 |
) |
| 105 |
); |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Deletes plugin-owned attachments (marked with `_jfb_uploaded_by_form` at |
| 110 |
* upload time) that were removed from a post's media field on this request. |
| 111 |
* |
| 112 |
* These attachments may have `post_author = 0` (anonymous form submissions), |
| 113 |
* so WordPress core's `current_user_can( 'delete_post', $attachment_id )` |
| 114 |
* cannot express their real ownership: `map_meta_cap()` only recognizes |
| 115 |
* "author owns it" when `post_author` is truthy, so a `post_author = 0` |
| 116 |
* attachment can never pass that check for anyone, including the |
| 117 |
* legitimate logged-in editor of the post it's attached to. Ownership is |
| 118 |
* therefore asserted via a combination of: |
| 119 |
* - the request's already-verified authorization to edit the owning post |
| 120 |
* ($context_post_id), checked the same way `Post_Id_Property::can_attach()` |
| 121 |
* authorizes the request on the only call path that reaches this method |
| 122 |
* (post_author match OR edit_post OR webhook) - using only the narrower |
| 123 |
* edit_post-or-webhook check here would silently skip cleanup for a |
| 124 |
* legitimate post owner on a post type whose capability mapping doesn't |
| 125 |
* resolve edit_post true for its own author (e.g. a custom |
| 126 |
* map_meta_cap callback), even though insert_post already authorized |
| 127 |
* the very same request to update that post; and |
| 128 |
* - a per-attachment uploader check ($this->is_owned_by_current_actor()): |
| 129 |
* a logged-in uploader's attachments can only be deleted by that same |
| 130 |
* user, so an editor of $context_post_id cannot delete an attachment |
| 131 |
* another user legitimately uploaded for an unrelated post just because |
| 132 |
* both happen to carry the plugin's upload marker. Attachments uploaded |
| 133 |
* before this per-uploader binding existed (no `_jfb_uploaded_by_user` |
| 134 |
* meta at all) fall back to the pre-existing marker + edit_post check |
| 135 |
* only - a deliberate, documented compatibility trade-off (see |
| 136 |
* changelog) since there is no way to retroactively know their real |
| 137 |
* uploader. Attachments explicitly recorded as anonymously uploaded |
| 138 |
* (uploader id `0`) are different: that is an ongoing, current-day case |
| 139 |
* (every anonymous form submission), not a legacy gap, so it is never |
| 140 |
* allowed via the edit_post fallback - only `Tools::is_webhook()` may |
| 141 |
* delete them, since no verifiable per-request identity exists to bind |
| 142 |
* them to. |
| 143 |
* |
| 144 |
* @param array $attachment_ids |
| 145 |
* @param int $context_post_id The post these attachments are being |
| 146 |
* removed from; the caller must have already |
| 147 |
* authorized the current request to edit it. |
| 148 |
*/ |
| 149 |
public static function delete_attachments( array $attachment_ids, int $context_post_id = 0 ): void { |
| 150 |
|
| 151 |
$attachment_ids = self::normalize_attachment_ids( $attachment_ids ); |
| 152 |
|
| 153 |
if ( empty( $attachment_ids ) ) { |
| 154 |
return; |
| 155 |
} |
| 156 |
|
| 157 |
$authorization = self::authorize_context_post( $context_post_id ); |
| 158 |
|
| 159 |
if ( ! $authorization['authorized'] ) { |
| 160 |
return; |
| 161 |
} |
| 162 |
|
| 163 |
foreach ( $attachment_ids as $attachment_id ) { |
| 164 |
if ( 'attachment' !== get_post_type( $attachment_id ) ) { |
| 165 |
continue; |
| 166 |
} |
| 167 |
|
| 168 |
if ( ! get_post_meta( $attachment_id, '_jfb_uploaded_by_form', true ) ) { |
| 169 |
continue; |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* `authorize_context_post()` above already rejects every logged-out request |
| 174 |
* outright (see its own docblock for why the anonymous post_author-match branch |
| 175 |
* `Post_Id_Property::can_attach()` still grants - issues-tracker #20547's |
| 176 |
* still-open Defect 3 - is deliberately NOT accepted as authorization here), so |
| 177 |
* `$authorization['verified_identity']` is currently always `=== true` by the |
| 178 |
* time this line runs. It is still checked explicitly (rather than assuming |
| 179 |
* `authorized` alone is enough) so this call site stays correct on its own terms |
| 180 |
* even if `authorize_context_post()` is ever changed to reintroduce a |
| 181 |
* non-verified authorized branch - `is_owned_by_current_actor()`'s |
| 182 |
* anonymous-uploader exception must never be unlocked by anything less than a |
| 183 |
* verified identity, regardless of what else may authorize the request to edit |
| 184 |
* the post in the future. |
| 185 |
*/ |
| 186 |
if ( ! self::is_owned_by_current_actor( $attachment_id, $authorization['verified_identity'] ? $context_post_id : 0 ) ) { |
| 187 |
continue; |
| 188 |
} |
| 189 |
|
| 190 |
wp_delete_attachment( $attachment_id, true ); |
| 191 |
} |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Authorizes the current request to run CLEANUP against `$context_post_id`. Unlike |
| 196 |
* `Post_Id_Property::can_attach()` (which this otherwise mirrors for post_author match / |
| 197 |
* edit_post / webhook), a logged-out request is deliberately NEVER authorized here, even |
| 198 |
* though `can_attach()` currently authorizes it via `post_author === 0 === get_current_user_id()` |
| 199 |
* (issues-tracker #20547's still-open Defect 3, modules/actions-v2/insert-post/properties/ |
| 200 |
* post-id-property.php - not fixed by this file). That anonymous match is not a |
| 201 |
* verifiable identity: because Defect 3 lets ANY anonymous visitor get authorized on |
| 202 |
* ANY `post_author = 0` post, not just one they created, relying on it here would let an |
| 203 |
* attacker delete a stranger's anonymously-uploaded attachment by first getting |
| 204 |
* "authorized" on the stranger's own carrier post through that same anonymous match - |
| 205 |
* reopening the exact "delete a stranger's file" primitive #20547 exists to close, just |
| 206 |
* laundered through Defect 3 instead of the original PoC's chain. Cleanup for a |
| 207 |
* logged-out request is therefore only ever performed via `Tools::is_webhook()`, which |
| 208 |
* `delete_attachments()` still allows. |
| 209 |
* |
| 210 |
* The trade-off: an anonymous visitor can no longer have their OWN, legitimately |
| 211 |
* removed-from-the-field attachment cleaned up automatically either - it is simply left |
| 212 |
* in the Media Library instead of being deleted. This is the same fail-closed posture |
| 213 |
* already documented for the anonymous-upload case elsewhere in this fix (see |
| 214 |
* `is_owned_by_current_actor()`'s docblock) and in `readme.md`'s "Что осознанно осталось |
| 215 |
* вне скоупа" section: anonymous form submitters never get automatic file cleanup |
| 216 |
* without going through a webhook or an admin, by design. |
| 217 |
* |
| 218 |
* `verified_identity` in the return value is kept (rather than collapsing this to a |
| 219 |
* plain bool) so `delete_attachments()`'s call to `is_owned_by_current_actor()` stays |
| 220 |
* self-documenting about WHY it passes `0` vs `$context_post_id` - it is always |
| 221 |
* `=== authorized` now that the anonymous branch is gone, but keeping the explicit field |
| 222 |
* name at the call site is clearer than an implicit "true always means verified" rule a |
| 223 |
* future change could silently invalidate by reintroducing an unverified branch here. |
| 224 |
* |
| 225 |
* @param int $context_post_id |
| 226 |
* |
| 227 |
* @return array{authorized: bool, verified_identity: bool} |
| 228 |
*/ |
| 229 |
private static function authorize_context_post( int $context_post_id ): array { |
| 230 |
if ( Tools::is_webhook() ) { |
| 231 |
return array( |
| 232 |
'authorized' => true, |
| 233 |
'verified_identity' => true, |
| 234 |
); |
| 235 |
} |
| 236 |
|
| 237 |
$current_user_id = get_current_user_id(); |
| 238 |
|
| 239 |
if ( $current_user_id > 0 ) { |
| 240 |
$post = get_post( $context_post_id ); |
| 241 |
|
| 242 |
if ( $post instanceof \WP_Post && absint( $post->post_author ) === $current_user_id ) { |
| 243 |
return array( |
| 244 |
'authorized' => true, |
| 245 |
'verified_identity' => true, |
| 246 |
); |
| 247 |
} |
| 248 |
|
| 249 |
if ( current_user_can( 'edit_post', $context_post_id ) ) { |
| 250 |
return array( |
| 251 |
'authorized' => true, |
| 252 |
'verified_identity' => true, |
| 253 |
); |
| 254 |
} |
| 255 |
} |
| 256 |
|
| 257 |
return array( |
| 258 |
'authorized' => false, |
| 259 |
'verified_identity' => false, |
| 260 |
); |
| 261 |
} |
| 262 |
|
| 263 |
/** |
| 264 |
* Shared ownership check for the plugin's `_jfb_uploaded_by_user` marker, used by BOTH |
| 265 |
* `delete_attachments()` here (may this attachment be deleted?) and |
| 266 |
* `Media_Field_Parser::is_allowed_submitted_attachment()` (may this attachment ID be |
| 267 |
* accepted into a post's meta?). The two questions apply the exact same trust model - |
| 268 |
* "does the current actor match who the plugin recorded as having uploaded this file?" |
| 269 |
* - so they share this one implementation instead of two independently maintained |
| 270 |
* copies that could silently drift apart. |
| 271 |
* |
| 272 |
* An attachment uploaded by a logged-in user may only be claimed (deleted OR newly |
| 273 |
* attached to a post) by that same user. Attachments carrying `_jfb_uploaded_by_form` |
| 274 |
* but NO `_jfb_uploaded_by_user` meta at all fall through to an unconditional allow |
| 275 |
* below, since there is nothing to bind them to. Both `Uploaded_File::add_attachment()` |
| 276 |
* (live uploads) and `Version_3_6_6::maybe_backfill_attachment()` (the migration) always |
| 277 |
* write BOTH markers together - including an explicit `_jfb_uploaded_by_user = 0` for |
| 278 |
* the anonymous case, precisely so a marked attachment never ends up in this |
| 279 |
* unconditional branch just because its real uploader happens to be "nobody" (see |
| 280 |
* `maybe_backfill_attachment()`'s docblock for why an earlier revision that left this |
| 281 |
* meta unset for `post_author = 0` was itself a security bug: this branch is meant only |
| 282 |
* for attachments that predate the marker system outright, and granting it to a marked |
| 283 |
* anonymous upload let any authenticated user claim/delete it via any post they |
| 284 |
* control). This branch is therefore not expected to be reachable through this fix's |
| 285 |
* own code paths going forward; it remains only as a documented fallback for an |
| 286 |
* attachment some other, unrelated code path marked `_jfb_uploaded_by_form` without |
| 287 |
* also setting an uploader value. |
| 288 |
* |
| 289 |
* An anonymously-uploaded attachment (uploader id `0`) cannot be told apart from a |
| 290 |
* stranger's anonymous upload by identity alone, since `0` is also what any logged-out |
| 291 |
* visitor's `get_current_user_id()` returns - claiming it on that basis would let any |
| 292 |
* anonymous visitor claim any other anonymous visitor's upload. `$owning_post_id` is |
| 293 |
* the one exception a caller can supply: when the caller has ALREADY (a) verified the |
| 294 |
* attachment ID was found in that specific post's own pre-save meta (proving it, not |
| 295 |
* some other post's anonymous upload, is what's being claimed) and (b) authorized the |
| 296 |
* current request to edit that same post via a VERIFIED IDENTITY - `Tools::is_webhook()`, |
| 297 |
* or a logged-in user's own `post_author` match / `edit_post` capability - never the |
| 298 |
* anonymous `post_author === 0 === get_current_user_id()` match alone. That last |
| 299 |
* distinction is load-bearing: `Post_Id_Property::can_attach()` (issues-tracker #20547's |
| 300 |
* still-open Defect 3) currently authorizes ANY anonymous visitor to edit ANY post with |
| 301 |
* `post_author = 0`, not just the post its own submission created, so an anonymous |
| 302 |
* post_author match proves nothing about who "the requester" actually is - passing it |
| 303 |
* through as ownership proof would let an attacker claim a stranger's anonymous upload |
| 304 |
* by first getting `Post_Id_Property::can_attach()` to authorize them on that stranger's |
| 305 |
* carrier post (trivial today, see Defect 3), then deleting the attachment through that |
| 306 |
* post's own cleanup. `delete_attachments()` is the only caller that can supply a |
| 307 |
* verified identity (via `authorize_context_post()` and the diff in |
| 308 |
* `maybe_delete_attachments()`), and only passes `$owning_post_id` when |
| 309 |
* `authorize_context_post()` reports `verified_identity => true`. `Media_Field_Parser:: |
| 310 |
* is_allowed_submitted_attachment()` has no post context at all at the point it runs |
| 311 |
* (see `Uploaded_File::add_attachment()`'s docblock on why no post is authorized yet |
| 312 |
* when a field is parsed), so it always omits this parameter and an anonymous upload |
| 313 |
* remains claimable there only via `Tools::is_webhook()` - including by the very same |
| 314 |
* anonymous submitter resubmitting their own not-yet-authorized form. Concretely: if that |
| 315 |
* form's Media field has "Delete removed attachments" enabled, an anonymous visitor who |
| 316 |
* uploads a file and then resubmits the SAME form (e.g. multi-step form, validation |
| 317 |
* retry, or simply re-saving the post) WITHOUT changing that field will have their own |
| 318 |
* just-uploaded, still-wanted attachment physically deleted - `sanitize_submitted_ |
| 319 |
* attachment_ids()` drops the ID from the "new" snapshot, `Media_Cleanup:: |
| 320 |
* maybe_delete_attachments()` sees it missing from the diff, and `wp_delete_attachment()` |
| 321 |
* runs. This is a known, deliberate limitation, not an oversight - the request has no |
| 322 |
* way to prove "still the same visitor" beyond a session this check does not rely on - |
| 323 |
* but it is a real, user-facing data-loss risk for anonymous-upload + cleanup-enabled |
| 324 |
* forms, not merely a cosmetic "field forgets its value" annoyance. Closing it properly |
| 325 |
* needs a verifiable, request-scoped identity for anonymous uploads (e.g. binding the |
| 326 |
* upload to the plugin's own CSRF/session token infrastructure in modules/security/csrf/, |
| 327 |
* available at field-parse time) rather than `get_current_user_id()`, which cannot |
| 328 |
* distinguish anonymous visitors from each other; tracked as a follow-up, not fixed by |
| 329 |
* this parameter. |
| 330 |
* |
| 331 |
* @param int $attachment_id |
| 332 |
* @param int $owning_post_id Optional. A post ID the caller has already verified this |
| 333 |
* attachment ID came from AND authorized the current request |
| 334 |
* to edit via a verified (non-anonymous-match) identity. Only |
| 335 |
* ever passed by `delete_attachments()`. |
| 336 |
* |
| 337 |
* @return bool |
| 338 |
*/ |
| 339 |
public static function is_owned_by_current_actor( int $attachment_id, int $owning_post_id = 0 ): bool { |
| 340 |
$has_uploader_meta = metadata_exists( 'post', $attachment_id, '_jfb_uploaded_by_user' ); |
| 341 |
|
| 342 |
if ( ! $has_uploader_meta ) { |
| 343 |
return true; |
| 344 |
} |
| 345 |
|
| 346 |
$uploader_id = (int) get_post_meta( $attachment_id, '_jfb_uploaded_by_user', true ); |
| 347 |
|
| 348 |
if ( ! $uploader_id ) { |
| 349 |
return Tools::is_webhook() || $owning_post_id > 0; |
| 350 |
} |
| 351 |
|
| 352 |
if ( get_current_user_id() === $uploader_id || Tools::is_webhook() ) { |
| 353 |
return true; |
| 354 |
} |
| 355 |
|
| 356 |
/** |
| 357 |
* A non-zero `_jfb_uploaded_by_user` is normally ground truth - `Uploaded_File:: |
| 358 |
* add_attachment()` records the actual `get_current_user_id()` at upload time, so |
| 359 |
* requiring an exact match is correct and intentionally has no `$owning_post_id` |
| 360 |
* fallback (unlike the anonymous-uploader branch above): that strictness is exactly |
| 361 |
* what stops one user from claiming another user's real upload. |
| 362 |
* |
| 363 |
* `Version_3_6_6::maybe_backfill_attachment()` is the one exception: it writes this |
| 364 |
* same meta key from `post_author` as a best-effort GUESS for pre-fix attachments, |
| 365 |
* not an observed fact, and marks that with `_jfb_uploaded_by_user_heuristic` (see |
| 366 |
* that method's docblock for why `post_author` can be stale/reassigned and is not |
| 367 |
* reliable enough to lock an attachment to forever). Without this exception, a |
| 368 |
* legacy attachment whose `post_author` no longer matches its post's actual current |
| 369 |
* editor would become permanently unmanageable by anyone but that one guessed user |
| 370 |
* or a webhook - worse than the pre-fix state, where an unmarked attachment was |
| 371 |
* freely manageable by any `edit_post`-authorized editor of its post. For a |
| 372 |
* heuristic value specifically, the same verified-identity `$owning_post_id` |
| 373 |
* exception the anonymous branch above uses also applies here. |
| 374 |
*/ |
| 375 |
if ( $owning_post_id > 0 && get_post_meta( $attachment_id, '_jfb_uploaded_by_user_heuristic', true ) ) { |
| 376 |
return true; |
| 377 |
} |
| 378 |
|
| 379 |
return false; |
| 380 |
} |
| 381 |
|
| 382 |
public function remove_hooks() { |
| 383 |
if ( $this->media_ownership_migration_notice ) { |
| 384 |
$this->media_ownership_migration_notice->remove_hooks(); |
| 385 |
} |
| 386 |
} |
| 387 |
|
| 388 |
public static function get_post_meta_keys_for_cleanup( $modifier ): array { |
| 389 |
$form_id = jet_fb_action_handler()->get_form_id(); |
| 390 |
if ( ! $form_id || empty( $modifier->fields_map ) ) { |
| 391 |
return array(); |
| 392 |
} |
| 393 |
$meta_keys = array(); |
| 394 |
foreach ( $modifier->fields_map as $field_name => $meta_key ) { |
| 395 |
if ( empty( $field_name ) || empty( $meta_key ) ) { |
| 396 |
continue; |
| 397 |
} |
| 398 |
$field = jet_form_builder()->form->get_field_by_name( |
| 399 |
$form_id, |
| 400 |
$field_name |
| 401 |
); |
| 402 |
if ( ! self::is_cleanup_enabled_media_field( $field ) ) { |
| 403 |
continue; |
| 404 |
} |
| 405 |
$meta_keys[] = $meta_key; |
| 406 |
} |
| 407 |
return array_values( |
| 408 |
array_unique( |
| 409 |
array_filter( $meta_keys ) |
| 410 |
) |
| 411 |
); |
| 412 |
} |
| 413 |
|
| 414 |
private static function is_cleanup_enabled_media_field( $field ): bool { |
| 415 |
if ( ! is_array( $field ) || empty( $field ) ) { |
| 416 |
return false; |
| 417 |
} |
| 418 |
if ( 'jet-forms/media-field' !== ( $field['blockName'] ?? '' ) ) { |
| 419 |
return false; |
| 420 |
} |
| 421 |
$attrs = $field['attrs'] ?? array(); |
| 422 |
if ( empty( $attrs['delete_uploaded_attachment'] ) ) { |
| 423 |
return false; |
| 424 |
} |
| 425 |
if ( empty( $attrs['insert_attachment'] ) ) { |
| 426 |
return false; |
| 427 |
} |
| 428 |
return in_array( |
| 429 |
$attrs['value_format'] ?? 'url', |
| 430 |
array( 'id', 'ids', 'both' ), |
| 431 |
true |
| 432 |
); |
| 433 |
} |
| 434 |
} |
| 435 |
|