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