/ 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;
}
}