'jpg', 'image/png' => 'png', 'image/webp' => 'webp', ); /** * The private directory for this site. * * @return string Absolute path, without a trailing slash. */ public static function dir() { $uploads = wp_upload_dir(); return rtrim( (string) $uploads['basedir'], '/\\' ) . '/mybot-private'; } /** * Creates the directory, and the two files that keep it private. * * @return true|WP_Error */ public static function ensure() { $dir = self::dir(); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return new WP_Error( 'mybot_attach_dir', __( 'The folder for visitor pictures could not be created on this server.', 'aihouse' ) ); } /* * Deny everything, both spellings: `Require` is Apache 2.4 and `Deny` is 2.2. The directives are * wrapped in `` so a host that does not know them does not answer 500 for the whole * directory — a broken `.htaccess` inside uploads can take a site down. */ $htaccess = $dir . '/.htaccess'; if ( ! file_exists( $htaccess ) ) { $rules = "# AI House: visitor pictures are served only through the plugin's own route.\n" . "\n\tRequire all denied\n\n" . "\n\tDeny from all\n\n"; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $htaccess, $rules ); } $index = $dir . '/index.php'; if ( ! file_exists( $index ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $index, " (int) ceil( self::MAX_BYTES * 4 / 3 ) + 4 ) { return new WP_Error( 'mybot_attach_size', __( 'That picture is too large to send.', 'aihouse' ) ); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- a picture to be measured, not code to be run. $binary = base64_decode( $matches[2], true ); if ( false === $binary || '' === $binary ) { return new WP_Error( 'mybot_attach_decode', __( 'That picture could not be read.', 'aihouse' ) ); } if ( strlen( $binary ) > self::MAX_BYTES ) { return new WP_Error( 'mybot_attach_size', __( 'That picture is too large to send.', 'aihouse' ) ); } /* * Ask the BYTES what they are, not the label that came with them. `getimagesizefromstring()` fails * on anything that is not a real image, which is the only check that matters for a file written to * the disk on the strength of a browser's word. */ $size = @getimagesizefromstring( $binary ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( ! is_array( $size ) || empty( $size[0] ) || empty( $size[1] ) ) { return new WP_Error( 'mybot_attach_not_image', __( 'That file is not a picture this plugin can read.', 'aihouse' ) ); } $ready = self::ensure(); if ( is_wp_error( $ready ) ) { return $ready; } try { $name = bin2hex( random_bytes( 16 ) ) . '.' . self::MIMES[ $mime ]; } catch ( Exception $e ) { // random_bytes needs a working source of randomness; without one there is no safe name. return new WP_Error( 'mybot_attach_random', __( 'This server could not produce a safe file name for the picture.', 'aihouse' ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $written = file_put_contents( self::dir() . '/' . $name, $binary, LOCK_EX ); if ( false === $written ) { return new WP_Error( 'mybot_attach_write', __( 'The picture could not be saved on this server.', 'aihouse' ) ); } return array( 'kind' => 'image', 'file' => $name, 'mime' => $mime, 'bytes' => strlen( $binary ), 'width' => (int) $size[0], 'height' => (int) $size[1], ); } /** * The absolute path of a stored file, or '' when the name is not one this class writes. * * The name is validated rather than trusted: it arrives from a database column that a filter or a * migration could have written, and `../` in it would reach the whole filesystem. * * @param string $file Stored file name. * @return string */ public static function path( $file ) { $file = (string) $file; if ( ! preg_match( '/^[a-f0-9]{32}\.(jpg|png|webp)$/', $file ) ) { return ''; } return self::dir() . '/' . $file; } /** * One attachment of a row, by position, with its record validated (1.14.0). * * The `index` a screen sends is a small integer that could be anything, so it is looked up rather * than used: an index that does not exist answers an empty array, and the file name it returns has * been through `path()` — which means it is a name this class writes, not a name somebody sent. * * @param string $json Stored value. * @param int $index Position in the row's list. * @return array The record, or array() when there is nothing at that position. */ public static function record( $json, $index ) { $records = self::from_json( $json ); $index = absint( $index ); if ( ! isset( $records[ $index ] ) || ! is_array( $records[ $index ] ) ) { return array(); } $record = $records[ $index ]; $file = isset( $record['file'] ) ? (string) $record['file'] : ''; if ( '' === self::path( $file ) ) { return array(); } return array( 'kind' => isset( $record['kind'] ) ? (string) $record['kind'] : 'image', 'file' => $file, 'mime' => isset( $record['mime'] ) ? (string) $record['mime'] : 'application/octet-stream', 'bytes' => isset( $record['bytes'] ) ? (int) $record['bytes'] : 0, 'width' => isset( $record['width'] ) ? (int) $record['width'] : 0, 'height' => isset( $record['height'] ) ? (int) $record['height'] : 0, ); } /** * Encodes records for the `attachments` column. * * @param array $records Records. * @return string JSON, or '' when there is nothing. */ public static function to_json( $records ) { $records = array_values( array_filter( (array) $records, 'is_array' ) ); if ( empty( $records ) ) { return ''; } return (string) wp_json_encode( $records ); } /** * Decodes the `attachments` column. * * @param string $json Stored value. * @return array */ public static function from_json( $json ) { $decoded = json_decode( (string) $json, true ); return is_array( $decoded ) ? array_values( array_filter( $decoded, 'is_array' ) ) : array(); } /** * Deletes the files behind one row's attachments. * * @param string $json Stored value. * @return int Number of files removed. */ public static function delete_json( $json ) { $removed = 0; foreach ( self::from_json( $json ) as $record ) { $path = self::path( isset( $record['file'] ) ? (string) $record['file'] : '' ); if ( '' !== $path && file_exists( $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink if ( unlink( $path ) ) { ++$removed; } } } return $removed; } /** * What the admin screens may show about one row's attachments — never the file name. * * The name is deliberately dropped: the only reader of a picture is the permission-checked route, and * a name that travels into a screen, a log line or the Supabase mirror is a locator that no longer * needs the permission check to be useful. * * @param string $json Stored value. * @return array */ public static function describe( $json ) { $out = array(); foreach ( self::from_json( $json ) as $index => $record ) { $out[] = array( 'index' => (int) $index, 'kind' => isset( $record['kind'] ) ? (string) $record['kind'] : 'image', 'mime' => isset( $record['mime'] ) ? (string) $record['mime'] : '', 'bytes' => isset( $record['bytes'] ) ? (int) $record['bytes'] : 0, 'width' => isset( $record['width'] ) ? (int) $record['width'] : 0, 'height' => isset( $record['height'] ) ? (int) $record['height'] : 0, ); } return $out; } }