@@ -947,6 +947,11 @@ public function storage_upload( WP_REST_Request $request ) {
947947 break ;
948948 }
949949
950+ // Load field settings for server-side validation of file type and size.
951+ $ field_settings = DT_Posts::get_post_field_settings ( $ post_type );
952+ $ allowed_types = $ field_settings [ $ meta_key ]['accepted_file_types ' ] ?? [];
953+ $ max_file_size_mb = $ field_settings [ $ meta_key ]['max_file_size ' ] ?? null ;
954+
950955 // Process all uploaded files.
951956 $ uploaded_files = [];
952957 $ uploaded_keys = [];
@@ -966,7 +971,59 @@ public function storage_upload( WP_REST_Request $request ) {
966971 $ safe_file_name = str_replace ( [ "\r" , "\n" ], '' , (string ) $ uploaded_file ['name ' ] );
967972 $ safe_file_type = sanitize_mime_type ( (string ) $ uploaded_file ['type ' ] );
968973 if ( $ safe_file_type === '' ) {
969- $ safe_file_type = (string ) $ uploaded_file ['type ' ];
974+ $ safe_file_type = 'application/octet-stream ' ;
975+ }
976+
977+ // Server-side file size validation.
978+ if ( !empty ( $ max_file_size_mb ) && $ uploaded_file ['size ' ] > ( (float ) $ max_file_size_mb * 1024 * 1024 ) ) {
979+ $ uploaded_files [] = [
980+ 'uploaded ' => false ,
981+ 'uploaded_key ' => '' ,
982+ 'name ' => $ uploaded_file ['name ' ],
983+ 'uploaded_msg ' => sprintf ( 'File "%s" exceeds the maximum allowed size of %s MB. ' , $ safe_file_name , $ max_file_size_mb ),
984+ ];
985+ continue ;
986+ }
987+
988+ // Server-side file type validation.
989+ if ( !empty ( $ allowed_types ) ) {
990+ $ file_ext_check = wp_check_filetype ( $ safe_file_name );
991+ $ detected_type = !empty ( $ file_ext_check ['type ' ] ) ? $ file_ext_check ['type ' ] : $ safe_file_type ;
992+ $ type_allowed = false ;
993+ foreach ( $ allowed_types as $ allowed ) {
994+ $ allowed = trim ( $ allowed );
995+ // Match wildcard patterns like "image/*" or "audio/*".
996+ if ( str_ends_with ( $ allowed , '/* ' ) ) {
997+ $ prefix = substr ( $ allowed , 0 , -1 );
998+ if ( strpos ( $ detected_type , $ prefix ) === 0 ) {
999+ $ type_allowed = true ;
1000+ break ;
1001+ }
1002+ } elseif ( strpos ( $ allowed , '/ ' ) !== false ) {
1003+ // Exact MIME match.
1004+ if ( $ detected_type === $ allowed ) {
1005+ $ type_allowed = true ;
1006+ break ;
1007+ }
1008+ } else {
1009+ // Extension match (e.g. ".docx").
1010+ $ allowed_ext = ltrim ( $ allowed , '. ' );
1011+ $ file_ext = strtolower ( pathinfo ( $ safe_file_name , PATHINFO_EXTENSION ) );
1012+ if ( $ file_ext === strtolower ( $ allowed_ext ) ) {
1013+ $ type_allowed = true ;
1014+ break ;
1015+ }
1016+ }
1017+ }
1018+ if ( !$ type_allowed ) {
1019+ $ uploaded_files [] = [
1020+ 'uploaded ' => false ,
1021+ 'uploaded_key ' => '' ,
1022+ 'name ' => $ uploaded_file ['name ' ],
1023+ 'uploaded_msg ' => sprintf ( 'File type "%s" is not allowed for this field. ' , $ detected_type ),
1024+ ];
1025+ continue ;
1026+ }
9701027 }
9711028
9721029 // For multi-file fields, don't reuse keys (always create new)
@@ -1181,6 +1238,7 @@ public function storage_delete_single( WP_REST_Request $request ) {
11811238 // Handle array of files (multi-file field).
11821239 if ( is_array ( $ meta_key_value ) ) {
11831240 $ file_found = false ;
1241+ $ s3_deleted = false ;
11841242 $ updated_files = [];
11851243 $ deleted_file_name = '' ;
11861244
@@ -1202,6 +1260,7 @@ public function storage_delete_single( WP_REST_Request $request ) {
12021260 $ result = DT_Storage_API::delete_file ( $ file_key );
12031261 if ( $ result && isset ( $ result ['file_deleted ' ] ) && $ result ['file_deleted ' ] ) {
12041262 // File deleted successfully, don't add it back to array.
1263+ $ s3_deleted = true ;
12051264 continue ;
12061265 }
12071266 }
@@ -1210,48 +1269,56 @@ public function storage_delete_single( WP_REST_Request $request ) {
12101269 $ updated_files [] = $ file_object ;
12111270 }
12121271
1213- if ( $ file_found ) {
1214- // Store old value for activity logging
1215- $ old_meta_value = $ meta_key_value ;
1216-
1217- // Update post meta with remaining files.
1218- if ( !empty ( $ updated_files ) ) {
1219- update_post_meta ( $ post_id , $ meta_key , $ updated_files );
1220- $ new_meta_value = $ updated_files ;
1221- } else {
1222- // No files left, delete meta key.
1223- delete_post_meta ( $ post_id , $ meta_key );
1224- $ new_meta_value = '' ;
1225- }
1226-
1227- // Log activity for file deletion
1228- $ post_settings = DT_Posts::get_post_settings ( $ post_type );
1229- $ field_name = $ post_settings ['fields ' ][ $ meta_key ]['name ' ] ?? $ meta_key ;
1230- $ object_note = sprintf ( _x ( 'Deleted file: %1$s from %2$s ' , 'file_upload activity ' , 'disciple_tools ' ), $ deleted_file_name , $ field_name );
1231-
1232- dt_activity_insert ( [
1233- 'action ' => 'field_update ' ,
1234- 'object_type ' => $ post_type ,
1235- 'object_id ' => $ post_id ,
1236- 'object_name ' => get_the_title ( $ post_id ),
1237- 'meta_key ' => $ meta_key ,
1238- 'meta_value ' => maybe_serialize ( $ new_meta_value ),
1239- 'old_value ' => maybe_serialize ( $ old_meta_value ),
1240- 'field_type ' => 'file_upload ' ,
1241- 'object_note ' => $ object_note ,
1242- ] );
1243-
1272+ if ( !$ file_found ) {
12441273 return [
1245- 'deleted ' => true ,
1246- 'deleted_key ' => $ file_key_to_delete
1274+ 'deleted ' => false ,
1275+ 'deleted_key ' => '' ,
1276+ 'error ' => 'File not found in field. '
12471277 ];
1248- } else {
1278+ }
1279+
1280+ if ( !$ s3_deleted ) {
12491281 return [
12501282 'deleted ' => false ,
12511283 'deleted_key ' => '' ,
1252- 'error ' => 'File not found in field . '
1284+ 'error ' => 'Failed to delete file from storage . '
12531285 ];
12541286 }
1287+
1288+ // Store old value for activity logging
1289+ $ old_meta_value = $ meta_key_value ;
1290+
1291+ // Update post meta with remaining files.
1292+ if ( !empty ( $ updated_files ) ) {
1293+ update_post_meta ( $ post_id , $ meta_key , $ updated_files );
1294+ $ new_meta_value = $ updated_files ;
1295+ } else {
1296+ // No files left, delete meta key.
1297+ delete_post_meta ( $ post_id , $ meta_key );
1298+ $ new_meta_value = '' ;
1299+ }
1300+
1301+ // Log activity for file deletion
1302+ $ post_settings = DT_Posts::get_post_settings ( $ post_type );
1303+ $ field_name = $ post_settings ['fields ' ][ $ meta_key ]['name ' ] ?? $ meta_key ;
1304+ $ object_note = sprintf ( _x ( 'Deleted file: %1$s from %2$s ' , 'file_upload activity ' , 'disciple_tools ' ), $ deleted_file_name , $ field_name );
1305+
1306+ dt_activity_insert ( [
1307+ 'action ' => 'field_update ' ,
1308+ 'object_type ' => $ post_type ,
1309+ 'object_id ' => $ post_id ,
1310+ 'object_name ' => get_the_title ( $ post_id ),
1311+ 'meta_key ' => $ meta_key ,
1312+ 'meta_value ' => maybe_serialize ( $ new_meta_value ),
1313+ 'old_value ' => maybe_serialize ( $ old_meta_value ),
1314+ 'field_type ' => 'file_upload ' ,
1315+ 'object_note ' => $ object_note ,
1316+ ] );
1317+
1318+ return [
1319+ 'deleted ' => true ,
1320+ 'deleted_key ' => $ file_key_to_delete
1321+ ];
12551322 } else {
12561323 // Single file format (backward compatibility).
12571324 if ( $ meta_key_value === $ file_key_to_delete ) {
@@ -1302,9 +1369,9 @@ public function storage_rename_single( WP_REST_Request $request ) {
13021369 return new WP_Error ( __METHOD__ , 'Missing parameters. ' );
13031370 }
13041371
1305- $ post_type = $ params ['post_type ' ];
1306- $ post_id = $ params ['id ' ];
1307- $ meta_key = sanitize_text_field ( wp_unslash ( $ params ['meta_key ' ] ) );
1372+ $ post_type = sanitize_text_field ( wp_unslash ( $ params ['post_type ' ] ) ) ;
1373+ $ post_id = absint ( $ params ['id ' ] ) ;
1374+ $ meta_key = sanitize_text_field ( wp_unslash ( $ params ['meta_key ' ] ) );
13081375 $ file_key_to_rename = sanitize_text_field ( wp_unslash ( $ params ['file_key ' ] ) );
13091376 $ new_name = trim ( sanitize_file_name ( wp_unslash ( $ params ['new_name ' ] ) ) );
13101377
@@ -1409,9 +1476,9 @@ public function storage_download( WP_REST_Request $request ) {
14091476 }
14101477
14111478 $ post_type = sanitize_text_field ( wp_unslash ( $ params ['post_type ' ] ) );
1412- $ post_id = sanitize_text_field ( wp_unslash ( $ params ['id ' ] ) );
1413- $ meta_key = sanitize_text_field ( wp_unslash ( $ params ['meta_key ' ] ) );
1414- $ file_key = sanitize_text_field ( wp_unslash ( $ params ['file_key ' ] ) );
1479+ $ post_id = absint ( $ params ['id ' ] );
1480+ $ meta_key = sanitize_text_field ( wp_unslash ( $ params ['meta_key ' ] ) );
1481+ $ file_key = sanitize_text_field ( wp_unslash ( $ params ['file_key ' ] ) );
14151482
14161483 // Verify file exists in post meta and extract file info
14171484 $ meta_key_value = get_post_meta ( $ post_id , $ meta_key , true );
@@ -1448,87 +1515,43 @@ public function storage_download( WP_REST_Request $request ) {
14481515 return new WP_Error ( __METHOD__ , 'File not found in post meta. ' );
14491516 }
14501517
1451- // Generate presigned URL
1518+ // Generate presigned URL for download.
14521519 $ presigned_url = DT_Storage_API::get_file_url ( $ file_key );
14531520
14541521 if ( empty ( $ presigned_url ) ) {
14551522 return new WP_Error ( __METHOD__ , 'Failed to generate download URL. ' );
14561523 }
14571524
1458- // Fetch file from S3 (server-side, no CORS)
1459- // Note: Do not use 'stream' => true as it tries to use URL as filename, causing "File name too long" errors
1460- $ response = wp_remote_get ( $ presigned_url , [
1461- 'timeout ' => 300 , // 5 minutes for large files
1462- 'redirection ' => 5 ,
1463- ] );
1464-
1465- if ( is_wp_error ( $ response ) ) {
1466- return new WP_Error ( __METHOD__ , 'Failed to fetch file from storage: ' . $ response ->get_error_message () );
1525+ // Determine content type from metadata or fallback to octet-stream.
1526+ $ content_type = !empty ( $ file_type ) ? sanitize_mime_type ( $ file_type ) : '' ;
1527+ if ( empty ( $ content_type ) ) {
1528+ $ ext_check = wp_check_filetype ( $ file_name );
1529+ $ content_type = !empty ( $ ext_check ['type ' ] ) ? $ ext_check ['type ' ] : 'application/octet-stream ' ;
14671530 }
14681531
1469- $ response_code = wp_remote_retrieve_response_code ( $ response );
1470- if ( $ response_code !== 200 ) {
1471- return new WP_Error ( __METHOD__ , 'Failed to fetch file from storage. Response code: ' . $ response_code );
1472- }
1473-
1474- // Get file content
1475- $ file_content = wp_remote_retrieve_body ( $ response );
1476-
1477- // Determine content type with priority:
1478- // 1. From metadata (file_type variable)
1479- // 2. From S3 response header
1480- // 3. From file extension (fallback)
1481- $ content_type = 'application/octet-stream ' ; // Default fallback
1532+ // Stream file from S3 through PHP to the client. This avoids CORS issues
1533+ // (the web component fetches this endpoint via fetch(), which would fail on
1534+ // a cross-origin redirect to S3). Using fpassthru() streams data without
1535+ // buffering the entire file in PHP memory.
1536+ wp_raise_memory_limit ( 'admin ' );
1537+ $ download_name = sanitize_file_name ( $ file_name );
1538+ header ( 'Content-Type: ' . $ content_type );
1539+ header ( 'Content-Disposition: attachment; filename=" ' . $ download_name . '" ' );
14821540
1483- if ( !empty ( $ file_type ) ) {
1484- // Priority 1: Use type from metadata database
1485- $ content_type = $ file_type ;
1541+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
1542+ $ stream = fopen ( $ presigned_url , 'r ' );
1543+ if ( $ stream ) {
1544+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fpassthru
1545+ fpassthru ( $ stream );
1546+ fclose ( $ stream );
14861547 } else {
1487- // Priority 2: Try S3 response header
1488- $ content_type = wp_remote_retrieve_header ( $ response , 'content-type ' );
1489-
1490- if ( empty ( $ content_type ) ) {
1491- // Priority 3: Extension-based detection
1492- $ file_ext = strtolower ( pathinfo ( $ file_name , PATHINFO_EXTENSION ) );
1493- $ mime_types = [
1494- 'pdf ' => 'application/pdf ' ,
1495- 'doc ' => 'application/msword ' ,
1496- 'docx ' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document ' ,
1497- 'txt ' => 'text/plain ' ,
1498- 'csv ' => 'text/csv ' ,
1499- 'json ' => 'application/json ' ,
1500- 'xml ' => 'application/xml ' ,
1501- 'html ' => 'text/html ' ,
1502- 'htm ' => 'text/html ' ,
1503- 'jpg ' => 'image/jpeg ' ,
1504- 'jpeg ' => 'image/jpeg ' ,
1505- 'png ' => 'image/png ' ,
1506- 'gif ' => 'image/gif ' ,
1507- ];
1508- $ content_type = isset ( $ mime_types [ $ file_ext ] )
1509- ? $ mime_types [ $ file_ext ]
1510- : 'application/octet-stream ' ;
1548+ // Fallback: buffer via WP HTTP API if fopen wrappers are disabled.
1549+ $ response = wp_remote_get ( $ presigned_url , [ 'timeout ' => 300 ] );
1550+ if ( is_wp_error ( $ response ) || wp_remote_retrieve_response_code ( $ response ) !== 200 ) {
1551+ return new WP_Error ( __METHOD__ , 'Failed to fetch file from storage. ' );
15111552 }
1553+ echo wp_remote_retrieve_body ( $ response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
15121554 }
1513-
1514- // Sanitize Content-Type to prevent HTTP header injection (e.g. newlines from S3 or meta).
1515- $ content_type = sanitize_mime_type ( $ content_type );
1516- if ( $ content_type === '' ) {
1517- $ content_type = 'application/octet-stream ' ;
1518- }
1519-
1520- // Set headers for file download
1521- header ( 'Content-Type: ' . $ content_type );
1522- $ download_name = str_replace ( [ "\r" , "\n" ], '' , (string ) $ file_name );
1523- header ( 'Content-Disposition: attachment; filename=" ' . $ download_name . '" ' );
1524- header ( 'Content-Length: ' . strlen ( $ file_content ) );
1525- header ( 'Cache-Control: no-cache, must-revalidate ' );
1526- header ( 'Pragma: no-cache ' );
1527-
1528- // Output file content
1529- echo $ file_content ; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1530-
1531- // Exit to prevent REST API wrapper from interfering
15321555 exit ;
15331556 }
15341557
0 commit comments