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