base_dir(); if ( ! wp_mkdir_p( $dir ) ) { return false; } $files = array( '.htaccess' => implode( "\n", array( '# Primiko: photos of the quote requests, never served directly.', '', "\tRequire all denied", '', '', "\tOrder allow,deny", "\tDeny from all", '', '# In case the rules above are ever lifted: no listing, no scripts.', 'Options -Indexes -ExecCGI', '', "\tphp_flag engine off", '', '', "\tphp_flag engine off", '', '', "\tSetHandler none", "\tForceType text/plain", '', '', ) ), 'index.php' => "filesystem(); foreach ( $files as $name => $content ) { $path = $dir . '/' . $name; // Rewritten when a new version of the plugin changes the rules. if ( file_exists( $path ) && $filesystem && $content === $filesystem->get_contents( $path ) ) { continue; } if ( ! $filesystem || ! $filesystem->put_contents( $path, $content, FS_CHMOD_FILE ) ) { return false; } } return true; } /** * Saves the photos of a request in its own folder, under random names, * each one decoded and encoded again as a JPEG. * * @param int $request_id Request post ID. * @param array[] $files Upload arrays, already checked. * @return string[]|\WP_Error Paths relative to the private folder. */ public function store( $request_id, $files ) { if ( ! $this->ensure() ) { return new \WP_Error( 'primiko_folder', 'The private folder cannot be created.' ); } $folder = $request_id . '-' . strtolower( wp_generate_password( 16, false ) ); $dir = $this->base_dir() . '/' . $folder; if ( ! wp_mkdir_p( $dir ) ) { return new \WP_Error( 'primiko_folder', 'The request folder cannot be created.' ); } require_once ABSPATH . 'wp-admin/includes/file.php'; $redirect = static function ( $uploads ) use ( $dir ) { $uploads['path'] = $dir; $uploads['url'] = ''; $uploads['subdir'] = ''; $uploads['error'] = false; return $uploads; }; // wp_handle_upload() checks the real type of the file, not only its // name, against this list. $overrides = array( 'test_form' => false, 'mimes' => array( 'jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', ), 'unique_filename_callback' => static function () { return self::random_name( '.upload.jpg' ); }, ); $stored = array(); add_filter( 'upload_dir', $redirect ); foreach ( $files as $file ) { $moved = wp_handle_upload( $file, $overrides ); if ( isset( $moved['error'] ) || empty( $moved['file'] ) ) { break; } $clean = $this->reencode( $moved['file'], $dir ); // The uploaded file is never kept, whatever happened. wp_delete_file( $moved['file'] ); if ( '' === $clean ) { break; } $stored[] = $folder . '/' . $clean; } remove_filter( 'upload_dir', $redirect ); if ( count( $stored ) !== count( $files ) ) { $this->delete_folder( $dir ); return new \WP_Error( 'primiko_photo', 'A photo could not be saved.' ); } return $stored; } /** * Decodes an uploaded image and saves its pixels as a new JPEG, turned * the right way up, no larger than MAX_SIDE, without any metadata. * * Refuses the image when it cannot be decoded: an image that cannot be * cleaned is not kept. * * @param string $source Uploaded file. * @param string $dir Folder of the request. * @return string File name of the clean image, empty on failure. */ private function reencode( $source, $dir ) { $size = wp_getimagesize( $source ); if ( ! $size || empty( $size[0] ) || empty( $size[1] ) || $size[0] * $size[1] > self::MAX_PIXELS ) { return ''; } // GD rebuilds the image from its pixels and drops every metadata. $prefer_gd = static function ( $editors ) { return array_unique( array_merge( array( 'WP_Image_Editor_GD' ), (array) $editors ) ); }; add_filter( 'wp_image_editors', $prefer_gd ); $editor = wp_get_image_editor( $source, array( 'mime_type' => $size['mime'] ) ); remove_filter( 'wp_image_editors', $prefer_gd ); if ( is_wp_error( $editor ) ) { return ''; } $editor->maybe_exif_rotate(); $current = $editor->get_size(); $longest = max( $current['width'], $current['height'] ); // Imagick only drops the metadata while resizing, so the image is // always resized with it, by a pixel when it is already small enough. if ( $longest > self::MAX_SIDE ) { $resized = $editor->resize( self::MAX_SIDE, self::MAX_SIDE, false ); } elseif ( ! $editor instanceof \WP_Image_Editor_GD ) { $resized = $editor->resize( $longest - 1, $longest - 1, false ); } else { $resized = true; } if ( is_wp_error( $resized ) ) { return ''; } $editor->set_quality( 82 ); $saved = $editor->save( $dir . '/' . self::random_name( '.jpg' ), 'image/jpeg' ); if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! wp_getimagesize( $saved['path'] ) ) { return ''; } return basename( $saved['path'] ); } /** * Random file name that cannot be guessed. * * @param string $suffix Extension, with its dot. * @return string */ private static function random_name( $suffix ) { return strtolower( wp_generate_password( 24, false ) ) . $suffix; } /** * Returns the absolute path of a stored photo, only if it lies inside * the private folder. * * @param string $relative Path relative to the private folder. * @return string Empty when the file is missing or outside the folder. */ public function path( $relative ) { $base = realpath( $this->base_dir() ); $path = realpath( $this->base_dir() . '/' . $relative ); if ( ! $base || ! $path || 0 !== strpos( $path, $base . DIRECTORY_SEPARATOR ) || ! is_file( $path ) ) { return ''; } return $path; } /** * Deletes the photos of a request when the request is deleted for good. * * @param int $post_id Post ID. */ public function delete_request_photos( $post_id ) { if ( Post_Types::REQUEST !== get_post_type( $post_id ) ) { return; } $folders = array(); foreach ( (array) get_post_meta( $post_id, '_primiko_photos', true ) as $relative ) { $path = $this->path( (string) $relative ); if ( '' !== $path ) { $folders[ dirname( $path ) ] = true; } } foreach ( array_keys( $folders ) as $folder ) { $this->delete_folder( $folder ); } } /** * Deletes a request folder and what it holds. * * @param string $dir Absolute path, inside the private folder. */ private function delete_folder( $dir ) { $filesystem = $this->filesystem(); $base = realpath( $this->base_dir() ); $real = realpath( $dir ); if ( $filesystem && $base && $real && 0 === strpos( $real, $base . DIRECTORY_SEPARATOR ) ) { $filesystem->delete( $real, true ); } } /** * Returns the direct filesystem, or null when WordPress would need * credentials to write files. * * @return \WP_Filesystem_Base|null */ private function filesystem() { global $wp_filesystem; require_once ABSPATH . 'wp-admin/includes/file.php'; if ( ! WP_Filesystem() || 'direct' !== $wp_filesystem->method ) { return null; } return $wp_filesystem; } }