PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.4.9
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.4.9
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 3 months ago chatbot.php 2 months ago discussions.php 2 months ago editor-assistant.php 3 months ago files.php 3 months ago forms-manager.php 3 months ago gdpr.php 4 months ago search.php 3 months ago security.php 1 year ago tasks-examples.php 6 months ago tasks.php 1 month ago wand.php 3 months ago
files.php
1059 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
18 // Initialize database
19 $this->check_db();
20
21 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
22
23 // Register task handler for cleanup
24 add_filter( 'mwai_task_cleanup_files', [ $this, 'handle_cleanup_task' ], 10, 2 );
25 }
26
27 public function delete_expired_files( $fileRefs ) {
28
29 // Give a chance to other process to delete the files (for example, in the case of files hosted by Assistants)
30 $fileRefs = apply_filters( 'mwai_files_delete', $fileRefs );
31
32 if ( !is_array( $fileRefs ) ) {
33 $fileRefs = [ $fileRefs ];
34 }
35
36 // Delete provider files before local cleanup
37 foreach ( $fileRefs as $refId ) {
38 $this->delete_provider_file( $refId );
39 }
40
41 foreach ( $fileRefs as $refId ) {
42 $file = null;
43 if ( $this->check_db() ) {
44 $file = $this->wpdb->get_row( $this->wpdb->prepare(
45 "SELECT *
46 FROM $this->table_files
47 WHERE refId = %s",
48 $refId
49 ) );
50 }
51 if ( $file ) {
52 $this->wpdb->delete( $this->table_files, [ 'refId' => $refId ] );
53 $this->wpdb->delete( $this->table_filemeta, [ 'file_id' => $file->id ] );
54 if ( file_exists( $file->path ) ) {
55 unlink( $file->path );
56 }
57 }
58 else {
59 $posts = get_posts( [ 'post_type' => 'attachment', 'meta_key' => '_mwai_file_id', 'meta_value' => $refId ] );
60 if ( $posts ) {
61 foreach ( $posts as $post ) {
62 wp_delete_attachment( $post->ID, true );
63 }
64 }
65 }
66 }
67 }
68
69 /**
70 * Delete file from provider (OpenAI, Anthropic, etc.) if applicable.
71 * This is called before local cleanup to ensure provider files are removed.
72 *
73 * @param string $refId The local file reference ID
74 */
75 private function delete_provider_file( $refId ) {
76 $metadata = $this->get_metadata( $refId );
77 $providerFileId = $metadata['file_id'] ?? null;
78 $provider = $metadata['provider'] ?? null;
79
80 if ( !$providerFileId || !$provider ) {
81 return; // No provider file to delete
82 }
83
84 $fileInfo = $this->get_info( $refId );
85 $envId = $fileInfo['envId'] ?? null;
86
87 if ( !$envId ) {
88 Meow_MWAI_Logging::warn( "Cannot delete provider file {$providerFileId}: no envId stored" );
89 return;
90 }
91
92 try {
93 if ( $provider === 'openai' ) {
94 $engine = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
95 $engine->delete_file( $providerFileId );
96 Meow_MWAI_Logging::log( "Deleted OpenAI file: {$providerFileId}" );
97 }
98 elseif ( $provider === 'anthropic' ) {
99 $engine = Meow_MWAI_Engines_Factory::get( $this->core, $envId );
100 if ( method_exists( $engine, 'delete_file' ) ) {
101 $engine->delete_file( $providerFileId );
102 Meow_MWAI_Logging::log( "Deleted Anthropic file: {$providerFileId}" );
103 }
104 }
105 // Google: Add when they have a Files API that needs cleanup
106 }
107 catch ( Exception $e ) {
108 // Log but don't fail - local cleanup should still proceed
109 Meow_MWAI_Logging::error( "Failed to delete {$provider} file {$providerFileId}: " . $e->getMessage() );
110 }
111 }
112
113 public function get_path( $refId ) {
114 $file = null;
115 if ( $this->check_db() ) {
116 $file = $this->wpdb->get_row( $this->wpdb->prepare(
117 "SELECT *
118 FROM $this->table_files
119 WHERE refId = %s",
120 $refId
121 ) );
122 }
123 if ( $file ) {
124 return $file->path;
125 }
126 else {
127 $posts = get_posts( [ 'post_type' => 'attachment', 'meta_key' => '_mwai_file_id', 'meta_value' => $refId ] );
128 if ( $posts ) {
129 foreach ( $posts as $post ) {
130 return get_attached_file( $post->ID );
131 }
132 }
133 }
134 return null;
135 }
136
137 public function get_base64_data( $refId ) {
138 $path = $this->get_path( $refId );
139 if ( $path ) {
140 $content = file_get_contents( $path );
141 $data = base64_encode( $content );
142 return $data;
143 }
144 return null;
145 }
146
147 public function is_image( $refId ) {
148 $info = $this->get_info( $refId );
149 return $info['type'] === 'image';
150 }
151
152 public function get_mime_type( $refId ) {
153 $path = $this->get_path( $refId );
154 if ( $path ) {
155 return Meow_MWAI_Core::get_mime_type( $path );
156 }
157 $url = $this->get_url( $refId );
158 if ( $url ) {
159 return Meow_MWAI_Core::get_mime_type( $url );
160 }
161 return null;
162 }
163
164 public function get_data( $refId ) {
165 $path = $this->get_path( $refId );
166 if ( $path ) {
167 $content = file_get_contents( $path );
168 return $content;
169 }
170 return null;
171 }
172
173 public function get_info( $refId ) {
174 $info = null;
175 if ( $this->check_db() ) {
176 $info = $this->wpdb->get_row( $this->wpdb->prepare(
177 "SELECT *
178 FROM $this->table_files
179 WHERE refId = %s",
180 $refId
181 ), ARRAY_A );
182 }
183 if ( !$info ) {
184 $posts = get_posts( [ 'post_type' => 'attachment', 'meta_key' => '_mwai_file_id', 'meta_value' => $refId ] );
185 if ( $posts ) {
186 $post = $posts[0];
187 $info = [
188 'refId' => $refId,
189 'url' => wp_get_attachment_url( $post->ID ),
190 'path' => get_attached_file( $post->ID )
191 ];
192 }
193 }
194 if ( $info ) {
195 $info['metadata'] = $this->get_metadata( $refId );
196 }
197 return $info;
198 }
199
200 public function get_url( $refId ) {
201 $file = null;
202 if ( $this->check_db() ) {
203 $file = $this->wpdb->get_row( $this->wpdb->prepare(
204 "SELECT *
205 FROM $this->table_files
206 WHERE refId = %s",
207 $refId
208 ) );
209 }
210 if ( $file ) {
211 return $file->url;
212 }
213 else {
214 $posts = get_posts( [ 'post_type' => 'attachment', 'meta_key' => '_mwai_file_id', 'meta_value' => $refId ] );
215 if ( $posts ) {
216 foreach ( $posts as $post ) {
217 return wp_get_attachment_url( $post->ID );
218 }
219 }
220 }
221 return null;
222 }
223
224 /**
225 * Handle a base-64 PNG returned by gpt-image-1: save as a temp file,
226 * register it in the Files DB, and give back a public URL.
227 *
228 * @param string $b64_json Raw base-64 image payload from OpenAI.
229 * @param string $purpose Optional purpose flag. Default 'generated'.
230 * @param int $ttl Time-to-live in seconds. Default 1 hour.
231 * @param string $target Target location: 'uploads' or 'library'. Default 'uploads'.
232 * @param array $metadata Additional metadata to store with the file.
233 *
234 * @return string|WP_Error Public URL or WP_Error on failure.
235 */
236 public function save_temp_image_from_b64(
237 string $b64_json,
238 string $purpose = 'generated',
239 int $ttl = HOUR_IN_SECONDS,
240 string $target = 'uploads',
241 array $metadata = []
242 ) {
243 // 1) Decode → binary.
244 $binary = base64_decode( $b64_json );
245 if ( !$binary ) {
246 return new WP_Error( 'mwai_bad_b64', 'Invalid base-64 payload.' );
247 }
248
249 // 2) Make a transient file in the server tmp dir.
250 $tmp_path = wp_tempnam( 'mwai-image' ); // Creates an empty file.
251 $filename = 'mwai-generated-' . time() . '-' . wp_generate_password( 8, false ) . '.png';
252 file_put_contents( $tmp_path, $binary );
253
254 // 3) Reuse the normal upload flow (target based on user preference, expiry = $ttl).
255 try {
256 // Extract envId from metadata if available
257 $envId = isset( $metadata['query_envId'] ) ? $metadata['query_envId'] : null;
258
259 $refId = $this->upload_file(
260 $tmp_path, // path on disk
261 $filename, // desired filename
262 $purpose, // purpose
263 $metadata, // metadata (now includes query info)
264 $envId, // envId from query
265 $target, // target (uploads or library based on user settings)
266 $ttl // expiry in seconds
267 );
268 // 4) Clean up temp file if it was uploaded to library (but not if uploads)
269 // For uploads target, the temp file IS the final file
270 if ( $target === 'library' && file_exists( $tmp_path ) ) {
271 @unlink( $tmp_path );
272 }
273
274 // 5) Turn refId → URL.
275 return $this->get_url( $refId );
276 }
277 catch ( Exception $e ) {
278 // Clean up temp file on error
279 if ( file_exists( $tmp_path ) ) {
280 @unlink( $tmp_path );
281 }
282 return new WP_Error( 'mwai_upload_failed', $e->getMessage() );
283 }
284 }
285
286 #region REST endpoints
287
288 public function rest_api_init() {
289 register_rest_route( $this->namespace, '/files/upload', [
290 'methods' => 'POST',
291 'callback' => [ $this, 'rest_upload' ],
292 'permission_callback' => [ $this->core, 'check_rest_nonce' ]
293 ] );
294 register_rest_route( $this->namespace, '/files/list', [
295 'methods' => 'POST',
296 'callback' => [ $this, 'rest_list' ],
297 'permission_callback' => [ $this->core, 'check_rest_nonce' ]
298 ] );
299 register_rest_route( $this->namespace, '/files/delete', [
300 'methods' => 'POST',
301 'callback' => [ $this, 'rest_delete' ],
302 'permission_callback' => [ $this->core, 'check_rest_nonce' ]
303 ] );
304 }
305
306 /*
307 * Record a new file in the Files database.
308 * This doesn't handle the upload or anything.
309 */
310 public function commit_file( $fileInfo ) {
311 if ( !$this->check_db() ) {
312 throw new Exception( 'Could not create database table.' );
313 }
314 $now = date( 'Y-m-d H:i:s' );
315 if ( empty( $fileInfo['refId'] ) ) {
316 if ( !empty( $fileInfo['url'] ) ) {
317 $fileInfo['refId'] = $this->generate_refId( $fileInfo['url'] );
318 }
319 else {
320 throw new Exception( 'File ID (or URL) is required.' );
321 }
322 }
323
324 // Check if file with this refId already exists
325 $existingFileId = $this->get_id_from_refId( $fileInfo['refId'] );
326 if ( $existingFileId ) {
327 // File already exists, return its ID
328 return $existingFileId;
329 }
330
331 if ( empty( $fileInfo['type'] ) ) {
332 $fileInfo['type'] = Meow_MWAI_Core::is_image( $fileInfo['url'] ) ? 'image' : 'file';
333 }
334 $success = $this->wpdb->insert( $this->table_files, [
335 'refId' => $fileInfo['refId'],
336 'envId' => empty( $fileInfo['envId'] ) ? null : $fileInfo['envId'],
337 'userId' => empty( $fileInfo['userId'] ) ? $this->get_effective_user_id() : $fileInfo['userId'],
338 'purpose' => empty( $fileInfo['purpose'] ) ? null : $fileInfo['purpose'],
339 'type' => empty( $fileInfo['type'] ) ? null : $fileInfo['type'],
340 'status' => empty( $fileInfo['status'] ) ? null : $fileInfo['status'],
341 'created' => empty( $fileInfo['created'] ) ? $now : $fileInfo['created'],
342 'updated' => empty( $fileInfo['updated'] ) ? $now : $fileInfo['updated'],
343 'expires' => empty( $fileInfo['expires'] ) ? null : $fileInfo['expires'],
344 'path' => empty( $fileInfo['path'] ) ? null : $fileInfo['path'],
345 'url' => empty( $fileInfo['url'] ) ? null : $fileInfo['url']
346 ] );
347
348 // Check for error
349 if ( !$success ) {
350 // Check if it's a duplicate key error (race condition)
351 if ( strpos( $this->wpdb->last_error, 'Duplicate entry' ) !== false &&
352 strpos( $this->wpdb->last_error, 'unique_file_id' ) !== false ) {
353 // Race condition: file was inserted by another request between our check and insert
354 // Try to get the existing file ID one more time
355 $existingFileId = $this->get_id_from_refId( $fileInfo['refId'] );
356 if ( $existingFileId ) {
357 return $existingFileId;
358 }
359 }
360 throw new Exception( 'Error while adding file in the DB (' . $this->wpdb->last_error . ')' );
361 }
362 return $this->wpdb->insert_id;
363 }
364
365 // Generate a refId from a URL or random, and make sure it's unique
366 public function generate_refId( $attempts = 0 ) {
367 // Use microtime for higher precision to avoid collisions when uploading multiple files simultaneously
368 $refId = md5( microtime( true ) . '-' . wp_rand() . '-' . $attempts );
369 $file = $this->wpdb->get_row( $this->wpdb->prepare(
370 "SELECT *
371 FROM $this->table_files
372 WHERE refId = %s",
373 $refId
374 ) );
375 if ( $file ) {
376 return $this->generate_refId( $attempts + 1 );
377 }
378 return $refId;
379 }
380
381 public function upload_file(
382 $path,
383 $filename = null,
384 $purpose = null,
385 $metadata = null,
386 $envId = null,
387 $target = null,
388 $expiry = null
389 ) {
390 require_once( ABSPATH . 'wp-admin/includes/image.php' );
391 require_once( ABSPATH . 'wp-admin/includes/file.php' );
392 require_once( ABSPATH . 'wp-admin/includes/media.php' );
393
394 $target = empty( $target ) ? $this->core->get_option( 'image_local_upload' ) : $target;
395 $expiry = empty( $expiry ) ? $this->core->get_option( 'image_expires' ) : $expiry;
396
397 $expires = ( $expiry === 'never' || empty( $expiry ) ) ? null : date( 'Y-m-d H:i:s', time() + intval( $expiry ) );
398 $refId = $this->generate_refId();
399 $url = null;
400 if ( empty( $filename ) ) {
401 $parsed_url = parse_url( $path, PHP_URL_PATH );
402 $filename = basename( $parsed_url );
403 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
404 }
405 else {
406 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
407 }
408
409 // Validate file type using WordPress built-in function
410 $validate = wp_check_filetype( $filename );
411 if ( $validate['type'] == false ) {
412 throw new Exception( 'File type is not allowed.' );
413 }
414 $newFilename = $refId . '.' . $extension;
415 $unique_filename = wp_unique_filename( wp_upload_dir()['path'], $newFilename );
416 $destination = wp_upload_dir()['path'] . '/' . $unique_filename;
417
418 if ( $target === 'uploads' ) {
419 if ( !$this->check_db() ) {
420 throw new Exception( 'Could not create database table.' );
421 }
422 if ( !copy( $path, $destination ) ) {
423 throw new Exception( 'Could not move the file.' );
424 }
425 $url = wp_upload_dir()['url'] . '/' . $unique_filename;
426
427 $now = date( 'Y-m-d H:i:s' );
428 $fileId = $this->commit_file( [
429 'refId' => $refId,
430 'envId' => $envId,
431 'purpose' => $purpose,
432 'type' => null,
433 'status' => 'uploaded',
434 'created' => $now,
435 'updated' => $now,
436 'expires' => $expires,
437 'path' => $destination,
438 'url' => $url
439 ] );
440 if ( $metadata && is_array( $metadata ) ) {
441 foreach ( $metadata as $metaKey => $metaValue ) {
442 $this->add_metadata( $fileId, $metaKey, $metaValue );
443 }
444 }
445
446 }
447 else if ( $target === 'library' ) {
448
449 if ( filter_var( $path, FILTER_VALIDATE_URL ) ) {
450 $tmp = download_url( $path );
451 if ( is_wp_error( $tmp ) ) {
452 throw new Exception( $tmp->get_error_message() );
453 }
454 $file_array = [ 'name' => $unique_filename, 'tmp_name' => $tmp ];
455 }
456 else {
457 $file_array = [ 'name' => $unique_filename, 'tmp_name' => $path ];
458 }
459
460 $id = media_handle_sideload( $file_array, 0 );
461 if ( is_wp_error( $id ) ) {
462 throw new Exception( $id->get_error_message() );
463 }
464
465 $url = wp_get_attachment_url( $id );
466 update_post_meta( $id, '_mwai_file_id', $refId );
467 update_post_meta( $id, '_mwai_file_expires', $expires );
468
469 // Store additional metadata
470 if ( $metadata && is_array( $metadata ) ) {
471 foreach ( $metadata as $metaKey => $metaValue ) {
472 update_post_meta( $id, '_mwai_' . $metaKey, $metaValue );
473 }
474 }
475
476 // Store purpose and envId as post meta
477 if ( $purpose ) {
478 update_post_meta( $id, '_mwai_purpose', $purpose );
479 }
480 if ( $envId ) {
481 update_post_meta( $id, '_mwai_envId', $envId );
482 }
483 }
484
485 return $refId;
486 }
487
488 public function add_metadata( $fileId, $metaKey, $metaValue ) {
489 $data = [
490 'file_id' => $fileId,
491 'meta_key' => $metaKey,
492 'meta_value' => $metaValue
493 ];
494 $res = $this->wpdb->insert( $this->table_filemeta, $data );
495 if ( $res === false ) {
496 Meow_MWAI_Logging::warn( 'Error while writing files metadata (' . $this->wpdb->last_error . ')' );
497 return false;
498 }
499 return $this->wpdb->insert_id;
500 }
501
502 public function update_refId( $fileId, $refId ) {
503 if ( $this->check_db() ) {
504 $this->wpdb->update( $this->table_files, [ 'refId' => $refId ], [ 'id' => $fileId ] );
505 }
506 }
507
508 public function update_purpose( $fileId, $purpose ) {
509 if ( $this->check_db() ) {
510 $this->wpdb->update( $this->table_files, [ 'purpose' => $purpose ], [ 'id' => $fileId ] );
511 }
512 }
513
514 public function update_envId( $fileId, $envId ) {
515 if ( $this->check_db() ) {
516 $this->wpdb->update( $this->table_files, [ 'envId' => $envId ], [ 'id' => $fileId ] );
517 }
518 }
519
520 public function get_metadata( $refId, $fileId = null ) {
521 if ( !$fileId ) {
522 $fileId = $this->get_id_from_refId( $refId );
523 }
524 if ( $fileId ) {
525 $sql = $this->wpdb->prepare( "SELECT * FROM $this->table_filemeta WHERE file_id = %d", $fileId );
526 $metadata = $this->wpdb->get_results( $sql, ARRAY_A );
527 $meta = [];
528 foreach ( $metadata as $metaItem ) {
529 $meta[$metaItem['meta_key']] = $metaItem['meta_value'];
530 }
531 return $meta;
532 }
533 return null;
534 }
535
536 public function search( $userId = null, $purpose = null, $metadata = [], $envId = null ) {
537 list( $sql, $params ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, true );
538 $finalQuery = $this->wpdb->prepare( $sql, $params );
539 $files = $this->wpdb->get_results( $finalQuery, ARRAY_A );
540 foreach ( $files as &$file ) {
541 $file['metadata'] = $this->get_metadata( $file['refId'] );
542 }
543 return $files;
544 }
545
546 public function list(
547 $userId = null,
548 $purpose = null,
549 $metadata = [],
550 $envId = null,
551 $limit = 10,
552 $offset = 0
553 ) {
554 list( $countSql, $countParams ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, false );
555 $total = $this->wpdb->get_var( $this->wpdb->prepare( $countSql, $countParams ) );
556
557 list( $fileSql, $fileParams ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, true );
558 if ( $limit ) {
559 $fileSql .= ' LIMIT %d';
560 $fileParams[] = $limit;
561 }
562 if ( $offset ) {
563 $fileSql .= ' OFFSET %d';
564 $fileParams[] = $offset;
565 }
566 $files = $this->wpdb->get_results( $this->wpdb->prepare( $fileSql, $fileParams ), ARRAY_A );
567 foreach ( $files as &$file ) {
568 $file['metadata'] = $this->get_metadata( $file['refId'] );
569 }
570 return [ 'files' => $files, 'total' => $total ];
571 }
572
573 private function _buildQuery( $userId, $purpose, $metadata, $envId, $selectStar ) {
574 $sql = $selectStar ? "SELECT * FROM $this->table_files WHERE 1=1" : "SELECT COUNT(*) FROM $this->table_files WHERE 1=1";
575 $params = [];
576
577 // Based on the old "search" function
578 $actualUserId = $this->core->get_user_id();
579 $canAdmin = $this->core->can_access_settings();
580 if ( $userId !== $actualUserId ) {
581 if ( !$canAdmin ) {
582 throw new Exception( 'You are not allowed to access files from another user.' );
583 }
584 }
585 if ( $userId ) {
586 $sql .= ' AND userId = %d';
587 $params[] = $userId;
588 }
589 if ( $purpose ) {
590 if ( is_array( $purpose ) ) {
591 $sql .= ' AND (';
592 foreach ( $purpose as $p ) {
593 $sql .= ' purpose = %s OR';
594 $params[] = $p;
595 }
596 $sql = rtrim( $sql, 'OR' );
597 $sql .= ')';
598 }
599 else {
600 $sql .= ' AND purpose = %s';
601 $params[] = $purpose;
602 }
603 }
604 if ( $metadata ) {
605 foreach ( $metadata as $metaKey => $metaValue ) {
606 $sql .= " AND EXISTS ( SELECT * FROM $this->table_filemeta
607 WHERE file_id = $this->table_files.id AND meta_key = %s AND meta_value = %s )";
608 $params[] = $metaKey;
609 $params[] = $metaValue;
610 }
611 }
612 if ( $envId ) {
613 $sql .= ' AND envId = %s';
614 $params[] = $envId;
615 }
616 $sql .= ' ORDER BY updated DESC';
617 return [ $sql, $params ];
618 }
619
620 public function get_id_from_refId( $refId ) {
621 $file = null;
622 if ( $this->check_db() ) {
623 $file = $this->wpdb->get_row( $this->wpdb->prepare(
624 "SELECT *
625 FROM $this->table_files
626 WHERE refId = %s",
627 $refId
628 ) );
629 }
630 if ( $file ) {
631 return $file->id;
632 }
633 return null;
634 }
635
636 public function add_metadata_from_refId( $refId, $metaKey, $metaValue ) {
637 $fileId = $this->get_id_from_refId( $refId );
638 if ( $fileId ) {
639 return $this->add_metadata( $fileId, $metaKey, $metaValue );
640 }
641 return false;
642 }
643
644 public function rest_list( $request ) {
645 $params = $request->get_json_params();
646 $userId = empty( $params['userId'] ) ? null : $params['userId'];
647 $envId = empty( $params['envId'] ) ? null : $params['envId'];
648 $purpose = empty( $params['purpose'] ) ? null : $params['purpose'];
649 $metadata = empty( $params['metadata'] ) ? null : json_decode( $params['metadata'], true );
650 $limit = empty( $params['limit'] ) ? 10 : intval( $params['limit'] );
651 $offset = empty( $params['page'] ) ? 0 : ( intval( $params['page'] ) - 1 ) * $limit;
652
653 // Security fix: For unauthenticated users or users without explicit userId,
654 // restrict to their own files based on session
655 $currentUserId = $this->core->get_user_id();
656 if ( !$currentUserId || $currentUserId === 0 ) {
657 // For unauthenticated users, get session-based user ID
658 $sessionUserId = $this->core->get_session_user_id();
659 if ( !$sessionUserId ) {
660 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
661 }
662 $userId = $sessionUserId;
663 }
664 else if ( empty( $userId ) ) {
665 // For authenticated users without specified userId, use their own ID
666 $userId = $currentUserId;
667 }
668 else if ( $userId !== $currentUserId && !$this->core->can_access_settings() ) {
669 // Non-admin users can only access their own files
670 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access to other user files' ], 403 );
671 }
672
673 $files = $this->list( $userId, $purpose, $metadata, $envId, $limit, $offset );
674 return new WP_REST_Response( [ 'success' => true, 'data' => $files ], 200 );
675 }
676
677 public function rest_delete( $request ) {
678 $params = $request->get_json_params();
679 $fileRefs = empty( $params['files'] ) ? [] : $params['files'];
680
681 // Security fix: Verify user can delete these files
682 $currentUserId = $this->core->get_user_id();
683 $sessionUserId = null;
684
685 if ( !$currentUserId || $currentUserId === 0 ) {
686 // For unauthenticated users, get session-based user ID
687 $sessionUserId = $this->core->get_session_user_id();
688 if ( !$sessionUserId ) {
689 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
690 }
691 }
692
693 // Convert refIds to numeric IDs
694 $fileIds = [];
695 foreach ( $fileRefs as $ref ) {
696 $id = $this->get_id_from_refId( $ref );
697 if ( $id ) {
698 $fileIds[] = $id;
699 }
700 }
701
702 if ( empty( $fileIds ) ) {
703 return new WP_REST_Response( [ 'success' => false, 'message' => 'No valid files to delete' ], 400 );
704 }
705
706 // Verify ownership of files before deletion
707 $authorizedFileIds = $this->filter_authorized_files( $fileIds, $currentUserId ?: $sessionUserId );
708
709 if ( empty( $authorizedFileIds ) ) {
710 return new WP_REST_Response( [ 'success' => false, 'message' => 'No authorized files to delete' ], 403 );
711 }
712
713 $this->delete_files( $authorizedFileIds );
714 return new WP_REST_Response( [ 'success' => true, 'deleted' => count( $authorizedFileIds ) ], 200 );
715 }
716
717 public function delete_files( $fileIds ) {
718 $query = "SELECT id, refId, path FROM $this->table_files WHERE id IN (";
719 $params = [];
720 foreach ( $fileIds as $fileId ) {
721 $query .= '%s,';
722 $params[] = $fileId;
723 }
724 $query = rtrim( $query, ',' );
725 $query .= ')';
726 $files = $this->wpdb->get_results( $this->wpdb->prepare( $query, $params ), ARRAY_A );
727 $refIds = apply_filters( 'mwai_files_delete', array_column( $files, 'refId' ) );
728 foreach ( $files as $file ) {
729 if ( in_array( $file['refId'], $refIds ) ) {
730 // Delete from provider first
731 $this->delete_provider_file( $file['refId'] );
732 // Delete local file and database records
733 $this->wpdb->delete( $this->table_files, [ 'refId' => $file['refId'] ] );
734 $this->wpdb->delete( $this->table_filemeta, [ 'file_id' => $file['id'] ] );
735 if ( file_exists( $file['path'] ) ) {
736 unlink( $file['path'] );
737 }
738 }
739 }
740 }
741
742 /**
743 * Get effective user ID for file ownership
744 * Returns actual user ID for logged-in users, or session-based ID for guests
745 *
746 * @return int|string User ID or session-based ID
747 */
748 private function get_effective_user_id() {
749 $userId = $this->core->get_user_id();
750 if ( !$userId || $userId === 0 ) {
751 // For guest users, use session-based ID
752 return $this->core->get_session_user_id();
753 }
754 return $userId;
755 }
756
757 /**
758 * Filter file IDs to only include those the user is authorized to access
759 *
760 * @param array $fileIds Array of file IDs to filter
761 * @param int|string $userId User ID (can be session-based string for guests)
762 * @return array Array of authorized file IDs
763 */
764 private function filter_authorized_files( $fileIds, $userId ) {
765 if ( empty( $fileIds ) || empty( $userId ) ) {
766 return [];
767 }
768
769 // Admins can access all files
770 if ( $this->core->can_access_settings() ) {
771 return $fileIds;
772 }
773
774 // Build query to check file ownership
775 $placeholders = array_fill( 0, count( $fileIds ), '%s' );
776 $query = $this->wpdb->prepare(
777 "SELECT id FROM $this->table_files
778 WHERE id IN (" . implode( ',', $placeholders ) . ')
779 AND userId = %s',
780 array_merge( $fileIds, [ $userId ] )
781 );
782
783 $authorizedIds = $this->wpdb->get_col( $query );
784 return array_map( 'intval', $authorizedIds );
785 }
786
787 public function rest_upload() {
788 if ( empty( $_FILES['file'] ) ) {
789 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided.' ], 400 );
790 }
791 $file = $_FILES['file'];
792 $purpose = empty( $_POST['purpose'] ) ? null : $_POST['purpose'];
793 $metadata = empty( $_POST['metadata'] ) ? null : json_decode( $_POST['metadata'], true );
794 $envId = empty( $_POST['envId'] ) ? null : $_POST['envId'];
795 if ( !$purpose ) {
796 return new WP_REST_Response( [ 'success' => false, 'message' => 'Purpose is required.' ], 400 );
797 }
798 // Validate file type - allow audio files for transcription purpose
799 $fileTypeCheck = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
800
801 // If WordPress doesn't recognize the type, check if it's an audio file for transcription
802 if ( !$fileTypeCheck['type'] ) {
803 // Get file extension
804 $ext = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );
805
806 // Whisper supported formats: flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm
807 $audioExtensions = [ 'flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm' ];
808
809 if ( in_array( $ext, $audioExtensions ) ) {
810 // This is a valid audio file for transcription - override the type check
811 $fileTypeCheck['type'] = 'audio/' . $ext;
812 $fileTypeCheck['ext'] = $ext;
813 }
814 else {
815 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid file type.' ], 400 );
816 }
817 }
818
819 try {
820 $refId = $this->upload_file( $file['tmp_name'], $file['name'], $purpose, $metadata, $envId );
821 $url = $this->get_url( $refId );
822 return new WP_REST_Response( [
823 'success' => true,
824 'data' => [ 'id' => $refId, 'url' => $url ]
825 ], 200 );
826 }
827 catch ( Exception $e ) {
828 return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
829 }
830 }
831
832 #endregion
833
834 #region Database functions
835
836 public function create_db() {
837 $charset_collate = $this->wpdb->get_charset_collate();
838 $sql = "CREATE TABLE $this->table_files (
839 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
840 refId VARCHAR(64) NOT NULL,
841 envId VARCHAR(128) NULL,
842 userId VARCHAR(64) NULL,
843 type VARCHAR(32) NULL,
844 status VARCHAR(32) NULL,
845 purpose VARCHAR(32) NULL,
846 created DATETIME NOT NULL,
847 updated DATETIME NOT NULL,
848 expires DATETIME NULL,
849 path TEXT NULL,
850 url TEXT NULL,
851 PRIMARY KEY (id),
852 UNIQUE KEY unique_file_id (refId)
853 ) $charset_collate;";
854
855 $sqlFileMeta = "CREATE TABLE $this->table_filemeta (
856 meta_id BIGINT(20) NOT NULL AUTO_INCREMENT,
857 file_id BIGINT(20) NOT NULL,
858 meta_key varchar(255) NULL,
859 meta_value longtext NULL,
860 PRIMARY KEY (meta_id)
861 ) $charset_collate;";
862
863 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
864 dbDelta( $sql );
865 dbDelta( $sqlFileMeta );
866 }
867
868 public function check_db() {
869 if ( $this->db_check ) {
870 return true;
871 }
872
873 // Per-module version check: skip SHOW TABLES if already verified for this version.
874 if ( get_option( 'mwai_db_version_files' ) === MWAI_VERSION ) {
875 $this->db_check = true;
876 return true;
877 }
878
879 // Check if table_files exists
880 $sql = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_files );
881 $table_files_exists = strtolower( $this->wpdb->get_var( $sql ) ) === strtolower( $this->table_files );
882
883 // Check if table_filemeta exists
884 $sqlMeta = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_filemeta );
885 $table_filemeta_exists = strtolower( $this->wpdb->get_var( $sqlMeta ) ) === strtolower( $this->table_filemeta );
886
887 // If either table does not exist, create them and re-check
888 if ( !$table_files_exists || !$table_filemeta_exists ) {
889 $this->create_db();
890 $table_files_exists = strtolower( $this->wpdb->get_var( $sql ) ) === strtolower( $this->table_files );
891 $table_filemeta_exists = strtolower( $this->wpdb->get_var( $sqlMeta ) ) === strtolower( $this->table_filemeta );
892 }
893
894 $this->db_check = $table_files_exists && $table_filemeta_exists;
895
896 // Check if userId column needs to be updated to VARCHAR for session support
897 if ( $this->db_check ) {
898 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_files WHERE Field = 'userId'" );
899 if ( $column_info && strpos( $column_info->Type, 'bigint' ) !== false ) {
900 $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN userId VARCHAR(64) NULL" );
901 }
902 update_option( 'mwai_db_version_files', MWAI_VERSION, true );
903 }
904
905 return $this->db_check;
906 }
907
908 #endregion
909
910 /**
911 * Handle cleanup task for files
912 * Deletes files that have passed their expiration date
913 */
914 public function handle_cleanup_task( $result, $job ) {
915 $start = microtime( true );
916
917 // Check if files table exists
918 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_files}'" );
919 if ( !$table_exists ) {
920 return [
921 'ok' => true,
922 'done' => true,
923 'message' => 'Files table does not exist yet',
924 ];
925 }
926
927 // Get current progress
928 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
929 $deleted_attachments = isset( $job['meta']['deleted_attachments'] ) ? (int) $job['meta']['deleted_attachments'] : 0;
930 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
931 $processing_attachments = isset( $job['meta']['processing_attachments'] ) ? (bool) $job['meta']['processing_attachments'] : false;
932
933 $batch_size = 100;
934 $current_time = current_time( 'mysql' );
935
936 // First, process expired files from database
937 if ( !$processing_attachments ) {
938 $expired_files = $this->wpdb->get_results( $this->wpdb->prepare(
939 "SELECT * FROM {$this->table_files}
940 WHERE expires IS NOT NULL AND expires < %s AND id > %d
941 ORDER BY id ASC
942 LIMIT %d",
943 $current_time,
944 $last_id,
945 $batch_size
946 ) );
947
948 if ( !empty( $expired_files ) ) {
949 $fileRefs = [];
950 foreach ( $expired_files as $file ) {
951 $fileRefs[] = $file->refId;
952 }
953 $this->delete_expired_files( $fileRefs );
954
955 $deleted_total += count( $expired_files );
956 $last_id = end( $expired_files )->id;
957
958 $time_elapsed = microtime( true ) - $start;
959
960 // Continue processing files if we have more and time allows
961 if ( count( $expired_files ) === $batch_size && $time_elapsed < 8 ) {
962 return [
963 'ok' => true,
964 'done' => false,
965 'message' => sprintf( 'Deleted %d expired files (total: %d)', count( $expired_files ), $deleted_total ),
966 'meta' => [
967 'deleted_total' => $deleted_total,
968 'deleted_attachments' => $deleted_attachments,
969 'last_id' => $last_id,
970 'processing_attachments' => false,
971 ],
972 'step' => $job['step'] + 1,
973 'step_name' => 'batch_' . ( $job['step'] + 1 ),
974 ];
975 }
976
977 // Move to attachments processing
978 $processing_attachments = true;
979 $last_id = 0;
980 }
981 else {
982 // No expired files, move to attachments
983 $processing_attachments = true;
984 }
985 }
986
987 // Process expired media library attachments
988 if ( $processing_attachments ) {
989 $expired_posts = get_posts( [
990 'post_type' => 'attachment',
991 'posts_per_page' => $batch_size,
992 'offset' => $last_id,
993 'meta_query' => [
994 [
995 'key' => '_mwai_file_expires',
996 'value' => $current_time,
997 'compare' => '<',
998 'type' => 'DATETIME'
999 ]
1000 ]
1001 ] );
1002
1003 if ( !empty( $expired_posts ) ) {
1004 $fileRefs = [];
1005 foreach ( $expired_posts as $post ) {
1006 $fileRefs[] = get_post_meta( $post->ID, '_mwai_file_id', true );
1007 }
1008 $this->delete_expired_files( $fileRefs );
1009
1010 $deleted_attachments += count( $expired_posts );
1011 $last_id += count( $expired_posts );
1012
1013 $time_elapsed = microtime( true ) - $start;
1014
1015 // Continue processing attachments if we have more and time allows
1016 if ( count( $expired_posts ) === $batch_size && $time_elapsed < 8 ) {
1017 return [
1018 'ok' => true,
1019 'done' => false,
1020 'message' => sprintf(
1021 'Deleted %d expired attachments (total: %d files, %d attachments)',
1022 count( $expired_posts ),
1023 $deleted_total,
1024 $deleted_attachments
1025 ),
1026 'meta' => [
1027 'deleted_total' => $deleted_total,
1028 'deleted_attachments' => $deleted_attachments,
1029 'last_id' => $last_id,
1030 'processing_attachments' => true,
1031 ],
1032 'step' => $job['step'] + 1,
1033 'step_name' => 'batch_' . ( $job['step'] + 1 ),
1034 ];
1035 }
1036 }
1037 }
1038
1039 // Completed
1040 $total_deleted = $deleted_total + $deleted_attachments;
1041 return [
1042 'ok' => true,
1043 'done' => true,
1044 'message' => sprintf(
1045 'Cleanup complete. Deleted %d expired files (%d filesystem, %d media library)',
1046 $total_deleted,
1047 $deleted_total,
1048 $deleted_attachments
1049 ),
1050 'meta' => [
1051 'deleted_total' => 0,
1052 'deleted_attachments' => 0,
1053 'last_id' => 0,
1054 'processing_attachments' => false,
1055 ],
1056 ];
1057 }
1058 }
1059