PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.5
3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / modules / files.php
ai-engine / classes / modules Last commit date
advisor.php 4 months ago chatbot.php 5 days ago discussions.php 5 days ago editor-assistant.php 4 months ago files.php 5 days ago forms-manager.php 4 months ago gdpr.php 5 months ago search.php 4 months ago security.php 1 year ago tasks-examples.php 7 months ago tasks.php 3 days ago wand.php 4 months ago workspace.php 5 days ago
files.php
1065 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 $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 // Ownership check. Compare against the EFFECTIVE user id: guests own their
578 // files under a 'session_<id>' string (get_effective_user_id), while
579 // get_user_id() returns null for them. Using get_user_id() here made every
580 // guest request fail this check ('session_x' !== null) and throw, 500-ing a
581 // guest listing its own files.
582 $actualUserId = $this->get_effective_user_id();
583 $canAdmin = $this->core->can_access_settings();
584 if ( $userId !== $actualUserId ) {
585 if ( !$canAdmin ) {
586 throw new Exception( 'You are not allowed to access files from another user.' );
587 }
588 }
589 if ( $userId ) {
590 // userId is VARCHAR (an int for members, 'session_<id>' for guests), so
591 // bind as a string. %d cast a guest id to 0 and never matched their files.
592 $sql .= ' AND userId = %s';
593 $params[] = $userId;
594 }
595 if ( $purpose ) {
596 if ( is_array( $purpose ) ) {
597 $sql .= ' AND (';
598 foreach ( $purpose as $p ) {
599 $sql .= ' purpose = %s OR';
600 $params[] = $p;
601 }
602 $sql = rtrim( $sql, 'OR' );
603 $sql .= ')';
604 }
605 else {
606 $sql .= ' AND purpose = %s';
607 $params[] = $purpose;
608 }
609 }
610 if ( $metadata ) {
611 foreach ( $metadata as $metaKey => $metaValue ) {
612 $sql .= " AND EXISTS ( SELECT * FROM $this->table_filemeta
613 WHERE file_id = $this->table_files.id AND meta_key = %s AND meta_value = %s )";
614 $params[] = $metaKey;
615 $params[] = $metaValue;
616 }
617 }
618 if ( $envId ) {
619 $sql .= ' AND envId = %s';
620 $params[] = $envId;
621 }
622 $sql .= ' ORDER BY updated DESC';
623 return [ $sql, $params ];
624 }
625
626 public function get_id_from_refId( $refId ) {
627 $file = null;
628 if ( $this->check_db() ) {
629 $file = $this->wpdb->get_row( $this->wpdb->prepare(
630 "SELECT *
631 FROM $this->table_files
632 WHERE refId = %s",
633 $refId
634 ) );
635 }
636 if ( $file ) {
637 return $file->id;
638 }
639 return null;
640 }
641
642 public function add_metadata_from_refId( $refId, $metaKey, $metaValue ) {
643 $fileId = $this->get_id_from_refId( $refId );
644 if ( $fileId ) {
645 return $this->add_metadata( $fileId, $metaKey, $metaValue );
646 }
647 return false;
648 }
649
650 public function rest_list( $request ) {
651 $params = $request->get_json_params();
652 $userId = empty( $params['userId'] ) ? null : $params['userId'];
653 $envId = empty( $params['envId'] ) ? null : $params['envId'];
654 $purpose = empty( $params['purpose'] ) ? null : $params['purpose'];
655 $metadata = empty( $params['metadata'] ) ? null : json_decode( $params['metadata'], true );
656 $limit = empty( $params['limit'] ) ? 10 : intval( $params['limit'] );
657 $offset = empty( $params['page'] ) ? 0 : ( intval( $params['page'] ) - 1 ) * $limit;
658
659 // Security fix: For unauthenticated users or users without explicit userId,
660 // restrict to their own files based on session
661 $currentUserId = $this->core->get_user_id();
662 if ( !$currentUserId || $currentUserId === 0 ) {
663 // For unauthenticated users, get session-based user ID
664 $sessionUserId = $this->core->get_session_user_id();
665 if ( !$sessionUserId ) {
666 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
667 }
668 $userId = $sessionUserId;
669 }
670 else if ( empty( $userId ) ) {
671 // For authenticated users without specified userId, use their own ID
672 $userId = $currentUserId;
673 }
674 else if ( $userId !== $currentUserId && !$this->core->can_access_settings() ) {
675 // Non-admin users can only access their own files
676 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access to other user files' ], 403 );
677 }
678
679 $files = $this->list( $userId, $purpose, $metadata, $envId, $limit, $offset );
680 return new WP_REST_Response( [ 'success' => true, 'data' => $files ], 200 );
681 }
682
683 public function rest_delete( $request ) {
684 $params = $request->get_json_params();
685 $fileRefs = empty( $params['files'] ) ? [] : $params['files'];
686
687 // Security fix: Verify user can delete these files
688 $currentUserId = $this->core->get_user_id();
689 $sessionUserId = null;
690
691 if ( !$currentUserId || $currentUserId === 0 ) {
692 // For unauthenticated users, get session-based user ID
693 $sessionUserId = $this->core->get_session_user_id();
694 if ( !$sessionUserId ) {
695 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
696 }
697 }
698
699 // Convert refIds to numeric IDs
700 $fileIds = [];
701 foreach ( $fileRefs as $ref ) {
702 $id = $this->get_id_from_refId( $ref );
703 if ( $id ) {
704 $fileIds[] = $id;
705 }
706 }
707
708 if ( empty( $fileIds ) ) {
709 return new WP_REST_Response( [ 'success' => false, 'message' => 'No valid files to delete' ], 400 );
710 }
711
712 // Verify ownership of files before deletion
713 $authorizedFileIds = $this->filter_authorized_files( $fileIds, $currentUserId ?: $sessionUserId );
714
715 if ( empty( $authorizedFileIds ) ) {
716 return new WP_REST_Response( [ 'success' => false, 'message' => 'No authorized files to delete' ], 403 );
717 }
718
719 $this->delete_files( $authorizedFileIds );
720 return new WP_REST_Response( [ 'success' => true, 'deleted' => count( $authorizedFileIds ) ], 200 );
721 }
722
723 public function delete_files( $fileIds ) {
724 $query = "SELECT id, refId, path FROM $this->table_files WHERE id IN (";
725 $params = [];
726 foreach ( $fileIds as $fileId ) {
727 $query .= '%s,';
728 $params[] = $fileId;
729 }
730 $query = rtrim( $query, ',' );
731 $query .= ')';
732 $files = $this->wpdb->get_results( $this->wpdb->prepare( $query, $params ), ARRAY_A );
733 $refIds = apply_filters( 'mwai_files_delete', array_column( $files, 'refId' ) );
734 foreach ( $files as $file ) {
735 if ( in_array( $file['refId'], $refIds ) ) {
736 // Delete from provider first
737 $this->delete_provider_file( $file['refId'] );
738 // Delete local file and database records
739 $this->wpdb->delete( $this->table_files, [ 'refId' => $file['refId'] ] );
740 $this->wpdb->delete( $this->table_filemeta, [ 'file_id' => $file['id'] ] );
741 if ( file_exists( $file['path'] ) ) {
742 unlink( $file['path'] );
743 }
744 }
745 }
746 }
747
748 /**
749 * Get effective user ID for file ownership
750 * Returns actual user ID for logged-in users, or session-based ID for guests
751 *
752 * @return int|string User ID or session-based ID
753 */
754 private function get_effective_user_id() {
755 $userId = $this->core->get_user_id();
756 if ( !$userId || $userId === 0 ) {
757 // For guest users, use session-based ID
758 return $this->core->get_session_user_id();
759 }
760 return $userId;
761 }
762
763 /**
764 * Filter file IDs to only include those the user is authorized to access
765 *
766 * @param array $fileIds Array of file IDs to filter
767 * @param int|string $userId User ID (can be session-based string for guests)
768 * @return array Array of authorized file IDs
769 */
770 private function filter_authorized_files( $fileIds, $userId ) {
771 if ( empty( $fileIds ) || empty( $userId ) ) {
772 return [];
773 }
774
775 // Admins can access all files
776 if ( $this->core->can_access_settings() ) {
777 return $fileIds;
778 }
779
780 // Build query to check file ownership
781 $placeholders = array_fill( 0, count( $fileIds ), '%s' );
782 $query = $this->wpdb->prepare(
783 "SELECT id FROM $this->table_files
784 WHERE id IN (" . implode( ',', $placeholders ) . ')
785 AND userId = %s',
786 array_merge( $fileIds, [ $userId ] )
787 );
788
789 $authorizedIds = $this->wpdb->get_col( $query );
790 return array_map( 'intval', $authorizedIds );
791 }
792
793 public function rest_upload() {
794 if ( empty( $_FILES['file'] ) ) {
795 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided.' ], 400 );
796 }
797 $file = $_FILES['file'];
798 $purpose = empty( $_POST['purpose'] ) ? null : $_POST['purpose'];
799 $metadata = empty( $_POST['metadata'] ) ? null : json_decode( $_POST['metadata'], true );
800 $envId = empty( $_POST['envId'] ) ? null : $_POST['envId'];
801 if ( !$purpose ) {
802 return new WP_REST_Response( [ 'success' => false, 'message' => 'Purpose is required.' ], 400 );
803 }
804 // Validate file type - allow audio files for transcription purpose
805 $fileTypeCheck = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
806
807 // If WordPress doesn't recognize the type, check if it's an audio file for transcription
808 if ( !$fileTypeCheck['type'] ) {
809 // Get file extension
810 $ext = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );
811
812 // Whisper supported formats: flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm
813 $audioExtensions = [ 'flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm' ];
814
815 if ( in_array( $ext, $audioExtensions ) ) {
816 // This is a valid audio file for transcription - override the type check
817 $fileTypeCheck['type'] = 'audio/' . $ext;
818 $fileTypeCheck['ext'] = $ext;
819 }
820 else {
821 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid file type.' ], 400 );
822 }
823 }
824
825 try {
826 $refId = $this->upload_file( $file['tmp_name'], $file['name'], $purpose, $metadata, $envId );
827 $url = $this->get_url( $refId );
828 return new WP_REST_Response( [
829 'success' => true,
830 'data' => [ 'id' => $refId, 'url' => $url ]
831 ], 200 );
832 }
833 catch ( Exception $e ) {
834 return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
835 }
836 }
837
838 #endregion
839
840 #region Database functions
841
842 public function create_db() {
843 $charset_collate = $this->wpdb->get_charset_collate();
844 $sql = "CREATE TABLE $this->table_files (
845 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
846 refId VARCHAR(64) NOT NULL,
847 envId VARCHAR(128) NULL,
848 userId VARCHAR(64) NULL,
849 type VARCHAR(32) NULL,
850 status VARCHAR(32) NULL,
851 purpose VARCHAR(32) NULL,
852 created DATETIME NOT NULL,
853 updated DATETIME NOT NULL,
854 expires DATETIME NULL,
855 path TEXT NULL,
856 url TEXT NULL,
857 PRIMARY KEY (id),
858 UNIQUE KEY unique_file_id (refId)
859 ) $charset_collate;";
860
861 $sqlFileMeta = "CREATE TABLE $this->table_filemeta (
862 meta_id BIGINT(20) NOT NULL AUTO_INCREMENT,
863 file_id BIGINT(20) NOT NULL,
864 meta_key varchar(255) NULL,
865 meta_value longtext NULL,
866 PRIMARY KEY (meta_id)
867 ) $charset_collate;";
868
869 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
870 dbDelta( $sql );
871 dbDelta( $sqlFileMeta );
872 }
873
874 public function check_db() {
875 if ( $this->db_check ) {
876 return true;
877 }
878
879 // Per-module version check: skip SHOW TABLES if already verified for this version.
880 if ( get_option( 'mwai_db_version_files' ) === MWAI_VERSION ) {
881 $this->db_check = true;
882 return true;
883 }
884
885 // Check if table_files exists
886 $sql = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_files );
887 $table_files_exists = strtolower( $this->wpdb->get_var( $sql ) ) === strtolower( $this->table_files );
888
889 // Check if table_filemeta exists
890 $sqlMeta = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_filemeta );
891 $table_filemeta_exists = strtolower( $this->wpdb->get_var( $sqlMeta ) ) === strtolower( $this->table_filemeta );
892
893 // If either table does not exist, create them and re-check
894 if ( !$table_files_exists || !$table_filemeta_exists ) {
895 $this->create_db();
896 $table_files_exists = strtolower( $this->wpdb->get_var( $sql ) ) === strtolower( $this->table_files );
897 $table_filemeta_exists = strtolower( $this->wpdb->get_var( $sqlMeta ) ) === strtolower( $this->table_filemeta );
898 }
899
900 $this->db_check = $table_files_exists && $table_filemeta_exists;
901
902 // Check if userId column needs to be updated to VARCHAR for session support
903 if ( $this->db_check ) {
904 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_files WHERE Field = 'userId'" );
905 if ( $column_info && strpos( $column_info->Type, 'bigint' ) !== false ) {
906 $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN userId VARCHAR(64) NULL" );
907 }
908 update_option( 'mwai_db_version_files', MWAI_VERSION, true );
909 }
910
911 return $this->db_check;
912 }
913
914 #endregion
915
916 /**
917 * Handle cleanup task for files
918 * Deletes files that have passed their expiration date
919 */
920 public function handle_cleanup_task( $result, $job ) {
921 $start = microtime( true );
922
923 // Check if files table exists
924 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_files}'" );
925 if ( !$table_exists ) {
926 return [
927 'ok' => true,
928 'done' => true,
929 'message' => 'Files table does not exist yet',
930 ];
931 }
932
933 // Get current progress
934 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
935 $deleted_attachments = isset( $job['meta']['deleted_attachments'] ) ? (int) $job['meta']['deleted_attachments'] : 0;
936 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
937 $processing_attachments = isset( $job['meta']['processing_attachments'] ) ? (bool) $job['meta']['processing_attachments'] : false;
938
939 $batch_size = 100;
940 $current_time = current_time( 'mysql' );
941
942 // First, process expired files from database
943 if ( !$processing_attachments ) {
944 $expired_files = $this->wpdb->get_results( $this->wpdb->prepare(
945 "SELECT * FROM {$this->table_files}
946 WHERE expires IS NOT NULL AND expires < %s AND id > %d
947 ORDER BY id ASC
948 LIMIT %d",
949 $current_time,
950 $last_id,
951 $batch_size
952 ) );
953
954 if ( !empty( $expired_files ) ) {
955 $fileRefs = [];
956 foreach ( $expired_files as $file ) {
957 $fileRefs[] = $file->refId;
958 }
959 $this->delete_expired_files( $fileRefs );
960
961 $deleted_total += count( $expired_files );
962 $last_id = end( $expired_files )->id;
963
964 $time_elapsed = microtime( true ) - $start;
965
966 // Continue processing files if we have more and time allows
967 if ( count( $expired_files ) === $batch_size && $time_elapsed < 8 ) {
968 return [
969 'ok' => true,
970 'done' => false,
971 'message' => sprintf( 'Deleted %d expired files (total: %d)', count( $expired_files ), $deleted_total ),
972 'meta' => [
973 'deleted_total' => $deleted_total,
974 'deleted_attachments' => $deleted_attachments,
975 'last_id' => $last_id,
976 'processing_attachments' => false,
977 ],
978 'step' => $job['step'] + 1,
979 'step_name' => 'batch_' . ( $job['step'] + 1 ),
980 ];
981 }
982
983 // Move to attachments processing
984 $processing_attachments = true;
985 $last_id = 0;
986 }
987 else {
988 // No expired files, move to attachments
989 $processing_attachments = true;
990 }
991 }
992
993 // Process expired media library attachments
994 if ( $processing_attachments ) {
995 $expired_posts = get_posts( [
996 'post_type' => 'attachment',
997 'posts_per_page' => $batch_size,
998 'offset' => $last_id,
999 'meta_query' => [
1000 [
1001 'key' => '_mwai_file_expires',
1002 'value' => $current_time,
1003 'compare' => '<',
1004 'type' => 'DATETIME'
1005 ]
1006 ]
1007 ] );
1008
1009 if ( !empty( $expired_posts ) ) {
1010 $fileRefs = [];
1011 foreach ( $expired_posts as $post ) {
1012 $fileRefs[] = get_post_meta( $post->ID, '_mwai_file_id', true );
1013 }
1014 $this->delete_expired_files( $fileRefs );
1015
1016 $deleted_attachments += count( $expired_posts );
1017 $last_id += count( $expired_posts );
1018
1019 $time_elapsed = microtime( true ) - $start;
1020
1021 // Continue processing attachments if we have more and time allows
1022 if ( count( $expired_posts ) === $batch_size && $time_elapsed < 8 ) {
1023 return [
1024 'ok' => true,
1025 'done' => false,
1026 'message' => sprintf(
1027 'Deleted %d expired attachments (total: %d files, %d attachments)',
1028 count( $expired_posts ),
1029 $deleted_total,
1030 $deleted_attachments
1031 ),
1032 'meta' => [
1033 'deleted_total' => $deleted_total,
1034 'deleted_attachments' => $deleted_attachments,
1035 'last_id' => $last_id,
1036 'processing_attachments' => true,
1037 ],
1038 'step' => $job['step'] + 1,
1039 'step_name' => 'batch_' . ( $job['step'] + 1 ),
1040 ];
1041 }
1042 }
1043 }
1044
1045 // Completed
1046 $total_deleted = $deleted_total + $deleted_attachments;
1047 return [
1048 'ok' => true,
1049 'done' => true,
1050 'message' => sprintf(
1051 'Cleanup complete. Deleted %d expired files (%d filesystem, %d media library)',
1052 $total_deleted,
1053 $deleted_total,
1054 $deleted_attachments
1055 ),
1056 'meta' => [
1057 'deleted_total' => 0,
1058 'deleted_attachments' => 0,
1059 'last_id' => 0,
1060 'processing_attachments' => false,
1061 ],
1062 ];
1063 }
1064 }
1065