): 2025/shiftora is a user's own. $head = self::data_folder_segment( $segments ); if ( null !== $head ) { return sprintf( '`%s` is inside a folder Shiftora keeps for itself, which is never written.', $head ); } if ( str_contains( $raw, ':' ) ) { return sprintf( '`%s` contains a colon, which is never written into uploads.', $raw ); } // Windows hosts drop trailing dots and spaces, so `x.php.` is `x.php` there. Both spellings are checked. foreach ( array_unique( [ $raw, rtrim( $raw, '. ' ) ] ) as $name ) { if ( '' === $name ) { continue; } if ( in_array( $name, self::CONFIG_NAMES, true ) || str_ends_with( $name, '.user.ini' ) ) { return sprintf( '`%s` is a server configuration file, which is never written into uploads.', $name ); } $parts = explode( '.', $name ); array_shift( $parts ); foreach ( $parts as $extension ) { if ( in_array( $extension, self::EXECUTABLE_EXTENSIONS, true ) ) { return sprintf( '`%s` has an executable extension (.%s), which is never written into uploads.', $name, $extension ); } } } return null; } /** * The segment that names one of Shiftora's own folders at the uploads root, or null. On a network the * root of a site is sites/, so the segment after it counts the same way. * * @param string[] $segments Path segments, relative to the uploads base. */ private static function data_folder_segment( array $segments ): ?string { $index = 0; if ( isset( $segments[2] ) && 'sites' === self::fold( $segments[0] ) && ctype_digit( $segments[1] ) ) { $index = 2; } $segment = (string) ( $segments[ $index ] ?? '' ); // A colon here can name a stream of the folder, and "~" an 8.3 short name for it. if ( '' !== $segment && ( self::matches_reserved_folder( $segment ) || ( [] !== $segments && ( str_contains( $segment, ':' ) || 1 === preg_match( '/~\d/', $segment ) ) && count( $segments ) > $index + 1 ) ) ) { return $segment; } return null; } /** * Whether a folder name is one of Shiftora's own, on any filesystem: a peer has no use for `SHIFTORA`, * so the comparison ignores case, Unicode case folds (the long s, the Kelvin sign) and the dots and * spaces Windows drops. */ public static function matches_reserved_folder( string $segment ): bool { return in_array( self::fold( rtrim( $segment, '. ' ) ), DataDirectory::reserved_folders(), true ); } private static function fold( string $name ): string { $name = function_exists( 'mb_strtolower' ) ? mb_strtolower( $name, 'UTF-8' ) : strtolower( $name ); // U+017F and U+212A lower-case to themselves yet compare equal to s and k under full case folding. return str_replace( [ "\u{017F}", "\u{212A}" ], [ 's', 'k' ], $name ); } /** * Splits a manifest into the files that may travel and the paths that may not. * * @param array $manifest * * @return array{kept: array, refused: string[]} */ public static function partition( array $manifest ): array { $kept = []; $refused = []; foreach ( $manifest as $entry ) { $path = (string) ( $entry['path'] ?? '' ); if ( null === self::refused_name( $path ) ) { $kept[] = $entry; } else { $refused[] = $path; } } return [ 'kept' => $kept, 'refused' => $refused, ]; } /** * The uploads folder as the filesystem sees it, read from WordPress on every call. Empty when it cannot be resolved. */ public static function root(): string { $uploads = wp_upload_dir(); $base = (string) ( $uploads['basedir'] ?? '' ); $real = '' === $base ? false : realpath( $base ); return false === $real ? '' : rtrim( $real, DIRECTORY_SEPARATOR ); } }