PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.1
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.1
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 / includes / classes / images / quarantine.php

quarantine.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.13.1, at includes/classes/images/quarantine.php

288 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes\Images;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 /**
9 * Hold "unused" images aside instead of deleting them.
10 *
11 * {@see UnusedScanner} cannot prove an image is unused — it can only report
12 * that it found no evidence of use. Acting on that with wp_delete_attachment()
13 * would make every false positive permanent and unrecoverable, and the failure
14 * is silent: a missing image on one page nobody looks at, discovered months
15 * later with no way back.
16 *
17 * So removal is staged. The attachment's files move into a quarantine folder
18 * and the post is soft-deleted, both reversible for a retention window. Only
19 * after that window passes is anything actually destroyed, and even then only
20 * by an explicit sweep the site owner controls.
21 *
22 * The record of what moved where is kept in an option rather than post meta,
23 * because the post itself is part of what gets removed.
24 */
25 class Quarantine {
26
27 const DIR_NAME = 'ablocks-quarantine';
28 const OPTION = 'ablocks_quarantined_images';
29
30 /**
31 * Days a quarantined image is kept before it can be swept.
32 */
33 const RETENTION_DAYS = 30;
34
35 /**
36 * Move an attachment's files into quarantine and trash the post.
37 *
38 * @param int $attachment_id Attachment ID.
39 * @return array{ok:bool, bytes:int, message:string}
40 */
41 public static function hold( $attachment_id ) {
42 $attachment_id = (int) $attachment_id;
43 $out = [
44 'ok' => false,
45 'bytes' => 0,
46 'message' => '',
47 ];
48
49 if ( 'attachment' !== get_post_type( $attachment_id ) ) {
50 $out['message'] = __( 'Not an attachment.', 'ablocks' );
51 return $out;
52 }
53
54 // Re-check at the moment of removal rather than trusting the scan that
55 // produced the list. Lists get reviewed slowly, and a page published in
56 // between would otherwise lose its image.
57 $evidence = UnusedScanner::find_usage( $attachment_id );
58 if ( ! empty( $evidence ) ) {
59 /* translators: %s: reason the image appears to be in use. */
60 $out['message'] = sprintf( __( 'Now in use (%s) — skipped.', 'ablocks' ), (string) reset( $evidence ) );
61 return $out;
62 }
63
64 $files = Compressor::files_for( $attachment_id );
65 if ( empty( $files ) ) {
66 $out['message'] = __( 'No files found.', 'ablocks' );
67 return $out;
68 }
69
70 $dir = self::dir_for( $attachment_id );
71 if ( ! wp_mkdir_p( $dir ) ) {
72 $out['message'] = __( 'Could not create the quarantine folder.', 'ablocks' );
73 return $out;
74 }
75
76 $moved = [];
77 foreach ( $files as $path ) {
78 if ( ! file_exists( $path ) ) {
79 continue;
80 }
81 $target = $dir . '/' . basename( $path );
82
83 $out['bytes'] += (int) filesize( $path );
84
85 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value checked.
86 if ( @rename( $path, $target ) ) {
87 $moved[] = [
88 'from' => $path,
89 'to' => $target,
90 ];
91 }
92 }
93
94 if ( empty( $moved ) ) {
95 $out['message'] = __( 'Nothing could be moved.', 'ablocks' );
96 return $out;
97 }
98
99 $records = self::records();
100 $records[ $attachment_id ] = [
101 'id' => $attachment_id,
102 'title' => get_the_title( $attachment_id ),
103 'files' => $moved,
104 'bytes' => (int) $out['bytes'],
105 'time' => time(),
106 ];
107 self::save_records( $records );
108
109 // Trashed, not deleted: the row has to survive for a restore to put the
110 // attachment back with the same ID, which is what every reference to it
111 // depends on.
112 wp_trash_post( $attachment_id );
113
114 $out['ok'] = true;
115
116 return $out;
117 }
118
119 /**
120 * Put a quarantined attachment back.
121 *
122 * @param int $attachment_id Attachment ID.
123 * @return bool
124 */
125 public static function restore( $attachment_id ) {
126 $attachment_id = (int) $attachment_id;
127 $records = self::records();
128
129 if ( empty( $records[ $attachment_id ]['files'] ) ) {
130 return false;
131 }
132
133 $restored = 0;
134 foreach ( $records[ $attachment_id ]['files'] as $pair ) {
135 if ( empty( $pair['from'] ) || empty( $pair['to'] ) || ! file_exists( $pair['to'] ) ) {
136 continue;
137 }
138 $dir = dirname( $pair['from'] );
139 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
140 continue;
141 }
142 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value checked.
143 if ( @rename( $pair['to'], $pair['from'] ) ) {
144 $restored++;
145 }
146 }
147
148 if ( ! $restored ) {
149 return false;
150 }
151
152 wp_untrash_post( $attachment_id );
153 // Untrashing restores the previous status, which for an attachment must
154 // be 'inherit'; WordPress can leave it as 'draft' otherwise, and a draft
155 // attachment is invisible to the media library.
156 wp_update_post(
157 [
158 'ID' => $attachment_id,
159 'post_status' => 'inherit',
160 ]
161 );
162
163 unset( $records[ $attachment_id ] );
164 self::save_records( $records );
165
166 self::cleanup_dir( self::dir_for( $attachment_id ) );
167
168 return true;
169 }
170
171 /**
172 * Permanently destroy quarantined items past the retention window.
173 *
174 * @param int|null $days Override the retention window.
175 * @return array{deleted:int, bytes:int}
176 */
177 public static function sweep( $days = null ) {
178 $days = null === $days
179 ? (int) apply_filters( 'ablocks/images/quarantine_retention_days', self::RETENTION_DAYS )
180 : (int) $days;
181
182 $cutoff = time() - ( max( 1, $days ) * DAY_IN_SECONDS );
183 $records = self::records();
184 $out = [
185 'deleted' => 0,
186 'bytes' => 0,
187 ];
188
189 foreach ( $records as $id => $record ) {
190 if ( empty( $record['time'] ) || $record['time'] > $cutoff ) {
191 continue;
192 }
193
194 foreach ( (array) $record['files'] as $pair ) {
195 if ( ! empty( $pair['to'] ) && file_exists( $pair['to'] ) ) {
196 $out['bytes'] += (int) filesize( $pair['to'] );
197 wp_delete_file( $pair['to'] );
198 }
199 }
200
201 self::cleanup_dir( self::dir_for( $id ) );
202 wp_delete_post( (int) $id, true );
203
204 unset( $records[ $id ] );
205 $out['deleted']++;
206 }
207
208 self::save_records( $records );
209
210 return $out;
211 }
212
213 /**
214 * Everything currently held.
215 *
216 * @return array
217 */
218 public static function records() {
219 $records = get_option( self::OPTION, [] );
220 return is_array( $records ) ? $records : [];
221 }
222
223 /**
224 * Summary for display.
225 *
226 * @return array{count:int, bytes:int, oldest:int}
227 */
228 public static function summary() {
229 $records = self::records();
230 $bytes = 0;
231 $oldest = 0;
232
233 foreach ( $records as $record ) {
234 $bytes += isset( $record['bytes'] ) ? (int) $record['bytes'] : 0;
235 $time = isset( $record['time'] ) ? (int) $record['time'] : 0;
236 if ( $time && ( ! $oldest || $time < $oldest ) ) {
237 $oldest = $time;
238 }
239 }
240
241 return [
242 'count' => count( $records ),
243 'bytes' => $bytes,
244 'oldest' => $oldest,
245 ];
246 }
247
248 /**
249 * Persist the record set.
250 *
251 * @param array $records Records.
252 */
253 private static function save_records( $records ) {
254 update_option( self::OPTION, $records, false );
255 }
256
257 /**
258 * Quarantine folder for an attachment.
259 *
260 * @param int $attachment_id Attachment ID.
261 * @return string
262 */
263 private static function dir_for( $attachment_id ) {
264 $upload = wp_upload_dir();
265 return trailingslashit( $upload['basedir'] ) . self::DIR_NAME . '/' . (int) $attachment_id;
266 }
267
268 /**
269 * Remove a quarantine folder once it is empty.
270 *
271 * @param string $dir Directory.
272 */
273 private static function cleanup_dir( $dir ) {
274 if ( ! is_dir( $dir ) ) {
275 return;
276 }
277 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value checked.
278 $entries = @scandir( $dir );
279 if ( false === $entries ) {
280 return;
281 }
282 if ( 2 === count( $entries ) ) {
283 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best effort.
284 @rmdir( $dir );
285 }
286 }
287 }
288