*/ const MIMES = array( 'jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', ); /** * Longest image side kept, in pixels. * * @var int */ const MAX_SIDE = 2048; /** * Largest width or height accepted for upload, in pixels. * * @var int */ const MAX_INPUT_SIDE = 8000; /** * Most unused uploads a member may keep at a time. * * @var int */ const MAX_PENDING = 30; /** * Daily maintenance cron hook. Other components hook their cleanup here too. * * @var string */ const CLEANUP_HOOK = 'spacebay_daily'; /** * Cron hook that continues moving post images after the community access * setting changed. * * @var string */ const RESYNC_HOOK = 'spacebay_media_resync'; /** * Option with the IDs of files a move failed for; the daily cleanup retries them. * * @var string */ const MISPLACED_OPTION = 'spacebay_media_misplaced'; /** * Most failed moves remembered for a retry. * * @var int */ const MAX_MISPLACED = 1000; /** * Hooks the cleanup job. * * @return void */ public static function init() { add_action( self::CLEANUP_HOOK, array( __CLASS__, 'cleanup' ) ); add_action( self::RESYNC_HOOK, array( __CLASS__, 'resync_posts' ) ); add_action( 'spacebay_settings_updated', array( __CLASS__, 'settings_updated' ), 10, 2 ); add_action( 'init', array( __CLASS__, 'schedule' ) ); PrivateMedia::init(); } /** * Moves post images when the community access setting changes: with * "members only" the images of public spaces become private files. * * @param array $current New settings. * @param array $old Previous settings. * @return void */ public static function settings_updated( $current, $old ) { if ( ( $current['access'] ?? '' ) !== ( $old['access'] ?? '' ) ) { self::resync_posts( 0 ); } } /** * Places every post image again, in batches, starting after a media ID. * Stops after a few seconds and continues in a cron event. * * @param int $after_id Last media ID already handled. * @return void */ public static function resync_posts( $after_id = 0 ) { global $wpdb; $after_id = absint( $after_id ); $started = microtime( true ); while ( true ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, runs when the access setting changes. $wpdb->prepare( 'SELECT id, user_id, object_type, object_id, file_path, file_url, mime_type, file_size, settings, status FROM %i WHERE object_type = %s AND status = %s AND id > %d ORDER BY id ASC LIMIT 100', Tables::name( 'media' ), 'post', 'attached', $after_id ), ARRAY_A ); if ( ! $rows ) { return; } foreach ( (array) $rows as $row ) { self::place( $row ); $after_id = (int) $row['id']; } if ( microtime( true ) - $started > 10 ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::RESYNC_HOOK, array( $after_id ) ); return; } } } /** * Schedules the daily cleanup once. * * @return void */ public static function schedule() { if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CLEANUP_HOOK ); } } /** * Largest upload size in bytes. * * @return int */ public static function max_bytes() { $setting = max( 1, (int) Settings::get( 'max_upload_mb', 5 ) ) * MB_IN_BYTES; return (int) min( $setting, wp_max_upload_size() ); } /** * Saves an uploaded image for a user. * * @param array $file One entry of $_FILES. * @param int $user_id Uploader. * @param array|null $crop Optional [width, height] to crop to, for avatars and covers. * @return array|WP_Error Formatted media item. */ public static function upload( array $file, $user_id, $crop = null ) { $too_big = in_array( (int) ( $file['error'] ?? 0 ), array( UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE ), true ); if ( ! empty( $file['error'] ) && ! $too_big ) { return new WP_Error( 'spacebay_upload_failed', __( 'The upload failed. Please try again.', 'spacebay' ), array( 'status' => 400 ) ); } if ( $too_big || (int) $file['size'] > self::max_bytes() ) { return new WP_Error( 'spacebay_upload_too_big', sprintf( /* translators: %s: Maximum file size, for example "5 MB". */ __( 'Images can be up to %s.', 'spacebay' ), size_format( self::max_bytes() ) ), array( 'status' => 400 ) ); } if ( ! function_exists( 'wp_handle_upload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } // Every upload starts in the private folder: until it is used, only the // uploader may see it. attach() moves it out when its place is public. // The stored name is random; the member's own file name is never kept. add_filter( 'upload_dir', array( PrivateMedia::class, 'upload_dir' ) ); $file['name'] = self::stored_name( (string) $file['name'] ); $result = wp_handle_upload( $file, array( 'test_form' => false, 'mimes' => self::MIMES, ) ); remove_filter( 'upload_dir', array( PrivateMedia::class, 'upload_dir' ) ); if ( isset( $result['error'] ) ) { return new WP_Error( 'spacebay_upload_type', __( 'Please upload a JPG, PNG, GIF or WebP image.', 'spacebay' ), array( 'status' => 400 ) ); } $path = $result['file']; $size = self::shrink( $path, $crop ); if ( false === $size ) { wp_delete_file( $path ); return new WP_Error( 'spacebay_upload_dimensions', __( 'This image is too large to process. Please resize it and try again.', 'spacebay' ), array( 'status' => 400 ) ); } if ( ! $size ) { wp_delete_file( $path ); return new WP_Error( 'spacebay_upload_type', __( 'Please upload a JPG, PNG, GIF or WebP image.', 'spacebay' ), array( 'status' => 400 ) ); } global $wpdb; $uploads = wp_get_upload_dir(); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'media' ), array( 'user_id' => absint( $user_id ), 'file_path' => ltrim( str_replace( wp_normalize_path( $uploads['basedir'] ), '', wp_normalize_path( $path ) ), '/' ), // Private files have no direct address; see self::url(). 'file_url' => '', 'mime_type' => $result['type'], 'file_size' => (int) filesize( $path ), 'settings' => wp_json_encode( $size ), 'status' => 'pending', 'created_at' => Time::now(), ) ); return self::format( self::find( (int) $wpdb->insert_id ) ); } /** * File name an upload is stored under: random letters and the extension, * never the member's own file name (it can hold names, dates or places). * * @param string $original File name sent by the browser. * @return string */ public static function stored_name( $original ) { $extension = strtolower( (string) pathinfo( sanitize_file_name( $original ), PATHINFO_EXTENSION ) ); return strtolower( wp_generate_password( 20, false ) ) . ( '' !== $extension ? '.' . $extension : '' ); } /** * Sends uploads to uploads/spacebay/YYYY/MM. * * @param array $dirs Upload directory data. * @return array */ public static function upload_dir( $dirs ) { $sub = '/spacebay' . $dirs['subdir']; $dirs['path'] = $dirs['basedir'] . $sub; $dirs['url'] = $dirs['baseurl'] . $sub; $dirs['subdir'] = $sub; // An empty index file in every folder stops directory listing. foreach ( array_unique( array( $dirs['basedir'] . '/spacebay', dirname( $dirs['path'] ), $dirs['path'] ) ) as $folder ) { if ( wp_mkdir_p( $folder ) && ! file_exists( $folder . '/index.php' ) ) { file_put_contents( $folder . '/index.php', " self::MAX_INPUT_SIDE || $info[1] > self::MAX_INPUT_SIDE ) { return false; } $width = (int) $info[0]; $height = (int) $info[1]; // Decoding needs about 5 bytes per pixel; refuse images the server cannot handle. wp_raise_memory_limit( 'image' ); $limit = wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) ); if ( $limit > 0 && memory_get_usage() + ( $width * $height * 5 ) > $limit ) { return false; } if ( 'image/gif' === $info['mime'] && ! $crop ) { // GIFs are kept as uploaded to keep their animation, so they are // scanned for code or markup, such as image/HTML polyglots. $data = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local uploaded file, scanned for embedded code. if ( preg_match( '/<\?php|<\s*(script|html|head|body|iframe|svg|object|embed|meta)\b/i', $data ) ) { return null; } return array( 'width' => $width, 'height' => $height, ); } add_filter( 'wp_image_editors', array( __CLASS__, 'image_editors' ), PHP_INT_MAX, 0 ); $editor = wp_get_image_editor( $path ); remove_filter( 'wp_image_editors', array( __CLASS__, 'image_editors' ), PHP_INT_MAX ); if ( is_wp_error( $editor ) ) { return null; } // Turn the picture upright first: the orientation tag goes with the metadata. $editor->maybe_exif_rotate(); if ( $crop ) { $editor->resize( (int) $crop[0], (int) $crop[1], true ); } elseif ( max( $width, $height ) > self::MAX_SIDE ) { $editor->resize( self::MAX_SIDE, self::MAX_SIDE ); } $saved = $editor->save( $path ); if ( is_wp_error( $saved ) ) { return null; } return array( 'width' => (int) $saved['width'], 'height' => (int) $saved['height'], ); } /** * The image editors used for member uploads (filter for wp_image_editors, * added only while an upload is processed): the metadata-stripping Imagick * editor, then GD, which never writes metadata. Editors added by other * plugins are left out on purpose, because they might keep the metadata. * * @return string[] */ public static function image_editors() { return array( ImagickStripEditor::class, 'WP_Image_Editor_GD' ); } /** * Finds a media row. * * @param int $id Media ID. * @return array|null */ public static function find( $id ) { global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, single row by primary key. $wpdb->prepare( 'SELECT id, user_id, object_type, object_id, file_path, file_url, mime_type, file_size, settings, status FROM %i WHERE id = %d', Tables::name( 'media' ), absint( $id ) ), ARRAY_A ); return $row ? $row : null; } /** * Media attached to objects, keyed by object ID. * * @param string $object_type Object type, for example "post". * @param int[] $object_ids Object IDs. * @return array */ public static function for_objects( $object_type, array $object_ids ) { global $wpdb; $object_ids = array_values( array_filter( array_map( 'absint', $object_ids ) ) ); if ( ! $object_ids ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $object_ids ), '%d' ) ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, one query per feed page. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN() list has one %d per ID. "SELECT id, user_id, object_type, object_id, file_path, file_url, mime_type, file_size, settings, status FROM %i WHERE object_type = %s AND object_id IN ($placeholders) AND status = 'attached' ORDER BY id ASC", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Placeholders are built from %d only. array_merge( array( Tables::name( 'media' ), $object_type ), $object_ids ) ), ARRAY_A ); $map = array(); foreach ( (array) $rows as $row ) { $map[ (int) $row['object_id'] ][] = self::format( $row ); } return $map; } /** * Attaches pending uploads of a user to an object. * * Only the uploader's own pending images can be attached. * * @param int[] $ids Media IDs, in display order. * @param int $user_id Uploader. * @param string $object_type Object type. * @param int $object_id Object ID. * @return int[] IDs that were attached. */ public static function attach( array $ids, $user_id, $object_type, $object_id ) { global $wpdb; $attached = array(); foreach ( array_slice( array_unique( array_map( 'absint', $ids ) ), 0, 10 ) as $id ) { $row = self::find( $id ); $is_own = $row && (int) $row['user_id'] === (int) $user_id; $is_reusable = $row && ( 'pending' === $row['status'] || ( $row['object_type'] === $object_type && (int) $row['object_id'] === (int) $object_id ) ); if ( ! $is_own || ! $is_reusable ) { continue; } $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'media' ), array( 'object_type' => $object_type, 'object_id' => absint( $object_id ), 'status' => 'attached', ), array( 'id' => $id ) ); self::place( self::find( $id ) ); $attached[] = $id; } return $attached; } /** * Whether files of an object may only be seen by the people who can read it. * * Private messages and posts in spaces that are not public (private, secret * or archived) are private, and so are all posts when the community is for * members only (visitors see nothing). Profile photos, covers and posts in * public spaces of an open community are public files. * * @param string $object_type Object type. * @param int $object_id Object ID. * @return bool */ public static function is_private_object( $object_type, $object_id ) { if ( 'message' === $object_type ) { $private = true; } elseif ( 'post' === $object_type ) { $post = \SpaceBay\Feed\Posts::find( (int) $object_id ); $space = $post ? \SpaceBay\Spaces\SpaceRepository::find( (int) $post['space_id'] ) : null; $private = ! $space || 'public' !== $space['privacy'] || 'published' !== $space['status'] || 'members' === Settings::get( 'access' ); } else { $private = false; } /** * Filters whether images attached to an object are private files, * served only to people allowed to read the object. * * @since 1.0.0 * * @param bool $private Whether the files are private. * @param string $object_type Object type, for example "post" or "message". * @param int $object_id Object ID. */ return (bool) apply_filters( 'spacebay_media_is_private', $private, $object_type, (int) $object_id ); } /** * Moves a file to the folder its object needs: the private folder for * pending uploads and private objects, uploads/spacebay/ for the rest. * * @param array|null $row Media row. * @return bool Whether the file is where it should be. */ public static function place( $row ) { if ( ! $row ) { return false; } $private = 'pending' === $row['status'] || self::is_private_object( (string) $row['object_type'], (int) $row['object_id'] ); if ( PrivateMedia::is_private_path( $row['file_path'] ) === $private ) { return true; } $from = self::absolute_path( $row ); $tail = PrivateMedia::tail( $row['file_path'] ); if ( ! $from || '' === $tail || ! file_exists( $from ) ) { self::log_failed_move( $row, $private, 'the file is missing' ); return false; } $uploads = wp_get_upload_dir(); $root = $private ? PrivateMedia::root() : 'spacebay'; $to = wp_normalize_path( trailingslashit( $uploads['basedir'] ) . $root . '/' . $tail ); $private ? PrivateMedia::protect( dirname( $to ) ) : self::protect_public( dirname( $to ) ); $moved = @rename( $from, $to ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Moves a file inside uploads; a failure falls back to copy and delete. if ( ! $moved && @copy( $from, $to ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failure is logged below. wp_delete_file( $from ); $moved = ! file_exists( $from ); if ( ! $moved ) { // The old file stays and the row keeps pointing at it; the copy // must not stay behind, least of all in the public folder. wp_delete_file( $to ); } } if ( ! $moved ) { self::log_failed_move( $row, $private, 'the move or its cleanup failed' ); self::remember_misplaced( (int) $row['id'] ); return false; } global $wpdb; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'media' ), array( 'file_path' => $root . '/' . $tail, 'file_url' => $private ? '' : trailingslashit( $uploads['baseurl'] ) . 'spacebay/' . $tail, ), array( 'id' => (int) $row['id'] ) ); return true; } /** * Moves the images of a space's posts after its privacy or status changed. * * @param int $space_id Space ID. * @return void */ public static function sync_space( $space_id ) { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom tables, runs when a space changes privacy. $wpdb->prepare( 'SELECT m.id, m.user_id, m.object_type, m.object_id, m.file_path, m.file_url, m.mime_type, m.file_size, m.settings, m.status FROM %i m INNER JOIN %i p ON p.id = m.object_id WHERE m.object_type = %s AND p.space_id = %d', Tables::name( 'media' ), Tables::name( 'posts' ), 'post', absint( $space_id ) ), ARRAY_A ); foreach ( (array) $rows as $row ) { self::place( $row ); } } /** * Writes one line to the PHP error log about a file that could not be * moved. Only the media ID and the path inside the SpaceBay folder are * logged, never the private folder's name or the server's paths. * * @param array $row Media row. * @param bool $to_private Whether the file should be private. * @param string $reason Short reason. * @return void */ private static function log_failed_move( array $row, $to_private, $reason ) { error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- A failed move leaves a file in the wrong place; the site owner needs to know. sprintf( 'SpaceBay: could not move image %d (%s) to the %s folder: %s.', (int) $row['id'], PrivateMedia::tail( (string) $row['file_path'] ), $to_private ? 'private' : 'public', $reason ) ); } /** * Remembers a file whose move failed, so the daily cleanup tries again. * * @param int $id Media ID. * @return void */ private static function remember_misplaced( $id ) { $ids = array_map( 'absint', (array) get_option( self::MISPLACED_OPTION, array() ) ); if ( in_array( $id, $ids, true ) || count( $ids ) >= self::MAX_MISPLACED ) { return; } $ids[] = $id; update_option( self::MISPLACED_OPTION, $ids, false ); } /** * Tries again to move files whose earlier move failed. Files still in the * wrong place stay on the list for the next day. * * @return void */ public static function retry_misplaced() { $ids = array_map( 'absint', (array) get_option( self::MISPLACED_OPTION, array() ) ); if ( ! $ids ) { return; } // Moves that fail again add themselves back through place(). delete_option( self::MISPLACED_OPTION ); foreach ( $ids as $id ) { $row = self::find( $id ); if ( $row && 'attached' === $row['status'] ) { self::place( $row ); } } } /** * Absolute path of a media file, or an empty string when the stored path * points outside SpaceBay's folders. * * @param array $row Row with file_path. * @return string */ public static function absolute_path( array $row ) { $relative = (string) $row['file_path']; if ( '' === $relative || false !== strpos( $relative, '..' ) ) { return ''; } $uploads = wp_get_upload_dir(); $base = wp_normalize_path( trailingslashit( $uploads['basedir'] ) ); $file = wp_normalize_path( $base . $relative ); foreach ( array( 'spacebay/', PrivateMedia::root() . '/' ) as $root ) { if ( 0 === strpos( $file, $base . $root ) ) { return $file; } } return ''; } /** * Address of a media file. Private files are only reachable through the * checked address (?spacebay_media=ID), never directly. * * @param array $row Row with id, file_path and file_url. * @return string */ public static function url( array $row ) { if ( PrivateMedia::is_private_path( (string) $row['file_path'] ) ) { return PrivateMedia::url( (int) $row['id'] ); } return esc_url_raw( set_url_scheme( (string) $row['file_url'] ) ); } /** * Adds empty index files to a public folder to stop directory listing. * * @param string $folder Folder inside uploads/spacebay/. * @return void */ private static function protect_public( $folder ) { $uploads = wp_get_upload_dir(); $base = wp_normalize_path( trailingslashit( $uploads['basedir'] ) . 'spacebay' ); $folder = wp_normalize_path( $folder ); while ( 0 === strpos( $folder, $base ) ) { if ( wp_mkdir_p( $folder ) && ! file_exists( $folder . '/index.php' ) ) { file_put_contents( $folder . '/index.php', "get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->prepare( 'SELECT id, file_path FROM %i WHERE object_type = %s AND object_id = %d', Tables::name( 'media' ), $object_type, absint( $object_id ) ), ARRAY_A ); foreach ( (array) $rows as $row ) { if ( in_array( (int) $row['id'], array_map( 'intval', $keep ), true ) ) { continue; } self::delete_row( $row ); } } /** * Deletes every upload of a user. * * @param int $user_id User ID. * @return void */ public static function delete_for_user( $user_id ) { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->prepare( 'SELECT id, file_path FROM %i WHERE user_id = %d', Tables::name( 'media' ), absint( $user_id ) ), ARRAY_A ); foreach ( (array) $rows as $row ) { self::delete_row( $row ); } } /** * Retries failed moves and removes pending uploads older than a day. * * @return void */ public static function cleanup() { global $wpdb; self::retry_misplaced(); // Batches of 200, up to 10,000 files a day. for ( $batch = 0; $batch < 50; $batch++ ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, daily cron. $wpdb->prepare( 'SELECT id, file_path FROM %i WHERE status = %s AND created_at < %s LIMIT 200', Tables::name( 'media' ), 'pending', gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) ), ARRAY_A ); if ( ! $rows ) { return; } foreach ( (array) $rows as $row ) { self::delete_row( $row ); } } } /** * Number of uploaded images a member has not used in a post yet. * * @param int $user_id User ID. * @return int */ public static function pending_count( $user_id ) { global $wpdb; return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fresh count before an upload. $wpdb->prepare( 'SELECT COUNT(id) FROM %i WHERE user_id = %d AND status = %s', Tables::name( 'media' ), absint( $user_id ), 'pending' ) ); } /** * Deletes one file and its row. * * @param array $row Row with id and file_path. * @return void */ private static function delete_row( array $row ) { global $wpdb; // Only ever delete inside uploads/spacebay/ and the private folder. $file = self::absolute_path( $row ); if ( '' !== $file ) { wp_delete_file( $file ); } $wpdb->delete( Tables::name( 'media' ), array( 'id' => (int) $row['id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } /** * Formats a media row for the REST API. * * @param array|null $row Media row. * @return array */ public static function format( $row ) { if ( ! $row ) { return array(); } $size = json_decode( (string) $row['settings'], true ); return array( 'id' => (int) $row['id'], 'url' => self::url( $row ), 'mime' => $row['mime_type'], 'width' => isset( $size['width'] ) ? (int) $size['width'] : 0, 'height' => isset( $size['height'] ) ? (int) $size['height'] : 0, ); } }