/ and serves them only to people who * may read what they belong to. * * The folder holds an .htaccess (Apache 2.2 and 2.4), a web.config (IIS) and * index.php files. nginx ignores those files, so the folder name is random and * never shown; the readme explains the one-line nginx rule that closes it. * * Images are served from any front-end address with ?spacebay_media=ID. The * login cookie identifies the viewer (an cannot send a REST nonce; a * read-only image needs none). Anyone not allowed gets "404 Not Found", so the * address does not reveal that the image exists. */ final class PrivateMedia { /** * Query variable of the checked address. * * @var string */ const QUERY_VAR = 'spacebay_media'; /** * Option with the random name of the private folder. * * @var string */ const FOLDER_OPTION = 'spacebay_private_media_dir'; /** * Folder in uploads that holds the random folder. * * @var string */ const BASE = 'spacebay-private'; /** * How long a browser may reuse an image without asking again, in seconds. * * @var int */ const MAX_AGE = 3600; /** * Hooks the checked address. * * @return void */ public static function init() { // Late on init: the user is known and other plugins are loaded; no query or template runs. add_action( 'init', array( __CLASS__, 'maybe_serve' ), 999 ); } /** * Path of the private folder relative to uploads: "spacebay-private/<32 hex>". * * @return string */ public static function root() { $name = (string) get_option( self::FOLDER_OPTION, '' ); if ( ! preg_match( '/^[a-f0-9]{32}$/', $name ) ) { $name = self::create_folder_name(); } return self::BASE . '/' . $name; } /** * Stores a random name for the private folder unless another request * stored one first, and returns the stored name. * * Two first uploads at the same moment must agree on one folder. * add_option() is not enough: its INSERT ... ON DUPLICATE KEY UPDATE lets * the later request overwrite the name. INSERT IGNORE keeps the first one, * and the name is read back from the database, not from the cache. * * @return string */ private static function create_folder_name() { global $wpdb; $candidate = bin2hex( random_bytes( 16 ) ); $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Race-free first write of one option; caches are cleared below. $wpdb->prepare( 'INSERT IGNORE INTO %i (option_name, option_value, autoload) VALUES (%s, %s, %s)', $wpdb->options, self::FOLDER_OPTION, $candidate, 'yes' ) ); wp_cache_delete( self::FOLDER_OPTION, 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); $stored = (string) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads back the value that won, past any cache. $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, self::FOLDER_OPTION ) ); if ( preg_match( '/^[a-f0-9]{32}$/', $stored ) ) { return $stored; } // A damaged stored value: replace it. update_option( self::FOLDER_OPTION, $candidate, true ); return $candidate; } /** * Whether a stored file path is in the private folder. * * @param string $file_path Path relative to uploads. * @return bool */ public static function is_private_path( $file_path ) { return 0 === strpos( (string) $file_path, self::BASE . '/' ); } /** * The part of a stored path after the SpaceBay folder, for example "2026/10/abc.jpg". * * @param string $file_path Path relative to uploads. * @return string */ public static function tail( $file_path ) { $file_path = (string) $file_path; if ( self::is_private_path( $file_path ) ) { // spacebay-private//tail. $parts = explode( '/', $file_path, 3 ); return $parts[2] ?? ''; } return 0 === strpos( $file_path, 'spacebay/' ) ? substr( $file_path, strlen( 'spacebay/' ) ) : ''; } /** * Checked address of a private image. * * @param int $id Media ID. * @return string */ public static function url( $id ) { return add_query_arg( self::QUERY_VAR, absint( $id ), home_url( '/' ) ); } /** * Sends uploads to the private folder (filter for upload_dir). * * @param array $dirs Upload directory data. * @return array */ public static function upload_dir( $dirs ) { $sub = '/' . self::root() . $dirs['subdir']; $dirs['path'] = $dirs['basedir'] . $sub; $dirs['url'] = $dirs['baseurl'] . $sub; $dirs['subdir'] = $sub; // Pass the base folder: asking WordPress for it here would run this filter again. self::protect( $dirs['path'], $dirs['basedir'] ); return $dirs; } /** * Creates a private folder with the files that stop web servers from * serving or listing it, from uploads/spacebay-private/ down. * * @param string $folder Folder inside the private folder. * @param string $basedir Uploads folder; looked up when empty. * @return void */ public static function protect( $folder, $basedir = '' ) { if ( '' === $basedir ) { $uploads = wp_get_upload_dir(); $basedir = $uploads['basedir']; } $base = wp_normalize_path( trailingslashit( $basedir ) . self::BASE ); $folder = wp_normalize_path( $folder ); if ( 0 !== strpos( $folder, $base ) || ! wp_mkdir_p( $folder ) ) { return; } $files = array( '.htaccess' => "# SpaceBay: private member images, served only through WordPress.\n\n\tRequire all denied\n\n\n\tOrder deny,allow\n\tDeny from all\n\n", 'web.config' => "\n\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n", ); foreach ( $files as $name => $content ) { if ( ! file_exists( $base . '/' . $name ) ) { file_put_contents( $base . '/' . $name, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- One-time server rules that deny direct access. } } // An empty index file in every folder stops directory listing. while ( 0 === strpos( $folder, $base ) ) { if ( ! file_exists( $folder . '/index.php' ) ) { file_put_contents( $folder . '/index.php', " 0 && (int) $row['user_id'] === $user_id; } elseif ( 'message' === $type ) { $allowed = self::can_view_message( $id, $user_id ); } elseif ( 'post' === $type ) { $allowed = self::can_view_post( $id, $user_id ); } else { $allowed = ! self::is_private_path( (string) $row['file_path'] ); } /** * Filters whether a viewer may see a private image. * * @since 1.0.0 * * @param bool $allowed Whether the viewer may see it. * @param array $row Media row. * @param int $user_id Viewer, 0 for a visitor. */ return (bool) apply_filters( 'spacebay_media_can_view', $allowed, $row, $user_id ); } /** * Whether a user may see the images of a message. * * @param int $message_id Message ID. * @param int $user_id Viewer. * @return bool */ private static function can_view_message( $message_id, $user_id ) { if ( ! $user_id ) { return false; } $message = Messages::find( $message_id ); if ( ! $message || 'published' !== $message['status'] ) { return false; } if ( ! SpaceAccess::is_restricted( $user_id ) && Messages::visible_to( $message, $user_id ) ) { return true; } // Staff read private messages only when someone reported them. if ( ! user_can( $user_id, Capabilities::MANAGE ) && ! user_can( $user_id, Capabilities::MODERATE ) ) { return false; } global $wpdb; return (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, one check per image. $wpdb->prepare( 'SELECT id FROM %i WHERE object_type = %s AND object_id = %d LIMIT 1', Tables::name( 'reports' ), 'message', absint( $message_id ) ) ); } /** * Whether a user may see the images of a post. * * @param int $post_id Post ID. * @param int $user_id Viewer, 0 for a visitor. * @return bool */ private static function can_view_post( $post_id, $user_id ) { $post = Posts::find( $post_id ); $space = $post ? SpaceRepository::find( (int) $post['space_id'] ) : null; if ( ! $post || ! $space ) { return false; } if ( SpaceAccess::can_moderate( $space, $user_id ) ) { return true; } if ( ! SpaceAccess::can_read( $space, $user_id ) ) { return false; } return 'published' === $post['status'] || ( $user_id > 0 && (int) $post['user_id'] === $user_id ); } /** * Decides what the checked address answers, without sending anything. * * @param int $id Media ID. * @param int $user_id Viewer, 0 for a visitor. * @return array{row: array, path: string, mime: string}|WP_Error 404 error when the viewer may not see it. */ public static function resolve( $id, $user_id ) { $not_found = new WP_Error( 'spacebay_media_not_found', __( 'Image not found.', 'spacebay' ), array( 'status' => 404 ) ); $row = Media::find( absint( $id ) ); if ( ! $row || ! self::is_private_path( (string) $row['file_path'] ) ) { return $not_found; } $mime = (string) $row['mime_type']; if ( ! in_array( $mime, Media::MIMES, true ) || ! self::can_view( $row, $user_id ) ) { return $not_found; } $path = Media::absolute_path( $row ); if ( '' === $path || ! is_file( $path ) || ! is_readable( $path ) ) { return $not_found; } return array( 'row' => $row, 'path' => $path, 'mime' => $mime, ); } /** * Response headers for a private image. * * @param array $found Result of resolve(). * @return array */ public static function headers( array $found ) { $extension = (string) pathinfo( $found['path'], PATHINFO_EXTENSION ); return array( 'Content-Type' => $found['mime'], 'Content-Length' => (string) filesize( $found['path'] ), 'Content-Disposition' => 'inline; filename="image' . ( '' !== $extension ? '.' . sanitize_key( $extension ) : '' ) . '"', 'Cache-Control' => 'private, max-age=' . self::MAX_AGE, 'ETag' => self::etag( $found ), 'Last-Modified' => gmdate( 'D, d M Y H:i:s', (int) filemtime( $found['path'] ) ) . ' GMT', 'Vary' => 'Cookie', 'X-Content-Type-Options' => 'nosniff', 'X-Robots-Tag' => 'noindex, nofollow', 'Content-Security-Policy' => "default-src 'none'; sandbox", // Other sites may not load the image with the viewer's login cookie. 'Cross-Origin-Resource-Policy' => 'same-origin', ); } /** * Entity tag of a private image. * * @param array $found Result of resolve(). * @return string */ public static function etag( array $found ) { return '"' . md5( $found['row']['id'] . '|' . filesize( $found['path'] ) . '|' . filemtime( $found['path'] ) ) . '"'; } /** * Whether the browser's cached copy is still the same file. * * @param array $found Result of resolve(). * @param string $if_none_match If-None-Match request header. * @return bool */ public static function not_modified( array $found, $if_none_match ) { $etag = self::etag( $found ); foreach ( explode( ',', (string) $if_none_match ) as $candidate ) { $candidate = trim( preg_replace( '/^W\//', '', trim( $candidate ) ) ); if ( $candidate === $etag || '*' === $candidate ) { return true; } } return false; } /** * Answers ?spacebay_media=ID on the front end and stops. * * @return void */ public static function maybe_serve() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only image request; access is checked against the logged-in user. if ( ! isset( $_GET[ self::QUERY_VAR ] ) || is_admin() || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } $id = absint( wp_unslash( $_GET[ self::QUERY_VAR ] ) ); // phpcs:enable $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; $found = in_array( $method, array( 'GET', 'HEAD' ), true ) ? self::resolve( $id, get_current_user_id() ) : new WP_Error( 'spacebay_media_not_found', '', array( 'status' => 404 ) ); // No output from other code may reach the image. while ( ob_get_level() > 0 ) { ob_end_clean(); } if ( is_wp_error( $found ) ) { nocache_headers(); status_header( 404 ); header( 'X-Robots-Tag: noindex, nofollow' ); header( 'Content-Type: text/plain; charset=utf-8' ); echo esc_html__( 'Image not found.', 'spacebay' ); exit; } $headers = self::headers( $found ); $match = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : ''; if ( '' !== $match && self::not_modified( $found, $match ) ) { status_header( 304 ); foreach ( array( 'Cache-Control', 'ETag', 'Vary', 'X-Robots-Tag', 'Cross-Origin-Resource-Policy' ) as $name ) { header( $name . ': ' . $headers[ $name ] ); } exit; } status_header( 200 ); foreach ( $headers as $name => $value ) { header( $name . ': ' . $value ); } if ( 'HEAD' !== $method ) { readfile( $found['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streams a checked image file. } exit; } }