PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.2.2
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.2.2
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 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 7 months ago wand.php 7 months ago
files.php
1028 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 $fileTypeCheck = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
778 if ( !$fileTypeCheck['type'] ) {
779 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid file type.' ], 400 );
780 }
781
782 try {
783 $refId = $this->upload_file( $file['tmp_name'], $file['name'], $purpose, $metadata, $envId );
784 $url = $this->get_url( $refId );
785 return new WP_REST_Response( [
786 'success' => true,
787 'data' => [ 'id' => $refId, 'url' => $url ]
788 ], 200 );
789 }
790 catch ( Exception $e ) {
791 return new WP_REST_Response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
792 }
793 }
794
795 #endregion
796
797 #region Database functions
798
799 public function create_db() {
800 $charset_collate = $this->wpdb->get_charset_collate();
801 $sql = "CREATE TABLE $this->table_files (
802 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
803 refId VARCHAR(64) NOT NULL,
804 envId VARCHAR(128) NULL,
805 userId VARCHAR(64) NULL,
806 type VARCHAR(32) NULL,
807 status VARCHAR(32) NULL,
808 purpose VARCHAR(32) NULL,
809 created DATETIME NOT NULL,
810 updated DATETIME NOT NULL,
811 expires DATETIME NULL,
812 path TEXT NULL,
813 url TEXT NULL,
814 PRIMARY KEY (id),
815 UNIQUE KEY unique_file_id (refId)
816 ) $charset_collate;";
817
818 $sqlFileMeta = "CREATE TABLE $this->table_filemeta (
819 meta_id BIGINT(20) NOT NULL AUTO_INCREMENT,
820 file_id BIGINT(20) NOT NULL,
821 meta_key varchar(255) NULL,
822 meta_value longtext NULL,
823 PRIMARY KEY (meta_id)
824 ) $charset_collate;";
825
826 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
827 dbDelta( $sql );
828 dbDelta( $sqlFileMeta );
829 }
830
831 public function check_db() {
832 if ( $this->db_check ) {
833 return true;
834 }
835
836 // Check if table_files exists
837 $sql = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_files );
838 $table_files_exists = strtolower( $this->wpdb->get_var( $sql ) ) === strtolower( $this->table_files );
839
840 // Check if table_filemeta exists
841 $sqlMeta = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_filemeta );
842 $table_filemeta_exists = strtolower( $this->wpdb->get_var( $sqlMeta ) ) === strtolower( $this->table_filemeta );
843
844 // If either table does not exist, create them
845 if ( !$table_files_exists || !$table_filemeta_exists ) {
846 $this->create_db();
847 }
848
849 // Update db_check for both tables
850 $this->db_check = $table_files_exists && $table_filemeta_exists;
851
852 // Check if userId column needs to be updated to VARCHAR for session support
853 // LATER: REMOVE THIS AFTER JANUARY 2026
854 if ( $this->db_check ) {
855 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_files WHERE Field = 'userId'" );
856 if ( $column_info && strpos( $column_info->Type, 'BIGINT' ) !== false ) {
857 // Update userId column from BIGINT to VARCHAR to support session-based IDs
858 $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN userId VARCHAR(64) NULL" );
859 }
860 }
861
862 // LATER: REMOVE THIS AFTER MARCH 2024
863 // $this->db_check = $this->db_check && $this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'userId'" );
864 // if ( !$this->db_check ) {
865 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN userId BIGINT(20) UNSIGNED NULL" );
866 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN purpose VARCHAR(32) NULL" );
867 // $this->wpdb->query( "ALTER TABLE $this->table_files MODIFY COLUMN path TEXT NULL" );
868 // $this->wpdb->query( "ALTER TABLE $this->table_files DROP COLUMN metadata" );
869 // $this->db_check = true;
870 // }
871 // // LATER: REMOVE THIS AFTER MARCH 2024
872 // $this->db_check = $this->db_check && !$this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'fileId'" );
873 // if ( !$this->db_check ) {
874 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN refId VARCHAR(64) NOT NULL" );
875 // $this->wpdb->query( "ALTER TABLE $this->table_files DROP COLUMN fileId" );
876 // $this->db_check = true;
877 // }
878 // // LATER: REMOVE THIS AFTER MARCH 2024
879 // $this->db_check = $this->db_check && $this->wpdb->get_var( "SHOW COLUMNS FROM $this->table_files LIKE 'envId'" );
880 // if ( !$this->db_check ) {
881 // $this->wpdb->query( "ALTER TABLE $this->table_files ADD COLUMN envId VARCHAR(128) NULL" );
882 // $this->db_check = true;
883 // }
884
885 return $this->db_check;
886 }
887
888 #endregion
889
890 /**
891 * Handle cleanup task for files
892 * Deletes files that have passed their expiration date
893 */
894 public function handle_cleanup_task( $result, $job ) {
895 $start = microtime( true );
896
897 // Check if files table exists
898 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_files}'" );
899 if ( !$table_exists ) {
900 return [
901 'ok' => true,
902 'done' => true,
903 'message' => 'Files table does not exist yet',
904 ];
905 }
906
907 // Get current progress
908 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
909 $deleted_attachments = isset( $job['meta']['deleted_attachments'] ) ? (int) $job['meta']['deleted_attachments'] : 0;
910 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
911 $processing_attachments = isset( $job['meta']['processing_attachments'] ) ? (bool) $job['meta']['processing_attachments'] : false;
912
913 $batch_size = 100;
914 $current_time = current_time( 'mysql' );
915
916 // First, process expired files from database
917 if ( !$processing_attachments ) {
918 $expired_files = $this->wpdb->get_results( $this->wpdb->prepare(
919 "SELECT * FROM {$this->table_files}
920 WHERE expires IS NOT NULL AND expires < %s AND id > %d
921 ORDER BY id ASC
922 LIMIT %d",
923 $current_time, $last_id, $batch_size
924 ) );
925
926 if ( !empty( $expired_files ) ) {
927 $fileRefs = [];
928 foreach ( $expired_files as $file ) {
929 $fileRefs[] = $file->refId;
930 }
931 $this->delete_expired_files( $fileRefs );
932
933 $deleted_total += count( $expired_files );
934 $last_id = end( $expired_files )->id;
935
936 $time_elapsed = microtime( true ) - $start;
937
938 // Continue processing files if we have more and time allows
939 if ( count( $expired_files ) === $batch_size && $time_elapsed < 8 ) {
940 return [
941 'ok' => true,
942 'done' => false,
943 'message' => sprintf( 'Deleted %d expired files (total: %d)', count( $expired_files ), $deleted_total ),
944 'meta' => [
945 'deleted_total' => $deleted_total,
946 'deleted_attachments' => $deleted_attachments,
947 'last_id' => $last_id,
948 'processing_attachments' => false,
949 ],
950 'step' => $job['step'] + 1,
951 'step_name' => 'batch_' . ( $job['step'] + 1 ),
952 ];
953 }
954
955 // Move to attachments processing
956 $processing_attachments = true;
957 $last_id = 0;
958 } else {
959 // No expired files, move to attachments
960 $processing_attachments = true;
961 }
962 }
963
964 // Process expired media library attachments
965 if ( $processing_attachments ) {
966 $expired_posts = get_posts( [
967 'post_type' => 'attachment',
968 'posts_per_page' => $batch_size,
969 'offset' => $last_id,
970 'meta_query' => [
971 [
972 'key' => '_mwai_file_expires',
973 'value' => $current_time,
974 'compare' => '<',
975 'type' => 'DATETIME'
976 ]
977 ]
978 ] );
979
980 if ( !empty( $expired_posts ) ) {
981 $fileRefs = [];
982 foreach ( $expired_posts as $post ) {
983 $fileRefs[] = get_post_meta( $post->ID, '_mwai_file_id', true );
984 }
985 $this->delete_expired_files( $fileRefs );
986
987 $deleted_attachments += count( $expired_posts );
988 $last_id += count( $expired_posts );
989
990 $time_elapsed = microtime( true ) - $start;
991
992 // Continue processing attachments if we have more and time allows
993 if ( count( $expired_posts ) === $batch_size && $time_elapsed < 8 ) {
994 return [
995 'ok' => true,
996 'done' => false,
997 'message' => sprintf( 'Deleted %d expired attachments (total: %d files, %d attachments)',
998 count( $expired_posts ), $deleted_total, $deleted_attachments ),
999 'meta' => [
1000 'deleted_total' => $deleted_total,
1001 'deleted_attachments' => $deleted_attachments,
1002 'last_id' => $last_id,
1003 'processing_attachments' => true,
1004 ],
1005 'step' => $job['step'] + 1,
1006 'step_name' => 'batch_' . ( $job['step'] + 1 ),
1007 ];
1008 }
1009 }
1010 }
1011
1012 // Completed
1013 $total_deleted = $deleted_total + $deleted_attachments;
1014 return [
1015 'ok' => true,
1016 'done' => true,
1017 'message' => sprintf( 'Cleanup complete. Deleted %d expired files (%d filesystem, %d media library)',
1018 $total_deleted, $deleted_total, $deleted_attachments ),
1019 'meta' => [
1020 'deleted_total' => 0,
1021 'deleted_attachments' => 0,
1022 'last_id' => 0,
1023 'processing_attachments' => false,
1024 ],
1025 ];
1026 }
1027 }
1028