Skip to content

Commit b565a65

Browse files
committed
Fix high priority issues
1 parent e7dbee2 commit b565a65

1 file changed

Lines changed: 134 additions & 111 deletions

File tree

dt-posts/dt-posts-endpoints.php

Lines changed: 134 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)