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