prepare(). // phpcs:disable WordPress.DB.DirectDatabaseQuery /** * Port of StorageHelper from J!DMS 2.3.3. * * InnoDB cannot extend a BLOB in place: every CONCAT rewrites the whole file so far and leaves the old copy as * free space in the table file. A file is therefore written in one piece whenever max_allowed_packet and the PHP * memory allow it, otherwise in blocks that are as large as possible. A file can never be larger than * max_allowed_packet (see max_file_size()), because the CONCAT result and the download are bound to it as well. */ final class DropDMS_Storage { /** Room for the SQL text and protocol overhead in the packet. */ const PACKET_RESERVE = 65536; /** Smallest block (multiple of 3, so base64 blocks fit together). */ const MIN_CHUNK = 1572864; /** Largest block; larger blocks hardly help but cost memory. */ const MAX_CHUNK = 16777215; /** * Writes the file into the (already created, empty) row of $id. * * @param int $id Document ID. * @param string $path Path of the uploaded file. * @param int $size File size in bytes. * @return int Number of write operations (1 = in one piece). * @throws RuntimeException When the file cannot be read. */ public static function write_file( $id, $path, $size ) { $packet = self::max_packet(); $single = (int) ceil( $size / 3 ) * 4 + self::PACKET_RESERVE <= $packet && self::memory_allows( $size ); if ( $single ) { $data = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file. if ( false === $data ) { throw new RuntimeException( esc_html__( 'The file could not be read.', 'dropdms' ) ); } $b64 = base64_encode( $data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- binary-safe transport into the BLOB. unset( $data ); self::store( $id, $b64, false ); return 1; } $chunk = self::chunk_size( $packet ); $handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming a local temp file. if ( false === $handle ) { throw new RuntimeException( esc_html__( 'The file could not be read.', 'dropdms' ) ); } $writes = 0; try { while ( ! feof( $handle ) ) { $part = fread( $handle, $chunk ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( false === $part || '' === $part ) { break; } self::store( $id, base64_encode( $part ), $writes > 0 ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode ++$writes; } } finally { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } return $writes; } /** * Stores the JPEG thumbnail of a document. * * @param int $id Document ID. * @param string $binary JPEG data. */ public static function store_thumb( $id, $binary ) { global $wpdb; $wpdb->query( $wpdb->prepare( 'UPDATE %i SET thumb = FROM_BASE64(%s) WHERE document_id = %d', DropDMS_Installer::data_table(), base64_encode( $binary ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode $id ) ); } /** * Largest storable file: the CONCAT result must not exceed max_allowed_packet either, and the download needs * the whole row to fit into one packet. */ public static function max_file_size() { return max( 0, self::max_packet() - self::PACKET_RESERVE ); } /** * Block size for the server: as large as possible, multiple of 3. * * @param int $packet max_allowed_packet in bytes. */ public static function chunk_size( $packet ) { $bytes = (int) floor( ( $packet - self::PACKET_RESERVE ) / 4 ) * 3; $bytes = min( self::MAX_CHUNK, max( self::MIN_CHUNK, $bytes ) ); return $bytes - $bytes % 3; } /** Value of max_allowed_packet (cached per request). */ public static function max_packet() { global $wpdb; static $packet = null; if ( null === $packet ) { $packet = (int) $wpdb->get_var( 'SELECT @@max_allowed_packet' ); // Unknown: assume the smallest common value (MySQL 5.x: 4 MB). if ( $packet <= 0 ) { $packet = 4194304; } } return $packet; } /** * Usage of the data table in bytes: content (files + thumbnails) and allocated space according to * information_schema (data, indexes, free areas). * * @return array{content: int, allocated: int, free: int} */ public static function usage() { global $wpdb; $table = DropDMS_Installer::data_table(); $content = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COALESCE(SUM(LENGTH(data)), 0) + COALESCE(SUM(LENGTH(thumb)), 0) FROM %i', $table ) ); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT COALESCE(data_length, 0) + COALESCE(index_length, 0) AS used, COALESCE(data_free, 0) AS free FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s', $table ) ); return array( 'content' => $content, 'allocated' => (int) ( $row->used ?? 0 ) + (int) ( $row->free ?? 0 ), 'free' => (int) ( $row->free ?? 0 ), ); } /** * Rebuilds the table and returns free space to the file system (OPTIMIZE TABLE). * * @throws RuntimeException When the database reports an error. */ public static function optimize() { global $wpdb; // OPTIMIZE returns a result table; errors are reported there, not as a failed query. $rows = $wpdb->get_results( $wpdb->prepare( 'OPTIMIZE TABLE %i', DropDMS_Installer::data_table() ) ); if ( $wpdb->last_error ) { throw new RuntimeException( esc_html( $wpdb->last_error ) ); } foreach ( (array) $rows as $row ) { if ( 'error' === strtolower( (string) ( $row->Msg_type ?? '' ) ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- MySQL column name. throw new RuntimeException( esc_html( (string) ( $row->Msg_text ?? '' ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase } } } /** * Writes (or appends) one base64 block. * * @param int $id Document ID. * @param string $b64 Base64 data. * @param bool $append Append to the existing content. * @throws RuntimeException When the query fails. */ private static function store( $id, $b64, $append ) { global $wpdb; $table = DropDMS_Installer::data_table(); $sql = $append ? $wpdb->prepare( 'UPDATE %i SET data = CONCAT(data, FROM_BASE64(%s)) WHERE document_id = %d', $table, $b64, $id ) : $wpdb->prepare( 'UPDATE %i SET data = FROM_BASE64(%s) WHERE document_id = %d', $table, $b64, $id ); unset( $b64 ); if ( false === $wpdb->query( $sql ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared above. throw new RuntimeException( esc_html( $wpdb->last_error ) ); } } /** * Is there enough PHP memory for file + base64 + the prepared query (+ reserve for driver copies)? * * @param int $size File size in bytes. */ private static function memory_allows( $size ) { $limit = wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) ); if ( $limit <= 0 ) { return true; } return memory_get_usage() + $size * 6 < $limit; } }