PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.2.9
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.2.9
3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / modules / files.php
ai-engine / classes / modules Last commit date
advisor.php 7 months ago chatbot.php 6 months ago discussions.php 6 months ago files.php 6 months ago forms-manager.php 10 months ago gdpr.php 11 months ago search.php 1 year ago security.php 1 year ago tasks-examples.php 6 months ago tasks.php 7 months ago wand.php 7 months ago
files.php
1124 lines
1 <?php
2
3 class Meow_MWAI_Modules_Files {
4 private $core = null;
5 private $wpdb = null;
6 private $namespace = 'mwai-ui/v1';
7 private $db_check = false;
8 private $table_files = null;
9 private $table_filemeta = null;
10
11 public function __construct( $core ) {
12 global $wpdb;
13 $this->core = $core;
14 $this->wpdb = $wpdb;
15 $this->table_files = $this->wpdb->prefix . 'mwai_files';
16 $this->table_filemeta = $this->wpdb->prefix . 'mwai_filemeta';
17 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
18
19 // TODO: Remove after January 2026 - Legacy cron support
20 // Old cron scheduling removed - now handled by Tasks module
21 // Register task handler for cleanup
22 add_filter( 'mwai_task_cleanup_files', [ $this, 'handle_cleanup_task' ], 10, 2 );
23 }
24
25 public function delete_expired_files( $fileRefs ) {
26
27 // Give a chance to other process to delete the files (for example, in the case of files hosted by Assistants)
28 $fileRefs = apply_filters( 'mwai_files_delete', $fileRefs );
29
30 if ( !is_array( $fileRefs ) ) {
31 $fileRefs = [ $fileRefs ];
32 }
33
34 // Delete provider files before local cleanup
35 foreach ( $fileRefs as $refId ) {
36 $this->delete_provider_file( $refId );
37 }
38
39 foreach ( $fileRefs as $refId ) {
40 $file = null;
41 if ( $this->check_db() ) {
42 $file = $this->wpdb->get_row( $this->wpdb->prepare(
43 "SELECT *
44 FROM $this->table_files
45 WHERE refId = %s",
46 $refId
47 ) );
48 }
49 if ( $file ) {
50 $this->wpdb->delete( $this->table_files, [ 'refId' => $refId ] );
51 $this->wpdb->delete( $this->table_filemeta, [ 'file_id' => $file->id ] );
52 if ( file_exists( $file->path ) ) {
53 unlink( $file->path );
54 }
55 }
56 else {
57 $posts = get_posts( [ 'post_type' => 'attachment', 'meta_key' => '_mwai_file_id', 'meta_value' => $refId ] );
58 if ( $posts ) {
59 foreach ( $posts as $post ) {
60 wp_delete_attachment( $post->ID, true );
61 }
62 }
63 }
64 }
65 }
66
67 /**
68 * Delete file from provider (OpenAI, Anthropic, etc.) if applicable.
69 * This is called before local cleanup to ensure provider files are removed.
70 *
71 * @param string $refId The local file reference ID
72 */
73 private function delete_provider_file( $refId ) {
74 $metadata = $this->get_metadata( $refId );
75 $providerFileId = $metadata['file_id'] ?? null;
76 $provider = $metadata['provider'] ?? null;
77
78 if ( !$providerFileId || !$provider ) {
79 return; // No provider file to delete
80 }
81
82 $fileInfo = $this->get_info( $refId );
83 $envId = $fileInfo['envId'] ?? null;
84
85 if ( !$envId ) {
86 Meow_MWAI_Logging::warn( "Cannot delete provider file {$providerFileId}: no envId stored" );
87 return;
88 }
89
90 try {
91 if ( $provider === 'openai' ) {
92 $engine = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
93 $engine->delete_file( $providerFileId );
94 Meow_MWAI_Logging::log( "Deleted OpenAI file: {$providerFileId}" );
95 }
96 elseif ( $provider === 'anthropic' ) {
97 $engine = Meow_MWAI_Engines_Factory::get( $this->core, $envId );
98 if ( method_exists( $engine, 'delete_file' ) ) {
99 $engine->delete_file( $providerFileId );
100 Meow_MWAI_Logging::log( "Deleted Anthropic file: {$providerFileId}" );
101 }
102 }
103 // Google: Add when they have a Files API that needs cleanup
104 }
105 catch ( Exception $e ) {
106 // Log but don't fail - local cleanup should still proceed
107 Meow_MWAI_Logging::error( "Failed to delete {$provider} file {$providerFileId}: " . $e->getMessage() );
108 }
109 }
110
111 public function get_path( $refId ) {
112 $file = null;
113 if ( $this->check_db() ) {
114 $file = $this->wpdb->get_row( $this->wpdb->prepare(
115 "SELECT *
116 FROM $this->table_files
117 WHERE refId = %s",
118 $refId
119 ) );
120 }
121 if ( $file ) {
122 return $file->path;
123 }
124 else {
125 $posts = get_posts( [ 'post_type' => 'attachment', 'meta_key' => '_mwai_file_id', 'meta_value' => $refId ] );
126 if ( $posts ) {
127 foreach ( $posts as $post ) {
128 return get_attached_file( $post->ID );
129 }
130 }
131 }
132 return null;
133 }
134
135 public function get_base64_data( $refId ) {
136 $path = $this->get_path( $refId );
137 if ( $path ) {
138 $content = file_get_contents( $path );
139 $data = base64_encode( $content );
140 return $data;
141 }
142 return null;
143 }
144
145 public function is_image( $refId ) {
146 $info = $this->get_info( $refId );
147 return $info['type'] === 'image';
148 }
149
150 public function get_mime_type( $refId ) {
151 $path = $this->get_path( $refId );
152 if ( $path ) {
153 return Meow_MWAI_Core::get_mime_type( $path );
154 }
155 $url = $this->get_url( $refId );
156 if ( $url ) {
157 return Meow_MWAI_Core::get_mime_type( $url );
158 }
159 return null;
160 }
161
162 public function get_data( $refId ) {
163 $path = $this->get_path( $refId );
164 if ( $path ) {
165 $content = file_get_contents( $path );
166 return $content;
167 }
168 return null;
169 }
170
171 public function get_info( $refId ) {
172 $info = null;
173 if ( $this->check_db() ) {
174 $info = $this->wpdb->get_row( $this->wpdb->prepare(
175 "SELECT *
176 FROM $this->table_files
177 WHERE refId = %s",
178 $refId
179 ), ARRAY_A );
180 }
181 if ( !$info ) {
182 $posts = get_posts( [ 'post_type' => 'attachment', 'meta_key' => '_mwai_file_id', 'meta_value' => $refId ] );
183 if ( $posts ) {
184 $post = $posts[0];
185 $info = [
186 'refId' => $refId,
187 'url' => wp_get_attachment_url( $post->ID ),
188 'path' => get_attached_file( $post->ID )
189 ];
190 }
191 }
192 if ( $info ) {
193 $info['metadata'] = $this->get_metadata( $refId );
194 }
195 return $info;
196 }
197
198 public function get_url( $refId ) {
199 $file = null;
200 if ( $this->check_db() ) {
201 $file = $this->wpdb->get_row( $this->wpdb->prepare(
202 "SELECT *
203 FROM $this->table_files
204 WHERE refId = %s",
205 $refId
206 ) );
207 }
208 if ( $file ) {
209 return $file->url;
210 }
211 else {
212 $posts = get_posts( [ 'post_type' => 'attachment', 'meta_key' => '_mwai_file_id', 'meta_value' => $refId ] );
213 if ( $posts ) {
214 foreach ( $posts as $post ) {
215 return wp_get_attachment_url( $post->ID );
216 }
217 }
218 }
219 return null;
220 }
221
222 /**
223 * Handle a base-64 PNG returned by gpt-image-1: save as a temp file,
224 * register it in the Files DB, and give back a public URL.
225 *
226 * @param string $b64_json Raw base-64 image payload from OpenAI.
227 * @param string $purpose Optional purpose flag. Default 'generated'.
228 * @param int $ttl Time-to-live in seconds. Default 1 hour.
229 * @param string $target Target location: 'uploads' or 'library'. Default 'uploads'.
230 * @param array $metadata Additional metadata to store with the file.
231 *
232 * @return string|WP_Error Public URL or WP_Error on failure.
233 */
234 public function save_temp_image_from_b64(
235 string $b64_json,
236 string $purpose = 'generated',
237 int $ttl = HOUR_IN_SECONDS,
238 string $target = 'uploads',
239 array $metadata = []
240 ) {
241 // 1) Decode → binary.
242 $binary = base64_decode( $b64_json );
243 if ( !$binary ) {
244 return new WP_Error( 'mwai_bad_b64', 'Invalid base-64 payload.' );
245 }
246
247 // 2) Make a transient file in the server tmp dir.
248 $tmp_path = wp_tempnam( 'mwai-image' ); // Creates an empty file.
249 $filename = 'mwai-generated-' . time() . '-' . wp_generate_password( 8, false ) . '.png';
250 file_put_contents( $tmp_path, $binary );
251
252 // 3) Reuse the normal upload flow (target based on user preference, expiry = $ttl).
253 try {
254 // Extract envId from metadata if available
255 $envId = isset( $metadata['query_envId'] ) ? $metadata['query_envId'] : null;
256
257 $refId = $this->upload_file(
258 $tmp_path, // path on disk
259 $filename, // desired filename
260 $purpose, // purpose
261 $metadata, // metadata (now includes query info)
262 $envId, // envId from query
263 $target, // target (uploads or library based on user settings)
264 $ttl // expiry in seconds
265 );
266 // 4) Clean up temp file if it was uploaded to library (but not if uploads)
267 // For uploads target, the temp file IS the final file
268 if ( $target === 'library' && file_exists( $tmp_path ) ) {
269 @unlink( $tmp_path );
270 }
271
272 // 5) Turn refId → URL.
273 return $this->get_url( $refId );
274 }
275 catch ( Exception $e ) {
276 // Clean up temp file on error
277 if ( file_exists( $tmp_path ) ) {
278 @unlink( $tmp_path );
279 }
280 return new WP_Error( 'mwai_upload_failed', $e->getMessage() );
281 }
282 }
283
284 #region REST endpoints
285
286 public function rest_api_init() {
287 register_rest_route( $this->namespace, '/files/upload', [
288 'methods' => 'POST',
289 'callback' => [ $this, 'rest_upload' ],
290 'permission_callback' => [ $this->core, 'check_rest_nonce' ]
291 ] );
292 register_rest_route( $this->namespace, '/files/list', [
293 'methods' => 'POST',
294 'callback' => [ $this, 'rest_list' ],
295 'permission_callback' => [ $this->core, 'check_rest_nonce' ]
296 ] );
297 register_rest_route( $this->namespace, '/files/delete', [
298 'methods' => 'POST',
299 'callback' => [ $this, 'rest_delete' ],
300 'permission_callback' => [ $this->core, 'check_rest_nonce' ]
301 ] );
302 }
303
304 /*
305 * Record a new file in the Files database.
306 * This doesn't handle the upload or anything.
307 */
308 public function commit_file( $fileInfo ) {
309 if ( !$this->check_db() ) {
310 throw new Exception( 'Could not create database table.' );
311 }
312 $now = date( 'Y-m-d H:i:s' );
313 if ( empty( $fileInfo['refId'] ) ) {
314 if ( !empty( $fileInfo['url'] ) ) {
315 $fileInfo['refId'] = $this->generate_refId( $fileInfo['url'] );
316 }
317 else {
318 throw new Exception( 'File ID (or URL) is required.' );
319 }
320 }
321
322 // Check if file with this refId already exists
323 $existingFileId = $this->get_id_from_refId( $fileInfo['refId'] );
324 if ( $existingFileId ) {
325 // File already exists, return its ID
326 return $existingFileId;
327 }
328
329 if ( empty( $fileInfo['type'] ) ) {
330 $fileInfo['type'] = Meow_MWAI_Core::is_image( $fileInfo['url'] ) ? 'image' : 'file';
331 }
332 $success = $this->wpdb->insert( $this->table_files, [
333 'refId' => $fileInfo['refId'],
334 'envId' => empty( $fileInfo['envId'] ) ? null : $fileInfo['envId'],
335 'userId' => empty( $fileInfo['userId'] ) ? $this->get_effective_user_id() : $fileInfo['userId'],
336 'purpose' => empty( $fileInfo['purpose'] ) ? null : $fileInfo['purpose'],
337 'type' => empty( $fileInfo['type'] ) ? null : $fileInfo['type'],
338 'status' => empty( $fileInfo['status'] ) ? null : $fileInfo['status'],
339 'created' => empty( $fileInfo['created'] ) ? $now : $fileInfo['created'],
340 'updated' => empty( $fileInfo['updated'] ) ? $now : $fileInfo['updated'],
341 'expires' => empty( $fileInfo['expires'] ) ? null : $fileInfo['expires'],
342 'path' => empty( $fileInfo['path'] ) ? null : $fileInfo['path'],
343 'url' => empty( $fileInfo['url'] ) ? null : $fileInfo['url']
344 ] );
345
346 // Check for error
347 if ( !$success ) {
348 // Check if it's a duplicate key error (race condition)
349 if ( strpos( $this->wpdb->last_error, 'Duplicate entry' ) !== false &&
350 strpos( $this->wpdb->last_error, 'unique_file_id' ) !== false ) {
351 // Race condition: file was inserted by another request between our check and insert
352 // Try to get the existing file ID one more time
353 $existingFileId = $this->get_id_from_refId( $fileInfo['refId'] );
354 if ( $existingFileId ) {
355 return $existingFileId;
356 }
357 }
358 throw new Exception( 'Error while adding file in the DB (' . $this->wpdb->last_error . ')' );
359 }
360 return $this->wpdb->insert_id;
361 }
362
363 // Generate a refId from a URL or random, and make sure it's unique
364 public function generate_refId( $attempts = 0 ) {
365 // Use microtime for higher precision to avoid collisions when uploading multiple files simultaneously
366 $refId = md5( microtime( true ) . '-' . wp_rand() . '-' . $attempts );
367 $file = $this->wpdb->get_row( $this->wpdb->prepare(
368 "SELECT *
369 FROM $this->table_files
370 WHERE refId = %s",
371 $refId
372 ) );
373 if ( $file ) {
374 return $this->generate_refId( $attempts + 1 );
375 }
376 return $refId;
377 }
378
379 public function upload_file(
380 $path,
381 $filename = null,
382 $purpose = null,
383 $metadata = null,
384 $envId = null,
385 $target = null,
386 $expiry = null
387 ) {
388 require_once( ABSPATH . 'wp-admin/includes/image.php' );
389 require_once( ABSPATH . 'wp-admin/includes/file.php' );
390 require_once( ABSPATH . 'wp-admin/includes/media.php' );
391
392 $target = empty( $target ) ? $this->core->get_option( 'image_local_upload' ) : $target;
393 $expiry = empty( $expiry ) ? $this->core->get_option( 'image_expires' ) : $expiry;
394
395 $expires = ( $expiry === 'never' || empty( $expiry ) ) ? null : date( 'Y-m-d H:i:s', time() + intval( $expiry ) );
396 $refId = $this->generate_refId();
397 $url = null;
398 if ( empty( $filename ) ) {
399 $parsed_url = parse_url( $path, PHP_URL_PATH );
400 $filename = basename( $parsed_url );
401 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
402 }
403 else {
404 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
405 }
406
407 // Validate file type using WordPress built-in function
408 $validate = wp_check_filetype( $filename );
409 if ( $validate['type'] == false ) {
410 throw new Exception( 'File type is not allowed.' );
411 }
412 $newFilename = $refId . '.' . $extension;
413 $unique_filename = wp_unique_filename( wp_upload_dir()['path'], $newFilename );
414 $destination = wp_upload_dir()['path'] . '/' . $unique_filename;
415
416 if ( $target === 'uploads' ) {
417 if ( !$this->check_db() ) {
418 throw new Exception( 'Could not create database table.' );
419 }
420 if ( !copy( $path, $destination ) ) {
421 throw new Exception( 'Could not move the file.' );
422 }
423 $url = wp_upload_dir()['url'] . '/' . $unique_filename;
424
425 $now = date( 'Y-m-d H:i:s' );
426 $fileId = $this->commit_file( [
427 'refId' => $refId,
428 'envId' => $envId,
429 'purpose' => $purpose,
430 'type' => null,
431 'status' => 'uploaded',
432 'created' => $now,
433 'updated' => $now,
434 'expires' => $expires,
435 'path' => $destination,
436 'url' => $url
437 ] );
438 if ( $metadata && is_array( $metadata ) ) {
439 foreach ( $metadata as $metaKey => $metaValue ) {
440 $this->add_metadata( $fileId, $metaKey, $metaValue );
441 }
442 }
443
444 }
445 else if ( $target === 'library' ) {
446
447 if ( filter_var( $path, FILTER_VALIDATE_URL ) ) {
448 $tmp = download_url( $path );
449 if ( is_wp_error( $tmp ) ) {
450 throw new Exception( $tmp->get_error_message() );
451 }
452 $file_array = [ 'name' => $unique_filename, 'tmp_name' => $tmp ];
453 }
454 else {
455 $file_array = [ 'name' => $unique_filename, 'tmp_name' => $path ];
456 }
457
458 $id = media_handle_sideload( $file_array, 0 );
459 if ( is_wp_error( $id ) ) {
460 throw new Exception( $id->get_error_message() );
461 }
462
463 $url = wp_get_attachment_url( $id );
464 update_post_meta( $id, '_mwai_file_id', $refId );
465 update_post_meta( $id, '_mwai_file_expires', $expires );
466
467 // Store additional metadata
468 if ( $metadata && is_array( $metadata ) ) {
469 foreach ( $metadata as $metaKey => $metaValue ) {
470 update_post_meta( $id, '_mwai_' . $metaKey, $metaValue );
471 }
472 }
473
474 // Store purpose and envId as post meta
475 if ( $purpose ) {
476 update_post_meta( $id, '_mwai_purpose', $purpose );
477 }
478 if ( $envId ) {
479 update_post_meta( $id, '_mwai_envId', $envId );
480 }
481 }
482
483 return $refId;
484 }
485
486 public function add_metadata( $fileId, $metaKey, $metaValue ) {
487 $data = [
488 'file_id' => $fileId,
489 'meta_key' => $metaKey,
490 'meta_value' => $metaValue
491 ];
492 $res = $this->wpdb->insert( $this->table_filemeta, $data );
493 if ( $res === false ) {
494 Meow_MWAI_Logging::warn( 'Error while writing files metadata (' . $this->wpdb->last_error . ')' );
495 return false;
496 }
497 return $this->wpdb->insert_id;
498 }
499
500 public function update_refId( $fileId, $refId ) {
501 if ( $this->check_db() ) {
502 $this->wpdb->update( $this->table_files, [ 'refId' => $refId ], [ 'id' => $fileId ] );
503 }
504 }
505
506 public function update_purpose( $fileId, $purpose ) {
507 if ( $this->check_db() ) {
508 $this->wpdb->update( $this->table_files, [ 'purpose' => $purpose ], [ 'id' => $fileId ] );
509 }
510 }
511
512 public function update_envId( $fileId, $envId ) {
513 if ( $this->check_db() ) {
514 $this->wpdb->update( $this->table_files, [ 'envId' => $envId ], [ 'id' => $fileId ] );
515 }
516 }
517
518 public function get_metadata( $refId, $fileId = null ) {
519 if ( !$fileId ) {
520 $fileId = $this->get_id_from_refId( $refId );
521 }
522 if ( $fileId ) {
523 $sql = $this->wpdb->prepare( "SELECT * FROM $this->table_filemeta WHERE file_id = %d", $fileId );
524 $metadata = $this->wpdb->get_results( $sql, ARRAY_A );
525 $meta = [];
526 foreach ( $metadata as $metaItem ) {
527 $meta[$metaItem['meta_key']] = $metaItem['meta_value'];
528 }
529 return $meta;
530 }
531 return null;
532 }
533
534 public function search( $userId = null, $purpose = null, $metadata = [], $envId = null ) {
535 list( $sql, $params ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, true );
536 $finalQuery = $this->wpdb->prepare( $sql, $params );
537 $files = $this->wpdb->get_results( $finalQuery, ARRAY_A );
538 foreach ( $files as &$file ) {
539 $file['metadata'] = $this->get_metadata( $file['refId'] );
540 }
541 return $files;
542 }
543
544 public function list(
545 $userId = null,
546 $purpose = null,
547 $metadata = [],
548 $envId = null,
549 $limit = 10,
550 $offset = 0
551 ) {
552 list( $countSql, $countParams ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, false );
553 $total = $this->wpdb->get_var( $this->wpdb->prepare( $countSql, $countParams ) );
554
555 list( $fileSql, $fileParams ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, true );
556 if ( $limit ) {
557 $fileSql .= ' LIMIT %d';
558 $fileParams[] = $limit;
559 }
560 if ( $offset ) {
561 $fileSql .= ' OFFSET %d';
562 $fileParams[] = $offset;
563 }
564 $files = $this->wpdb->get_results( $this->wpdb->prepare( $fileSql, $fileParams ), ARRAY_A );
565 foreach ( $files as &$file ) {
566 $file['metadata'] = $this->get_metadata( $file['refId'] );
567 }
568 return [ 'files' => $files, 'total' => $total ];
569 }
570
571 private function _buildQuery( $userId, $purpose, $metadata, $envId, $selectStar ) {
572 $sql = $selectStar ? "SELECT * FROM $this->table_files WHERE 1=1" : "SELECT COUNT(*) FROM $this->table_files WHERE 1=1";
573 $params = [];
574
575 // Based on the old "search" function
576 $actualUserId = $this->core->get_user_id();
577 $canAdmin = $this->core->can_access_settings();
578 if ( $userId !== $actualUserId ) {
579 if ( !$canAdmin ) {
580 throw new Exception( 'You are not allowed to access files from another user.' );
581 }
582 }
583 if ( $userId ) {
584 $sql .= ' AND userId = %d';
585 $params[] = $userId;
586 }
587 if ( $purpose ) {
588 if ( is_array( $purpose ) ) {
589 $sql .= ' AND (';
590 foreach ( $purpose as $p ) {
591 $sql .= ' purpose = %s OR';
592 $params[] = $p;
593 }
594 $sql = rtrim( $sql, 'OR' );
595 $sql .= ')';
596 }
597 else {
598 $sql .= ' AND purpose = %s';
599 $params[] = $purpose;
600 }
601 }
602 if ( $metadata ) {
603 foreach ( $metadata as $metaKey => $metaValue ) {
604 $sql .= " AND EXISTS ( SELECT * FROM $this->table_filemeta
605 WHERE file_id = $this->table_files.id AND meta_key = %s AND meta_value = %s )";
606 $params[] = $metaKey;
607 $params[] = $metaValue;
608 }
609 }
610 if ( $envId ) {
611 $sql .= ' AND envId = %s';
612 $params[] = $envId;
613 }
614 $sql .= ' ORDER BY updated DESC';
615 return [ $sql, $params ];
616 }
617
618 // public function search( $userId = null, $purpose = null, $metadata = [], $limit = 10, $offset = 0 ) {
619 // $sql = "SELECT * FROM $this->table_files WHERE 1=1";
620 // $actualUserId = $this->core->get_user_id();
621 // $canAdmin = $this->core->can_access_settings();
622 // if ( $userId !== $actualUserId ) {
623 // if ( !$canAdmin ) {
624 // throw new Exception( 'You are not allowed to access files from another user.' );
625 // }
626 // }
627 // if ( $userId ) {
628 // $sql .= $this->wpdb->prepare( " AND userId = %d", $userId );
629 // }
630 // if ( $purpose ) {
631 // if ( is_array( $purpose ) ) {
632 // $sql .= " AND (";
633 // foreach ( $purpose as $p ) {
634 // $sql .= $this->wpdb->prepare( " purpose = %s OR", $p );
635 // }
636 // $sql = rtrim( $sql, 'OR' );
637 // $sql .= ")";
638 // }
639 // else {
640 // $sql .= $this->wpdb->prepare( " AND purpose = %s", $purpose );
641 // }
642 // }
643 // if ( $metadata ) {
644 // foreach ( $metadata as $metaKey => $metaValue ) {
645 // $sql .= $this->wpdb->prepare( " AND EXISTS ( SELECT * FROM $this->table_filemeta
646 // WHERE file_id = $this->table_files.id AND meta_key = %s AND meta_value = %s )",
647 // $metaKey, $metaValue
648 // );
649 // }
650 // }
651 // $sql .= " ORDER BY updated DESC";
652 // if ( $limit ) {
653 // $sql .= $this->wpdb->prepare( " LIMIT %d", $limit );
654 // }
655 // if ( $offset ) {
656 // $sql .= $this->wpdb->prepare( " OFFSET %d", $offset );
657 // }
658 // $files = $this->wpdb->get_results( $sql, ARRAY_A );
659
660 // // Add metadata
661 // foreach ( $files as &$file ) {
662 // $file['metadata'] = $this->get_metadata( $file['refId'] );
663 // }
664
665 // return $files;
666 // }
667
668 public function get_id_from_refId( $refId ) {
669 $file = null;
670 if ( $this->check_db() ) {
671 $file = $this->wpdb->get_row( $this->wpdb->prepare(
672 "SELECT *
673 FROM $this->table_files
674 WHERE refId = %s",
675 $refId
676 ) );
677 }
678 if ( $file ) {
679 return $file->id;
680 }
681 return null;
682 }
683
684 public function add_metadata_from_refId( $refId, $metaKey, $metaValue ) {
685 $fileId = $this->get_id_from_refId( $refId );
686 if ( $fileId ) {
687 return $this->add_metadata( $fileId, $metaKey, $metaValue );
688 }
689 return false;
690 }
691
692 public function rest_list( $request ) {
693 $params = $request->get_json_params();
694 $userId = empty( $params['userId'] ) ? null : $params['userId'];
695 $envId = empty( $params['envId'] ) ? null : $params['envId'];
696 $purpose = empty( $params['purpose'] ) ? null : $params['purpose'];
697 $metadata = empty( $params['metadata'] ) ? null : json_decode( $params['metadata'], true );
698 $limit = empty( $params['limit'] ) ? 10 : intval( $params['limit'] );
699 $offset = empty( $params['page'] ) ? 0 : ( intval( $params['page'] ) - 1 ) * $limit;
700
701 // Security fix: For unauthenticated users or users without explicit userId,
702 // restrict to their own files based on session
703 $currentUserId = $this->core->get_user_id();
704 if ( !$currentUserId || $currentUserId === 0 ) {
705 // For unauthenticated users, get session-based user ID
706 $sessionUserId = $this->core->get_session_user_id();
707 if ( !$sessionUserId ) {
708 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
709 }
710 $userId = $sessionUserId;
711 }
712 else if ( empty( $userId ) ) {
713 // For authenticated users without specified userId, use their own ID
714 $userId = $currentUserId;
715 }
716 else if ( $userId !== $currentUserId && !$this->core->can_access_settings() ) {
717 // Non-admin users can only access their own files
718 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access to other user files' ], 403 );
719 }
720
721 $files = $this->list( $userId, $purpose, $metadata, $envId, $limit, $offset );
722 return new WP_REST_Response( [ 'success' => true, 'data' => $files ], 200 );
723 }
724
725 public function rest_delete( $request ) {
726 $params = $request->get_json_params();
727 $fileRefs = empty( $params['files'] ) ? [] : $params['files'];
728
729 // Security fix: Verify user can delete these files
730 $currentUserId = $this->core->get_user_id();
731 $sessionUserId = null;
732
733 if ( !$currentUserId || $currentUserId === 0 ) {
734 // For unauthenticated users, get session-based user ID
735 $sessionUserId = $this->core->get_session_user_id();
736 if ( !$sessionUserId ) {
737 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
738 }
739 }
740
741 // Convert refIds to numeric IDs
742 $fileIds = [];
743 foreach ( $fileRefs as $ref ) {
744 $id = $this->get_id_from_refId( $ref );
745 if ( $id ) {
746 $fileIds[] = $id;
747 }
748 }
749
750 if ( empty( $fileIds ) ) {
751 return new WP_REST_Response( [ 'success' => false, 'message' => 'No valid files to delete' ], 400 );
752 }
753
754 // Verify ownership of files before deletion
755 $authorizedFileIds = $this->filter_authorized_files( $fileIds, $currentUserId ?: $sessionUserId );
756
757 if ( empty( $authorizedFileIds ) ) {
758 return new WP_REST_Response( [ 'success' => false, 'message' => 'No authorized files to delete' ], 403 );
759 }
760
761 $this->delete_files( $authorizedFileIds );
762 return new WP_REST_Response( [ 'success' => true, 'deleted' => count( $authorizedFileIds ) ], 200 );
763 }
764
765 public function delete_files( $fileIds ) {
766 $query = "SELECT id, refId, path FROM $this->table_files WHERE id IN (";
767 $params = [];
768 foreach ( $fileIds as $fileId ) {
769 $query .= '%s,';
770 $params[] = $fileId;
771 }
772 $query = rtrim( $query, ',' );
773 $query .= ')';
774 $files = $this->wpdb->get_results( $this->wpdb->prepare( $query, $params ), ARRAY_A );
775 $refIds = apply_filters( 'mwai_files_delete', array_column( $files, 'refId' ) );
776 foreach ( $files as $file ) {
777 if ( in_array( $file['refId'], $refIds ) ) {
778 // Delete from provider first
779 $this->delete_provider_file( $file['refId'] );
780 // Delete local file and database records
781 $this->wpdb->delete( $this->table_files, [ 'refId' => $file['refId'] ] );
782 $this->wpdb->delete( $this->table_filemeta, [ 'file_id' => $file['id'] ] );
783 if ( file_exists( $file['path'] ) ) {
784 unlink( $file['path'] );
785 }
786 }
787 }
788 }
789
790 /**
791 * Get effective user ID for file ownership
792 * Returns actual user ID for logged-in users, or session-based ID for guests
793 *
794 * @return int|string User ID or session-based ID
795 */
796 private function get_effective_user_id() {
797 $userId = $this->core->get_user_id();
798 if ( !$userId || $userId === 0 ) {
799 // For guest users, use session-based ID
800 return $this->core->get_session_user_id();
801 }
802 return $userId;
803 }
804
805 /**
806 * Filter file IDs to only include those the user is authorized to access
807 *
808 * @param array $fileIds Array of file IDs to filter
809 * @param int|string $userId User ID (can be session-based string for guests)
810 * @return array Array of authorized file IDs
811 */
812 private function filter_authorized_files( $fileIds, $userId ) {
813 if ( empty( $fileIds ) || empty( $userId ) ) {
814 return [];
815 }
816
817 // Admins can access all files
818 if ( $this->core->can_access_settings() ) {
819 return $fileIds;
820 }
821
822 // Build query to check file ownership
823 $placeholders = array_fill( 0, count( $fileIds ), '%s' );
824 $query = $this->wpdb->prepare(
825 "SELECT id FROM $this->table_files
826 WHERE id IN (" . implode( ',', $placeholders ) . ')
827 AND userId = %s',
828 array_merge( $fileIds, [ $userId ] )
829 );
830
831 $authorizedIds = $this->wpdb->get_col( $query );
832 return array_map( 'intval', $authorizedIds );
833 }
834
835 public function rest_upload() {
836 if ( empty( $_FILES['file'] ) ) {
837 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided.' ], 400 );
838 }
839 $file = $_FILES['file'];
840 $purpose = empty( $_POST['purpose'] ) ? null : $_POST['purpose'];
841 $metadata = empty( $_POST['metadata'] ) ? null : json_decode( $_POST['metadata'], true );
842 $envId = empty( $_POST['envId'] ) ? null : $_POST['envId'];
843 if ( !$purpose ) {
844 return new WP_REST_Response( [ 'success' => false, 'message' => 'Purpose is required.' ], 400 );
845 }
846 // Validate file type - allow audio files for transcription purpose
847 $fileTypeCheck = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
848
849 // If WordPress doesn't recognize the type, check if it's an audio file for transcription
850 if ( !$fileTypeCheck['type'] ) {
851 // Get file extension
852 $ext = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );
853
854 // Whisper supported formats: flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm
855 $audioExtensions = [ 'flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm' ];
856
857 if ( in_array( $ext, $audioExtensions ) ) {
858 // This is a valid audio file for transcription - override the type check
859 $fileTypeCheck['type'] = 'audio/' . $ext;
860 $fileTypeCheck['ext'] = $ext;
861 }
862 else {
863 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid file type.' ], 400 );
864 }
865 }
866
867 try {
868 $refId = $this->upload_file( $file['tmp_name'], $file['name'], $purpose, $metadata, $envId );
869 $url = $this->get_url( $refId );
870 return new WP_REST_Response( [
871 'success' => true,
872 'data' => [ 'id' => $refId, 'url' => $url ]
873 ], 200 );
874 }
875 catch ( Exception $e ) {
876 return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
877 }
878 }
879
880 #endregion
881
882 #region Database functions
883
884 public function create_db() {
885 $charset_collate = $this->wpdb->get_charset_collate();
886 $sql = "CREATE TABLE $this->table_files (
887 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
888 refId VARCHAR(64) NOT NULL,
889 envId VARCHAR(128) NULL,
890 userId VARCHAR(64) NULL,
891 type VARCHAR(32) NULL,
892 status VARCHAR(32) NULL,
893 purpose VARCHAR(32) NULL,
894 created DATETIME NOT NULL,
895 updated DATETIME NOT NULL,
896 expires DATETIME NULL,
897 path TEXT NULL,
898 url TEXT NULL,
899 PRIMARY KEY (id),
900 UNIQUE KEY unique_file_id (refId)
901 ) $charset_collate;";
902
903 $sqlFileMeta = "CREATE TABLE $this->table_filemeta (
904 meta_id BIGINT(20) NOT NULL AUTO_INCREMENT,
905 file_id BIGINT(20) NOT NULL,
906 meta_key varchar(255) NULL,
907 meta_value longtext NULL,
908 PRIMARY KEY (meta_id)
909 ) $charset_collate;";
910
911 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
912 dbDelta( $sql );
913 dbDelta( $sqlFileMeta );
914 }
915
916 public function check_db() {
917 if ( $this->db_check ) {
918 return true;
919 }
920
921 // Check if table_files exists
922 $sql = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_files );
923 $table_files_exists = strtolower( $this->wpdb->get_var( $sql ) ) === strtolower( $this->table_files );
924
925 // Check if table_filemeta exists
926 $sqlMeta = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_filemeta );
927 $table_filemeta_exists = strtolower( $this->wpdb->get_var( $sqlMeta ) ) === strtolower( $this->table_filemeta );
928
929 // If either table does not exist, create them
930 if ( !$table_files_exists || !$table_filemeta_exists ) {
931 $this->create_db();
932 }
933
934 // Update db_check for both tables
935 $this->db_check = $table_files_exists && $table_filemeta_exists;
936
937 // Check if userId column needs to be updated to VARCHAR for session support
938 // LATER: REMOVE THIS AFTER JANUARY 2026
939 if ( $this->db_check ) {
940 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_files WHERE Field = 'userId'" );
941 if ( $column_info && strpos( $column_info->Type, 'BIGINT' ) !== false ) {
942 // Update userId column from BIGINT to VARCHAR to support session-based IDs
943 $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN userId VARCHAR(64) NULL" );
944 }
945 }
946
947 // LATER: REMOVE THIS AFTER MARCH 2024
948 // $this->db_check = $this->db_check && $this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'userId'" );
949 // if ( !$this->db_check ) {
950 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN userId BIGINT(20) UNSIGNED NULL" );
951 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN purpose VARCHAR(32) NULL" );
952 // $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN path TEXT NULL" );
953 // $this->wpdb->query( "ALTER TABLE $this->table_files DROP COLUMN metadata" );
954 // $this->db_check = true;
955 // }
956 // // LATER: REMOVE THIS AFTER MARCH 2024
957 // $this->db_check = $this->db_check && !$this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'fileId'" );
958 // if ( !$this->db_check ) {
959 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN refId VARCHAR(64) NOT NULL" );
960 // $this->wpdb->query( "ALTER TABLE $this->table_files DROP COLUMN fileId" );
961 // $this->db_check = true;
962 // }
963 // // LATER: REMOVE THIS AFTER MARCH 2024
964 // $this->db_check = $this->db_check && $this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'envId'" );
965 // if ( !$this->db_check ) {
966 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN envId VARCHAR(128) NULL" );
967 // $this->db_check = true;
968 // }
969
970 return $this->db_check;
971 }
972
973 #endregion
974
975 /**
976 * Handle cleanup task for files
977 * Deletes files that have passed their expiration date
978 */
979 public function handle_cleanup_task( $result, $job ) {
980 $start = microtime( true );
981
982 // Check if files table exists
983 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_files}'" );
984 if ( !$table_exists ) {
985 return [
986 'ok' => true,
987 'done' => true,
988 'message' => 'Files table does not exist yet',
989 ];
990 }
991
992 // Get current progress
993 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
994 $deleted_attachments = isset( $job['meta']['deleted_attachments'] ) ? (int) $job['meta']['deleted_attachments'] : 0;
995 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
996 $processing_attachments = isset( $job['meta']['processing_attachments'] ) ? (bool) $job['meta']['processing_attachments'] : false;
997
998 $batch_size = 100;
999 $current_time = current_time( 'mysql' );
1000
1001 // First, process expired files from database
1002 if ( !$processing_attachments ) {
1003 $expired_files = $this->wpdb->get_results( $this->wpdb->prepare(
1004 "SELECT * FROM {$this->table_files}
1005 WHERE expires IS NOT NULL AND expires < %s AND id > %d
1006 ORDER BY id ASC
1007 LIMIT %d",
1008 $current_time,
1009 $last_id,
1010 $batch_size
1011 ) );
1012
1013 if ( !empty( $expired_files ) ) {
1014 $fileRefs = [];
1015 foreach ( $expired_files as $file ) {
1016 $fileRefs[] = $file->refId;
1017 }
1018 $this->delete_expired_files( $fileRefs );
1019
1020 $deleted_total += count( $expired_files );
1021 $last_id = end( $expired_files )->id;
1022
1023 $time_elapsed = microtime( true ) - $start;
1024
1025 // Continue processing files if we have more and time allows
1026 if ( count( $expired_files ) === $batch_size && $time_elapsed < 8 ) {
1027 return [
1028 'ok' => true,
1029 'done' => false,
1030 'message' => sprintf( 'Deleted %d expired files (total: %d)', count( $expired_files ), $deleted_total ),
1031 'meta' => [
1032 'deleted_total' => $deleted_total,
1033 'deleted_attachments' => $deleted_attachments,
1034 'last_id' => $last_id,
1035 'processing_attachments' => false,
1036 ],
1037 'step' => $job['step'] + 1,
1038 'step_name' => 'batch_' . ( $job['step'] + 1 ),
1039 ];
1040 }
1041
1042 // Move to attachments processing
1043 $processing_attachments = true;
1044 $last_id = 0;
1045 }
1046 else {
1047 // No expired files, move to attachments
1048 $processing_attachments = true;
1049 }
1050 }
1051
1052 // Process expired media library attachments
1053 if ( $processing_attachments ) {
1054 $expired_posts = get_posts( [
1055 'post_type' => 'attachment',
1056 'posts_per_page' => $batch_size,
1057 'offset' => $last_id,
1058 'meta_query' => [
1059 [
1060 'key' => '_mwai_file_expires',
1061 'value' => $current_time,
1062 'compare' => '<',
1063 'type' => 'DATETIME'
1064 ]
1065 ]
1066 ] );
1067
1068 if ( !empty( $expired_posts ) ) {
1069 $fileRefs = [];
1070 foreach ( $expired_posts as $post ) {
1071 $fileRefs[] = get_post_meta( $post->ID, '_mwai_file_id', true );
1072 }
1073 $this->delete_expired_files( $fileRefs );
1074
1075 $deleted_attachments += count( $expired_posts );
1076 $last_id += count( $expired_posts );
1077
1078 $time_elapsed = microtime( true ) - $start;
1079
1080 // Continue processing attachments if we have more and time allows
1081 if ( count( $expired_posts ) === $batch_size && $time_elapsed < 8 ) {
1082 return [
1083 'ok' => true,
1084 'done' => false,
1085 'message' => sprintf(
1086 'Deleted %d expired attachments (total: %d files, %d attachments)',
1087 count( $expired_posts ),
1088 $deleted_total,
1089 $deleted_attachments
1090 ),
1091 'meta' => [
1092 'deleted_total' => $deleted_total,
1093 'deleted_attachments' => $deleted_attachments,
1094 'last_id' => $last_id,
1095 'processing_attachments' => true,
1096 ],
1097 'step' => $job['step'] + 1,
1098 'step_name' => 'batch_' . ( $job['step'] + 1 ),
1099 ];
1100 }
1101 }
1102 }
1103
1104 // Completed
1105 $total_deleted = $deleted_total + $deleted_attachments;
1106 return [
1107 'ok' => true,
1108 'done' => true,
1109 'message' => sprintf(
1110 'Cleanup complete. Deleted %d expired files (%d filesystem, %d media library)',
1111 $total_deleted,
1112 $deleted_total,
1113 $deleted_attachments
1114 ),
1115 'meta' => [
1116 'deleted_total' => 0,
1117 'deleted_attachments' => 0,
1118 'last_id' => 0,
1119 'processing_attachments' => false,
1120 ],
1121 ];
1122 }
1123 }
1124