PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.1.0
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.1.0
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 9 months ago discussions.php 9 months ago files.php 9 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 9 months ago wand.php 10 months ago
files.php
1003 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 if ( empty( $fileInfo['type'] ) ) {
313 $fileInfo['type'] = Meow_MWAI_Core::is_image( $fileInfo['url'] ) ? 'image' : 'file';
314 }
315 $success = $this->wpdb->insert( $this->table_files, [
316 'refId' => $fileInfo['refId'],
317 'envId' => empty( $fileInfo['envId'] ) ? null : $fileInfo['envId'],
318 'userId' => empty( $fileInfo['userId'] ) ? $this->get_effective_user_id() : $fileInfo['userId'],
319 'purpose' => empty( $fileInfo['purpose'] ) ? null : $fileInfo['purpose'],
320 'type' => empty( $fileInfo['type'] ) ? null : $fileInfo['type'],
321 'status' => empty( $fileInfo['status'] ) ? null : $fileInfo['status'],
322 'created' => empty( $fileInfo['created'] ) ? $now : $fileInfo['created'],
323 'updated' => empty( $fileInfo['updated'] ) ? $now : $fileInfo['updated'],
324 'expires' => empty( $fileInfo['expires'] ) ? null : $fileInfo['expires'],
325 'path' => empty( $fileInfo['path'] ) ? null : $fileInfo['path'],
326 'url' => empty( $fileInfo['url'] ) ? null : $fileInfo['url']
327 ] );
328 // check for error
329 if ( !$success ) {
330 throw new Exception( 'Error while adding file in the DB (' . $this->wpdb->last_error . ')' );
331 }
332 return $this->wpdb->insert_id;
333 }
334
335 // Generate a refId from a URL or random, and make sure it's unique
336 public function generate_refId( $attempts = 0 ) {
337 $refId = md5( date( 'Y-m-d H:i:s' ) . '-' . $attempts );
338 $file = $this->wpdb->get_row( $this->wpdb->prepare(
339 "SELECT *
340 FROM $this->table_files
341 WHERE refId = %s",
342 $refId
343 ) );
344 if ( $file ) {
345 return $this->generate_refId( $attempts + 1 );
346 }
347 return $refId;
348 }
349
350 public function upload_file(
351 $path,
352 $filename = null,
353 $purpose = null,
354 $metadata = null,
355 $envId = null,
356 $target = null,
357 $expiry = null
358 ) {
359 require_once( ABSPATH . 'wp-admin/includes/image.php' );
360 require_once( ABSPATH . 'wp-admin/includes/file.php' );
361 require_once( ABSPATH . 'wp-admin/includes/media.php' );
362
363 $target = empty( $target ) ? $this->core->get_option( 'image_local_upload' ) : $target;
364 $expiry = empty( $expiry ) ? $this->core->get_option( 'image_expires' ) : $expiry;
365
366 $expires = ( $expiry === 'never' || empty( $expiry ) ) ? null : date( 'Y-m-d H:i:s', time() + intval( $expiry ) );
367 $refId = $this->generate_refId();
368 $url = null;
369 if ( empty( $filename ) ) {
370 $parsed_url = parse_url( $path, PHP_URL_PATH );
371 $filename = basename( $parsed_url );
372 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
373 }
374 else {
375 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
376 }
377
378 // Validate file type using WordPress built-in function
379 $validate = wp_check_filetype( $filename );
380 if ( $validate['type'] == false ) {
381 throw new Exception( 'File type is not allowed.' );
382 }
383 $newFilename = $refId . '.' . $extension;
384 $unique_filename = wp_unique_filename( wp_upload_dir()['path'], $newFilename );
385 $destination = wp_upload_dir()['path'] . '/' . $unique_filename;
386
387 if ( $target === 'uploads' ) {
388 if ( !$this->check_db() ) {
389 throw new Exception( 'Could not create database table.' );
390 }
391 if ( !copy( $path, $destination ) ) {
392 throw new Exception( 'Could not move the file.' );
393 }
394 $url = wp_upload_dir()['url'] . '/' . $unique_filename;
395
396 $now = date( 'Y-m-d H:i:s' );
397 $fileId = $this->commit_file( [
398 'refId' => $refId,
399 'envId' => $envId,
400 'purpose' => $purpose,
401 'type' => null,
402 'status' => 'uploaded',
403 'created' => $now,
404 'updated' => $now,
405 'expires' => $expires,
406 'path' => $destination,
407 'url' => $url
408 ] );
409 if ( $metadata && is_array( $metadata ) ) {
410 foreach ( $metadata as $metaKey => $metaValue ) {
411 $this->add_metadata( $fileId, $metaKey, $metaValue );
412 }
413 }
414
415 }
416 else if ( $target === 'library' ) {
417
418 if ( filter_var( $path, FILTER_VALIDATE_URL ) ) {
419 $tmp = download_url( $path );
420 if ( is_wp_error( $tmp ) ) {
421 throw new Exception( $tmp->get_error_message() );
422 }
423 $file_array = [ 'name' => $unique_filename, 'tmp_name' => $tmp ];
424 }
425 else {
426 $file_array = [ 'name' => $unique_filename, 'tmp_name' => $path ];
427 }
428
429 $id = media_handle_sideload( $file_array, 0 );
430 if ( is_wp_error( $id ) ) {
431 throw new Exception( $id->get_error_message() );
432 }
433
434 $url = wp_get_attachment_url( $id );
435 update_post_meta( $id, '_mwai_file_id', $refId );
436 update_post_meta( $id, '_mwai_file_expires', $expires );
437
438 // Store additional metadata
439 if ( $metadata && is_array( $metadata ) ) {
440 foreach ( $metadata as $metaKey => $metaValue ) {
441 update_post_meta( $id, '_mwai_' . $metaKey, $metaValue );
442 }
443 }
444
445 // Store purpose and envId as post meta
446 if ( $purpose ) {
447 update_post_meta( $id, '_mwai_purpose', $purpose );
448 }
449 if ( $envId ) {
450 update_post_meta( $id, '_mwai_envId', $envId );
451 }
452 }
453
454 return $refId;
455 }
456
457 public function add_metadata( $fileId, $metaKey, $metaValue ) {
458 $data = [
459 'file_id' => $fileId,
460 'meta_key' => $metaKey,
461 'meta_value' => $metaValue
462 ];
463 $res = $this->wpdb->insert( $this->table_filemeta, $data );
464 if ( $res === false ) {
465 Meow_MWAI_Logging::warn( 'Error while writing files metadata (' . $this->wpdb->last_error . ')' );
466 return false;
467 }
468 return $this->wpdb->insert_id;
469 }
470
471 public function update_refId( $fileId, $refId ) {
472 if ( $this->check_db() ) {
473 $this->wpdb->update( $this->table_files, [ 'refId' => $refId ], [ 'id' => $fileId ] );
474 }
475 }
476
477 public function update_purpose( $fileId, $purpose ) {
478 if ( $this->check_db() ) {
479 $this->wpdb->update( $this->table_files, [ 'purpose' => $purpose ], [ 'id' => $fileId ] );
480 }
481 }
482
483 public function update_envId( $fileId, $envId ) {
484 if ( $this->check_db() ) {
485 $this->wpdb->update( $this->table_files, [ 'envId' => $envId ], [ 'id' => $fileId ] );
486 }
487 }
488
489 public function get_metadata( $refId, $fileId = null ) {
490 if ( !$fileId ) {
491 $fileId = $this->get_id_from_refId( $refId );
492 }
493 if ( $fileId ) {
494 $sql = $this->wpdb->prepare( "SELECT * FROM $this->table_filemeta WHERE file_id = %d", $fileId );
495 $metadata = $this->wpdb->get_results( $sql, ARRAY_A );
496 $meta = [];
497 foreach ( $metadata as $metaItem ) {
498 $meta[$metaItem['meta_key']] = $metaItem['meta_value'];
499 }
500 return $meta;
501 }
502 return null;
503 }
504
505 public function search( $userId = null, $purpose = null, $metadata = [], $envId = null ) {
506 list( $sql, $params ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, true );
507 $finalQuery = $this->wpdb->prepare( $sql, $params );
508 $files = $this->wpdb->get_results( $finalQuery, ARRAY_A );
509 foreach ( $files as &$file ) {
510 $file['metadata'] = $this->get_metadata( $file['refId'] );
511 }
512 return $files;
513 }
514
515 public function list(
516 $userId = null,
517 $purpose = null,
518 $metadata = [],
519 $envId = null,
520 $limit = 10,
521 $offset = 0
522 ) {
523 list( $countSql, $countParams ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, false );
524 $total = $this->wpdb->get_var( $this->wpdb->prepare( $countSql, $countParams ) );
525
526 list( $fileSql, $fileParams ) = $this->_buildQuery( $userId, $purpose, $metadata, $envId, true );
527 if ( $limit ) {
528 $fileSql .= ' LIMIT %d';
529 $fileParams[] = $limit;
530 }
531 if ( $offset ) {
532 $fileSql .= ' OFFSET %d';
533 $fileParams[] = $offset;
534 }
535 $files = $this->wpdb->get_results( $this->wpdb->prepare( $fileSql, $fileParams ), ARRAY_A );
536 foreach ( $files as &$file ) {
537 $file['metadata'] = $this->get_metadata( $file['refId'] );
538 }
539 return [ 'files' => $files, 'total' => $total ];
540 }
541
542 private function _buildQuery( $userId, $purpose, $metadata, $envId, $selectStar ) {
543 $sql = $selectStar ? "SELECT * FROM $this->table_files WHERE 1=1" : "SELECT COUNT(*) FROM $this->table_files WHERE 1=1";
544 $params = [];
545
546 // Based on the old "search" function
547 $actualUserId = $this->core->get_user_id();
548 $canAdmin = $this->core->can_access_settings();
549 if ( $userId !== $actualUserId ) {
550 if ( !$canAdmin ) {
551 throw new Exception( 'You are not allowed to access files from another user.' );
552 }
553 }
554 if ( $userId ) {
555 $sql .= ' AND userId = %d';
556 $params[] = $userId;
557 }
558 if ( $purpose ) {
559 if ( is_array( $purpose ) ) {
560 $sql .= ' AND (';
561 foreach ( $purpose as $p ) {
562 $sql .= ' purpose = %s OR';
563 $params[] = $p;
564 }
565 $sql = rtrim( $sql, 'OR' );
566 $sql .= ')';
567 }
568 else {
569 $sql .= ' AND purpose = %s';
570 $params[] = $purpose;
571 }
572 }
573 if ( $metadata ) {
574 foreach ( $metadata as $metaKey => $metaValue ) {
575 $sql .= " AND EXISTS ( SELECT * FROM $this->table_filemeta
576 WHERE file_id = $this->table_files.id AND meta_key = %s AND meta_value = %s )";
577 $params[] = $metaKey;
578 $params[] = $metaValue;
579 }
580 }
581 if ( $envId ) {
582 $sql .= ' AND envId = %s';
583 $params[] = $envId;
584 }
585 $sql .= ' ORDER BY updated DESC';
586 return [ $sql, $params ];
587 }
588
589 // public function search( $userId = null, $purpose = null, $metadata = [], $limit = 10, $offset = 0 ) {
590 // $sql = "SELECT * FROM $this->table_files WHERE 1=1";
591 // $actualUserId = $this->core->get_user_id();
592 // $canAdmin = $this->core->can_access_settings();
593 // if ( $userId !== $actualUserId ) {
594 // if ( !$canAdmin ) {
595 // throw new Exception( 'You are not allowed to access files from another user.' );
596 // }
597 // }
598 // if ( $userId ) {
599 // $sql .= $this->wpdb->prepare( " AND userId = %d", $userId );
600 // }
601 // if ( $purpose ) {
602 // if ( is_array( $purpose ) ) {
603 // $sql .= " AND (";
604 // foreach ( $purpose as $p ) {
605 // $sql .= $this->wpdb->prepare( " purpose = %s OR", $p );
606 // }
607 // $sql = rtrim( $sql, 'OR' );
608 // $sql .= ")";
609 // }
610 // else {
611 // $sql .= $this->wpdb->prepare( " AND purpose = %s", $purpose );
612 // }
613 // }
614 // if ( $metadata ) {
615 // foreach ( $metadata as $metaKey => $metaValue ) {
616 // $sql .= $this->wpdb->prepare( " AND EXISTS ( SELECT * FROM $this->table_filemeta
617 // WHERE file_id = $this->table_files.id AND meta_key = %s AND meta_value = %s )",
618 // $metaKey, $metaValue
619 // );
620 // }
621 // }
622 // $sql .= " ORDER BY updated DESC";
623 // if ( $limit ) {
624 // $sql .= $this->wpdb->prepare( " LIMIT %d", $limit );
625 // }
626 // if ( $offset ) {
627 // $sql .= $this->wpdb->prepare( " OFFSET %d", $offset );
628 // }
629 // $files = $this->wpdb->get_results( $sql, ARRAY_A );
630
631 // // Add metadata
632 // foreach ( $files as &$file ) {
633 // $file['metadata'] = $this->get_metadata( $file['refId'] );
634 // }
635
636 // return $files;
637 // }
638
639 public function get_id_from_refId( $refId ) {
640 $file = null;
641 if ( $this->check_db() ) {
642 $file = $this->wpdb->get_row( $this->wpdb->prepare(
643 "SELECT *
644 FROM $this->table_files
645 WHERE refId = %s",
646 $refId
647 ) );
648 }
649 if ( $file ) {
650 return $file->id;
651 }
652 return null;
653 }
654
655 public function add_metadata_from_refId( $refId, $metaKey, $metaValue ) {
656 $fileId = $this->get_id_from_refId( $refId );
657 if ( $fileId ) {
658 return $this->add_metadata( $fileId, $metaKey, $metaValue );
659 }
660 return false;
661 }
662
663 public function rest_list( $request ) {
664 $params = $request->get_json_params();
665 $userId = empty( $params['userId'] ) ? null : $params['userId'];
666 $envId = empty( $params['envId'] ) ? null : $params['envId'];
667 $purpose = empty( $params['purpose'] ) ? null : $params['purpose'];
668 $metadata = empty( $params['metadata'] ) ? null : json_decode( $params['metadata'], true );
669 $limit = empty( $params['limit'] ) ? 10 : intval( $params['limit'] );
670 $offset = empty( $params['page'] ) ? 0 : ( intval( $params['page'] ) - 1 ) * $limit;
671
672 // Security fix: For unauthenticated users or users without explicit userId,
673 // restrict to their own files based on session
674 $currentUserId = $this->core->get_user_id();
675 if ( !$currentUserId || $currentUserId === 0 ) {
676 // For unauthenticated users, get session-based user ID
677 $sessionUserId = $this->core->get_session_user_id();
678 if ( !$sessionUserId ) {
679 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
680 }
681 $userId = $sessionUserId;
682 } else if ( empty( $userId ) ) {
683 // For authenticated users without specified userId, use their own ID
684 $userId = $currentUserId;
685 } else if ( $userId !== $currentUserId && !$this->core->can_access_settings() ) {
686 // Non-admin users can only access their own files
687 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access to other user files' ], 403 );
688 }
689
690 $files = $this->list( $userId, $purpose, $metadata, $envId, $limit, $offset );
691 return new WP_REST_Response( [ 'success' => true, 'data' => $files ], 200 );
692 }
693
694 public function rest_delete( $request ) {
695 $params = $request->get_json_params();
696 $fileIds = empty( $params['files'] ) ? [] : $params['files'];
697
698 // Security fix: Verify user can delete these files
699 $currentUserId = $this->core->get_user_id();
700 $sessionUserId = null;
701
702 if ( !$currentUserId || $currentUserId === 0 ) {
703 // For unauthenticated users, get session-based user ID
704 $sessionUserId = $this->core->get_session_user_id();
705 if ( !$sessionUserId ) {
706 return new WP_REST_Response( [ 'success' => false, 'message' => 'Unauthorized access' ], 403 );
707 }
708 }
709
710 // Verify ownership of files before deletion
711 $authorizedFileIds = $this->filter_authorized_files( $fileIds, $currentUserId ?: $sessionUserId );
712
713 if ( empty( $authorizedFileIds ) ) {
714 return new WP_REST_Response( [ 'success' => false, 'message' => 'No authorized files to delete' ], 403 );
715 }
716
717 $this->delete_files( $authorizedFileIds );
718 return new WP_REST_Response( [ 'success' => true, 'deleted' => count( $authorizedFileIds ) ], 200 );
719 }
720
721 public function delete_files( $fileIds ) {
722 $query = "SELECT refId, path FROM $this->table_files WHERE id IN (";
723 $params = [];
724 foreach ( $fileIds as $fileId ) {
725 $query .= '%s,';
726 $params[] = $fileId;
727 }
728 $query = rtrim( $query, ',' );
729 $query .= ')';
730 $files = $this->wpdb->get_results( $this->wpdb->prepare( $query, $params ), ARRAY_A );
731 $refIds = apply_filters( 'mwai_files_delete', array_column( $files, 'refId' ) );
732 foreach ( $files as $file ) {
733 if ( in_array( $file['refId'], $refIds ) ) {
734 $this->wpdb->delete( $this->table_files, [ 'refId' => $file['refId'] ] );
735 if ( file_exists( $file['path'] ) ) {
736 unlink( $file['path'] );
737 }
738 }
739 }
740 }
741
742 /**
743 * Get effective user ID for file ownership
744 * Returns actual user ID for logged-in users, or session-based ID for guests
745 *
746 * @return int|string User ID or session-based ID
747 */
748 private function get_effective_user_id() {
749 $userId = $this->core->get_user_id();
750 if ( !$userId || $userId === 0 ) {
751 // For guest users, use session-based ID
752 return $this->core->get_session_user_id();
753 }
754 return $userId;
755 }
756
757 /**
758 * Filter file IDs to only include those the user is authorized to access
759 *
760 * @param array $fileIds Array of file IDs to filter
761 * @param int|string $userId User ID (can be session-based string for guests)
762 * @return array Array of authorized file IDs
763 */
764 private function filter_authorized_files( $fileIds, $userId ) {
765 if ( empty( $fileIds ) || empty( $userId ) ) {
766 return [];
767 }
768
769 // Admins can access all files
770 if ( $this->core->can_access_settings() ) {
771 return $fileIds;
772 }
773
774 // Build query to check file ownership
775 $placeholders = array_fill( 0, count( $fileIds ), '%s' );
776 $query = $this->wpdb->prepare(
777 "SELECT id FROM $this->table_files
778 WHERE id IN (" . implode( ',', $placeholders ) . ")
779 AND userId = %s",
780 array_merge( $fileIds, [ $userId ] )
781 );
782
783 $authorizedIds = $this->wpdb->get_col( $query );
784 return array_map( 'intval', $authorizedIds );
785 }
786
787 public function rest_upload() {
788 if ( empty( $_FILES['file'] ) ) {
789 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided.' ], 400 );
790 }
791 $file = $_FILES['file'];
792 $purpose = empty( $_POST['purpose'] ) ? null : $_POST['purpose'];
793 $metadata = empty( $_POST['metadata'] ) ? null : json_decode( $_POST['metadata'], true );
794 $envId = empty( $_POST['envId'] ) ? null : $_POST['envId'];
795 if ( !$purpose ) {
796 return new WP_REST_Response( [ 'success' => false, 'message' => 'Purpose is required.' ], 400 );
797 }
798 $fileTypeCheck = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
799 if ( !$fileTypeCheck['type'] ) {
800 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid file type.' ], 400 );
801 }
802
803 try {
804 $refId = $this->upload_file( $file['tmp_name'], $file['name'], $purpose, $metadata, $envId );
805 $url = $this->get_url( $refId );
806 return new WP_REST_Response( [
807 'success' => true,
808 'data' => [ 'id' => $refId, 'url' => $url ]
809 ], 200 );
810 }
811 catch ( Exception $e ) {
812 return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
813 }
814 }
815
816 #endregion
817
818 #region Database functions
819
820 public function create_db() {
821 $charset_collate = $this->wpdb->get_charset_collate();
822 $sql = "CREATE TABLE $this->table_files (
823 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
824 refId VARCHAR(64) NOT NULL,
825 envId VARCHAR(128) NULL,
826 userId VARCHAR(64) NULL,
827 type VARCHAR(32) NULL,
828 status VARCHAR(32) NULL,
829 purpose VARCHAR(32) NULL,
830 created DATETIME NOT NULL,
831 updated DATETIME NOT NULL,
832 expires DATETIME NULL,
833 path TEXT NULL,
834 url TEXT NULL,
835 PRIMARY KEY (id),
836 UNIQUE KEY unique_file_id (refId)
837 ) $charset_collate;";
838
839 $sqlFileMeta = "CREATE TABLE $this->table_filemeta (
840 meta_id BIGINT(20) NOT NULL AUTO_INCREMENT,
841 file_id BIGINT(20) NOT NULL,
842 meta_key varchar(255) NULL,
843 meta_value longtext NULL,
844 PRIMARY KEY (meta_id)
845 ) $charset_collate;";
846
847 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
848 dbDelta( $sql );
849 dbDelta( $sqlFileMeta );
850 }
851
852 public function check_db() {
853 if ( $this->db_check ) {
854 return true;
855 }
856
857 // Check if table_files exists
858 $sql = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_files );
859 $table_files_exists = strtolower( $this->wpdb->get_var( $sql ) ) === strtolower( $this->table_files );
860
861 // Check if table_filemeta exists
862 $sqlMeta = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_filemeta );
863 $table_filemeta_exists = strtolower( $this->wpdb->get_var( $sqlMeta ) ) === strtolower( $this->table_filemeta );
864
865 // If either table does not exist, create them
866 if ( !$table_files_exists || !$table_filemeta_exists ) {
867 $this->create_db();
868 }
869
870 // Update db_check for both tables
871 $this->db_check = $table_files_exists && $table_filemeta_exists;
872
873 // Check if userId column needs to be updated to VARCHAR for session support
874 // LATER: REMOVE THIS AFTER JANUARY 2026
875 if ( $this->db_check ) {
876 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_files WHERE Field = 'userId'" );
877 if ( $column_info && strpos( $column_info->Type, 'BIGINT' ) !== false ) {
878 // Update userId column from BIGINT to VARCHAR to support session-based IDs
879 $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN userId VARCHAR(64) NULL" );
880 }
881 }
882
883 // LATER: REMOVE THIS AFTER MARCH 2024
884 // $this->db_check = $this->db_check && $this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'userId'" );
885 // if ( !$this->db_check ) {
886 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN userId BIGINT(20) UNSIGNED NULL" );
887 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN purpose VARCHAR(32) NULL" );
888 // $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN path TEXT NULL" );
889 // $this->wpdb->query( "ALTER TABLE $this->table_files DROP COLUMN metadata" );
890 // $this->db_check = true;
891 // }
892 // // LATER: REMOVE THIS AFTER MARCH 2024
893 // $this->db_check = $this->db_check && !$this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'fileId'" );
894 // if ( !$this->db_check ) {
895 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN refId VARCHAR(64) NOT NULL" );
896 // $this->wpdb->query( "ALTER TABLE $this->table_files DROP COLUMN fileId" );
897 // $this->db_check = true;
898 // }
899 // // LATER: REMOVE THIS AFTER MARCH 2024
900 // $this->db_check = $this->db_check && $this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'envId'" );
901 // if ( !$this->db_check ) {
902 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN envId VARCHAR(128) NULL" );
903 // $this->db_check = true;
904 // }
905
906 return $this->db_check;
907 }
908
909 #endregion
910
911 /**
912 * Handle cleanup task for files
913 */
914 public function handle_cleanup_task( $result, $job ) {
915 $start = microtime( true );
916 $orphan_days = 30; // Delete files older than 30 days
917
918 // Check if files table exists
919 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_files}'" );
920 if ( !$table_exists ) {
921 return [
922 'ok' => true,
923 'done' => true,
924 'message' => 'Files table does not exist yet',
925 ];
926 }
927
928 // Get current progress
929 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
930 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
931
932 // Clean up orphaned database records
933 $batch_size = 100;
934 $deleted_batch = 0;
935
936 $orphan_cutoff = date( 'Y-m-d H:i:s', strtotime( "-{$orphan_days} days" ) );
937
938 $orphan_files = $this->wpdb->get_results( $this->wpdb->prepare(
939 "SELECT id, path FROM {$this->table_files}
940 WHERE updated < %s AND id > %d
941 ORDER BY id ASC
942 LIMIT %d",
943 $orphan_cutoff, $last_id, $batch_size
944 ) );
945
946 if ( !empty( $orphan_files ) ) {
947 foreach ( $orphan_files as $file ) {
948 // Try to delete physical file if it exists
949 if ( !empty( $file->path ) && file_exists( $file->path ) ) {
950 @unlink( $file->path );
951 }
952 }
953
954 // Delete database records
955 $ids = wp_list_pluck( $orphan_files, 'id' );
956 $ids_string = implode( ',', array_map( 'intval', $ids ) );
957
958 // Delete from filemeta first (foreign key constraint)
959 $this->wpdb->query(
960 "DELETE FROM {$this->table_filemeta} WHERE file_id IN ($ids_string)"
961 );
962
963 // Then delete from files
964 $deleted_batch = $this->wpdb->query(
965 "DELETE FROM {$this->table_files} WHERE id IN ($ids_string)"
966 );
967
968 $deleted_total += $deleted_batch;
969 $last_id = end( $ids );
970 }
971
972 // Check if we have more to process or time is running out
973 $has_more = count( $orphan_files ) === $batch_size;
974 $time_elapsed = microtime( true ) - $start;
975
976 if ( $has_more && $time_elapsed < 8 ) {
977 // Continue processing
978 return [
979 'ok' => true,
980 'done' => false,
981 'message' => sprintf( 'Deleted %d files (total: %d)', $deleted_batch, $deleted_total ),
982 'meta' => [
983 'deleted_total' => $deleted_total,
984 'last_id' => $last_id,
985 ],
986 'step' => $job['step'] + 1,
987 'step_name' => 'batch_' . ( $job['step'] + 1 ),
988 ];
989 }
990
991 // Completed
992 return [
993 'ok' => true,
994 'done' => true,
995 'message' => sprintf( 'Cleanup complete. Deleted %d files older than %d days', $deleted_total, $orphan_days ),
996 'meta' => [
997 'deleted_total' => 0,
998 'last_id' => 0,
999 ],
1000 ];
1001 }
1002 }
1003