PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.9
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.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 7 months ago chatbot.php 4 months ago discussions.php 5 months ago files.php 6 months ago forms-manager.php 10 months ago gdpr.php 4 months ago search.php 11 months ago security.php 11 months ago tasks-examples.php 6 months ago tasks.php 5 months ago wand.php 5 months ago
files.php
1051 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 get_id_from_refId( $refId ) {
619 $file = null;
620 if ( $this->check_db() ) {
621 $file = $this->wpdb->get_row( $this->wpdb->prepare(
622 "SELECT *
623 FROM $this->table_files
624 WHERE refId = %s",
625 $refId
626 ) );
627 }
628 if ( $file ) {
629 return $file->id;
630 }
631 return null;
632 }
633
634 public function add_metadata_from_refId( $refId, $metaKey, $metaValue ) {
635 $fileId = $this->get_id_from_refId( $refId );
636 if ( $fileId ) {
637 return $this->add_metadata( $fileId, $metaKey, $metaValue );
638 }
639 return false;
640 }
641
642 public function rest_list( $request ) {
643 $params = $request->get_json_params();
644 $userId = empty( $params['userId'] ) ? null : $params['userId'];
645 $envId = empty( $params['envId'] ) ? null : $params['envId'];
646 $purpose = empty( $params['purpose'] ) ? null : $params['purpose'];
647 $metadata = empty( $params['metadata'] ) ? null : json_decode( $params['metadata'], true );
648 $limit = empty( $params['limit'] ) ? 10 : intval( $params['limit'] );
649 $offset = empty( $params['page'] ) ? 0 : ( intval( $params['page'] ) - 1 ) * $limit;
650
651 // Security fix: For unauthenticated users or users without explicit userId,
652 // restrict to their own files based on session
653 $currentUserId = $this->core->get_user_id();
654 if ( !$currentUserId || $currentUserId === 0 ) {
655 // For unauthenticated users, get session-based user ID
656 $sessionUserId = $this->core->get_session_user_id();
657 if ( !$sessionUserId ) {
658 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
659 }
660 $userId = $sessionUserId;
661 }
662 else if ( empty( $userId ) ) {
663 // For authenticated users without specified userId, use their own ID
664 $userId = $currentUserId;
665 }
666 else if ( $userId !== $currentUserId && !$this->core->can_access_settings() ) {
667 // Non-admin users can only access their own files
668 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access to other user files' ], 403 );
669 }
670
671 $files = $this->list( $userId, $purpose, $metadata, $envId, $limit, $offset );
672 return new WP_REST_Response( [ 'success' => true, 'data' => $files ], 200 );
673 }
674
675 public function rest_delete( $request ) {
676 $params = $request->get_json_params();
677 $fileRefs = empty( $params['files'] ) ? [] : $params['files'];
678
679 // Security fix: Verify user can delete these files
680 $currentUserId = $this->core->get_user_id();
681 $sessionUserId = null;
682
683 if ( !$currentUserId || $currentUserId === 0 ) {
684 // For unauthenticated users, get session-based user ID
685 $sessionUserId = $this->core->get_session_user_id();
686 if ( !$sessionUserId ) {
687 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
688 }
689 }
690
691 // Convert refIds to numeric IDs
692 $fileIds = [];
693 foreach ( $fileRefs as $ref ) {
694 $id = $this->get_id_from_refId( $ref );
695 if ( $id ) {
696 $fileIds[] = $id;
697 }
698 }
699
700 if ( empty( $fileIds ) ) {
701 return new WP_REST_Response( [ 'success' => false, 'message' => 'No valid files to delete' ], 400 );
702 }
703
704 // Verify ownership of files before deletion
705 $authorizedFileIds = $this->filter_authorized_files( $fileIds, $currentUserId ?: $sessionUserId );
706
707 if ( empty( $authorizedFileIds ) ) {
708 return new WP_REST_Response( [ 'success' => false, 'message' => 'No authorized files to delete' ], 403 );
709 }
710
711 $this->delete_files( $authorizedFileIds );
712 return new WP_REST_Response( [ 'success' => true, 'deleted' => count( $authorizedFileIds ) ], 200 );
713 }
714
715 public function delete_files( $fileIds ) {
716 $query = "SELECT id, refId, path FROM $this->table_files WHERE id IN (";
717 $params = [];
718 foreach ( $fileIds as $fileId ) {
719 $query .= '%s,';
720 $params[] = $fileId;
721 }
722 $query = rtrim( $query, ',' );
723 $query .= ')';
724 $files = $this->wpdb->get_results( $this->wpdb->prepare( $query, $params ), ARRAY_A );
725 $refIds = apply_filters( 'mwai_files_delete', array_column( $files, 'refId' ) );
726 foreach ( $files as $file ) {
727 if ( in_array( $file['refId'], $refIds ) ) {
728 // Delete from provider first
729 $this->delete_provider_file( $file['refId'] );
730 // Delete local file and database records
731 $this->wpdb->delete( $this->table_files, [ 'refId' => $file['refId'] ] );
732 $this->wpdb->delete( $this->table_filemeta, [ 'file_id' => $file['id'] ] );
733 if ( file_exists( $file['path'] ) ) {
734 unlink( $file['path'] );
735 }
736 }
737 }
738 }
739
740 /**
741 * Get effective user ID for file ownership
742 * Returns actual user ID for logged-in users, or session-based ID for guests
743 *
744 * @return int|string User ID or session-based ID
745 */
746 private function get_effective_user_id() {
747 $userId = $this->core->get_user_id();
748 if ( !$userId || $userId === 0 ) {
749 // For guest users, use session-based ID
750 return $this->core->get_session_user_id();
751 }
752 return $userId;
753 }
754
755 /**
756 * Filter file IDs to only include those the user is authorized to access
757 *
758 * @param array $fileIds Array of file IDs to filter
759 * @param int|string $userId User ID (can be session-based string for guests)
760 * @return array Array of authorized file IDs
761 */
762 private function filter_authorized_files( $fileIds, $userId ) {
763 if ( empty( $fileIds ) || empty( $userId ) ) {
764 return [];
765 }
766
767 // Admins can access all files
768 if ( $this->core->can_access_settings() ) {
769 return $fileIds;
770 }
771
772 // Build query to check file ownership
773 $placeholders = array_fill( 0, count( $fileIds ), '%s' );
774 $query = $this->wpdb->prepare(
775 "SELECT id FROM $this->table_files
776 WHERE id IN (" . implode( ',', $placeholders ) . ')
777 AND userId = %s',
778 array_merge( $fileIds, [ $userId ] )
779 );
780
781 $authorizedIds = $this->wpdb->get_col( $query );
782 return array_map( 'intval', $authorizedIds );
783 }
784
785 public function rest_upload() {
786 if ( empty( $_FILES['file'] ) ) {
787 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided.' ], 400 );
788 }
789 $file = $_FILES['file'];
790 $purpose = empty( $_POST['purpose'] ) ? null : $_POST['purpose'];
791 $metadata = empty( $_POST['metadata'] ) ? null : json_decode( $_POST['metadata'], true );
792 $envId = empty( $_POST['envId'] ) ? null : $_POST['envId'];
793 if ( !$purpose ) {
794 return new WP_REST_Response( [ 'success' => false, 'message' => 'Purpose is required.' ], 400 );
795 }
796 // Validate file type - allow audio files for transcription purpose
797 $fileTypeCheck = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
798
799 // If WordPress doesn't recognize the type, check if it's an audio file for transcription
800 if ( !$fileTypeCheck['type'] ) {
801 // Get file extension
802 $ext = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );
803
804 // Whisper supported formats: flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm
805 $audioExtensions = [ 'flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm' ];
806
807 if ( in_array( $ext, $audioExtensions ) ) {
808 // This is a valid audio file for transcription - override the type check
809 $fileTypeCheck['type'] = 'audio/' . $ext;
810 $fileTypeCheck['ext'] = $ext;
811 }
812 else {
813 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid file type.' ], 400 );
814 }
815 }
816
817 try {
818 $refId = $this->upload_file( $file['tmp_name'], $file['name'], $purpose, $metadata, $envId );
819 $url = $this->get_url( $refId );
820 return new WP_REST_Response( [
821 'success' => true,
822 'data' => [ 'id' => $refId, 'url' => $url ]
823 ], 200 );
824 }
825 catch ( Exception $e ) {
826 return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
827 }
828 }
829
830 #endregion
831
832 #region Database functions
833
834 public function create_db() {
835 $charset_collate = $this->wpdb->get_charset_collate();
836 $sql = "CREATE TABLE $this->table_files (
837 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
838 refId VARCHAR(64) NOT NULL,
839 envId VARCHAR(128) NULL,
840 userId VARCHAR(64) NULL,
841 type VARCHAR(32) NULL,
842 status VARCHAR(32) NULL,
843 purpose VARCHAR(32) NULL,
844 created DATETIME NOT NULL,
845 updated DATETIME NOT NULL,
846 expires DATETIME NULL,
847 path TEXT NULL,
848 url TEXT NULL,
849 PRIMARY KEY (id),
850 UNIQUE KEY unique_file_id (refId)
851 ) $charset_collate;";
852
853 $sqlFileMeta = "CREATE TABLE $this->table_filemeta (
854 meta_id BIGINT(20) NOT NULL AUTO_INCREMENT,
855 file_id BIGINT(20) NOT NULL,
856 meta_key varchar(255) NULL,
857 meta_value longtext NULL,
858 PRIMARY KEY (meta_id)
859 ) $charset_collate;";
860
861 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
862 dbDelta( $sql );
863 dbDelta( $sqlFileMeta );
864 }
865
866 public function check_db() {
867 if ( $this->db_check ) {
868 return true;
869 }
870
871 // Check if table_files exists
872 $sql = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_files );
873 $table_files_exists = strtolower( $this->wpdb->get_var( $sql ) ) === strtolower( $this->table_files );
874
875 // Check if table_filemeta exists
876 $sqlMeta = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_filemeta );
877 $table_filemeta_exists = strtolower( $this->wpdb->get_var( $sqlMeta ) ) === strtolower( $this->table_filemeta );
878
879 // If either table does not exist, create them
880 if ( !$table_files_exists || !$table_filemeta_exists ) {
881 $this->create_db();
882 }
883
884 // Update db_check for both tables
885 $this->db_check = $table_files_exists && $table_filemeta_exists;
886
887 // Check if userId column needs to be updated to VARCHAR for session support
888 // LATER: REMOVE THIS AFTER JANUARY 2026
889 if ( $this->db_check ) {
890 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_files WHERE Field = 'userId'" );
891 if ( $column_info && strpos( $column_info->Type, 'BIGINT' ) !== false ) {
892 // Update userId column from BIGINT to VARCHAR to support session-based IDs
893 $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN userId VARCHAR(64) NULL" );
894 }
895 }
896
897 return $this->db_check;
898 }
899
900 #endregion
901
902 /**
903 * Handle cleanup task for files
904 * Deletes files that have passed their expiration date
905 */
906 public function handle_cleanup_task( $result, $job ) {
907 $start = microtime( true );
908
909 // Check if files table exists
910 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_files}'" );
911 if ( !$table_exists ) {
912 return [
913 'ok' => true,
914 'done' => true,
915 'message' => 'Files table does not exist yet',
916 ];
917 }
918
919 // Get current progress
920 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
921 $deleted_attachments = isset( $job['meta']['deleted_attachments'] ) ? (int) $job['meta']['deleted_attachments'] : 0;
922 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
923 $processing_attachments = isset( $job['meta']['processing_attachments'] ) ? (bool) $job['meta']['processing_attachments'] : false;
924
925 $batch_size = 100;
926 $current_time = current_time( 'mysql' );
927
928 // First, process expired files from database
929 if ( !$processing_attachments ) {
930 $expired_files = $this->wpdb->get_results( $this->wpdb->prepare(
931 "SELECT * FROM {$this->table_files}
932 WHERE expires IS NOT NULL AND expires < %s AND id > %d
933 ORDER BY id ASC
934 LIMIT %d",
935 $current_time,
936 $last_id,
937 $batch_size
938 ) );
939
940 if ( !empty( $expired_files ) ) {
941 $fileRefs = [];
942 foreach ( $expired_files as $file ) {
943 $fileRefs[] = $file->refId;
944 }
945 $this->delete_expired_files( $fileRefs );
946
947 $deleted_total += count( $expired_files );
948 $last_id = end( $expired_files )->id;
949
950 $time_elapsed = microtime( true ) - $start;
951
952 // Continue processing files if we have more and time allows
953 if ( count( $expired_files ) === $batch_size && $time_elapsed < 8 ) {
954 return [
955 'ok' => true,
956 'done' => false,
957 'message' => sprintf( 'Deleted %d expired files (total: %d)', count( $expired_files ), $deleted_total ),
958 'meta' => [
959 'deleted_total' => $deleted_total,
960 'deleted_attachments' => $deleted_attachments,
961 'last_id' => $last_id,
962 'processing_attachments' => false,
963 ],
964 'step' => $job['step'] + 1,
965 'step_name' => 'batch_' . ( $job['step'] + 1 ),
966 ];
967 }
968
969 // Move to attachments processing
970 $processing_attachments = true;
971 $last_id = 0;
972 }
973 else {
974 // No expired files, move to attachments
975 $processing_attachments = true;
976 }
977 }
978
979 // Process expired media library attachments
980 if ( $processing_attachments ) {
981 $expired_posts = get_posts( [
982 'post_type' => 'attachment',
983 'posts_per_page' => $batch_size,
984 'offset' => $last_id,
985 'meta_query' => [
986 [
987 'key' => '_mwai_file_expires',
988 'value' => $current_time,
989 'compare' => '<',
990 'type' => 'DATETIME'
991 ]
992 ]
993 ] );
994
995 if ( !empty( $expired_posts ) ) {
996 $fileRefs = [];
997 foreach ( $expired_posts as $post ) {
998 $fileRefs[] = get_post_meta( $post->ID, '_mwai_file_id', true );
999 }
1000 $this->delete_expired_files( $fileRefs );
1001
1002 $deleted_attachments += count( $expired_posts );
1003 $last_id += count( $expired_posts );
1004
1005 $time_elapsed = microtime( true ) - $start;
1006
1007 // Continue processing attachments if we have more and time allows
1008 if ( count( $expired_posts ) === $batch_size && $time_elapsed < 8 ) {
1009 return [
1010 'ok' => true,
1011 'done' => false,
1012 'message' => sprintf(
1013 'Deleted %d expired attachments (total: %d files, %d attachments)',
1014 count( $expired_posts ),
1015 $deleted_total,
1016 $deleted_attachments
1017 ),
1018 'meta' => [
1019 'deleted_total' => $deleted_total,
1020 'deleted_attachments' => $deleted_attachments,
1021 'last_id' => $last_id,
1022 'processing_attachments' => true,
1023 ],
1024 'step' => $job['step'] + 1,
1025 'step_name' => 'batch_' . ( $job['step'] + 1 ),
1026 ];
1027 }
1028 }
1029 }
1030
1031 // Completed
1032 $total_deleted = $deleted_total + $deleted_attachments;
1033 return [
1034 'ok' => true,
1035 'done' => true,
1036 'message' => sprintf(
1037 'Cleanup complete. Deleted %d expired files (%d filesystem, %d media library)',
1038 $total_deleted,
1039 $deleted_total,
1040 $deleted_attachments
1041 ),
1042 'meta' => [
1043 'deleted_total' => 0,
1044 'deleted_attachments' => 0,
1045 'last_id' => 0,
1046 'processing_attachments' => false,
1047 ],
1048 ];
1049 }
1050 }
1051