` rules do nothing on nginx, * the same limitation WordPress's own `uploads/.htaccess` carries. So * what actually makes this directory safe is that the renderer cannot * be made to emit anything but inert SVG. Worth knowing before putting * a different kind of file in here: a new file type does not inherit * that guarantee, and the .htaccess alone will not cover it. * * **Known limitation, multisite.** `wp_users` and `wp_usermeta` are * network-wide but `wp_get_upload_dir()` is per-site, so an agent * created on site A has no face file on site B and degrades to the * shipped robot there. That is a graceful fallback rather than a * breakage, and fixing it properly means deciding whether faces belong * to the network. * * @package OpenStation */ defined( 'ABSPATH' ) || exit; /** * Size the stored portrait is rendered at. * * The SVG scales, so this only sets the intrinsic size an `img` with no * dimensions falls back to. 96 matches what `get_avatar()` asks for. */ const OPENSTATION_AGENT_FACE_SIZE = 96; /** * Absolute path to the face directory. * * The directory name keeps the frozen `desktop-mode-` prefix its * siblings use: it is a path on live filesystems the moment it ships. * * @return string Absolute path, no trailing slash. */ function openstation_agent_faces_dir() { $uploads = wp_get_upload_dir(); $base = trailingslashit( $uploads['basedir'] ) . 'desktop-mode-agent-faces'; /** * Filters the agent-face storage directory. * * Whatever this points at must be web-servable: the portraits are * loaded by the browser as avatars. * * @param string $base Absolute path, no trailing slash. */ return (string) apply_filters( 'openstation_agent_faces_base_dir', $base ); } /** * Base URL of the face directory. * * @return string Absolute URL, no trailing slash. */ function openstation_agent_faces_url() { $uploads = wp_get_upload_dir(); $url = untrailingslashit( $uploads['baseurl'] ) . '/desktop-mode-agent-faces'; /** * Filters the agent-face base URL. Must resolve to the same bytes * `openstation_agent_faces_base_dir` points at. * * @param string $url Absolute URL, no trailing slash. */ return (string) apply_filters( 'openstation_agent_faces_base_url', $url ); } /** * Create the face directory and harden it. * * Exec-off, not deny-all: the portraits must stay servable. * * @return string|WP_Error Absolute path, or an error. */ function openstation_agent_faces_ensure_dir() { $base = openstation_agent_faces_dir(); if ( ! wp_mkdir_p( $base ) ) { return new WP_Error( 'openstation_agent_faces_mkdir_failed', __( 'Could not create the agent-faces directory.', 'desktop-mode' ), array( 'status' => 500 ) ); } $index = $base . '/index.php'; if ( ! file_exists( $index ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $index, "\n\tphp_flag engine off\n\n" . "\n\tphp_flag engine off\n\n" . "\n" . "\t\n\t\tRequire all denied\n\t\n" . "\t\n\t\tOrder deny,allow\n\t\tDeny from all\n\t\n" . "\n"; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $htaccess, $rules ); } return $base; } /** * The filename an agent's current face would have. * * The hash is of the stored look, so a shuffled face lands on a new * filename and every cache that held the old one is bypassed without a * query string. * * @param int $user_id Agent user id. * @return string Filename, or '' when the agent has no face. */ function openstation_agent_face_filename( $user_id ) { $raw = (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_FACE_META, true ); if ( '' === $raw ) { return ''; } return (int) $user_id . '-' . substr( md5( $raw ), 0, 8 ) . '.svg'; } /** * URL of an agent's face file, if it has been written. * * Pure: never writes, never renders. A missing file returns '' and the * caller falls back to the shipped robot, which is also what happens on * hosts that refuse to serve SVG from uploads. * * @param int $user_id Agent user id. * @return string URL, or '' when there is no face on disk. */ function openstation_agent_face_url( $user_id ) { $file = openstation_agent_face_filename( $user_id ); if ( '' === $file ) { return ''; } if ( ! file_exists( openstation_agent_faces_dir() . '/' . $file ) ) { return ''; } return openstation_agent_faces_url() . '/' . $file; } /** * Render an agent's face and write it to disk. * * Idempotent: a face whose file already exists is left alone. Stale * files for the same agent are removed, so an admin shuffling a face * ten times leaves one file behind rather than ten. * * @param int $user_id Agent user id. * @return string|WP_Error Absolute path written (or already present), * '' when the agent has no face, or an error. */ function openstation_agent_face_write( $user_id ) { $user_id = (int) $user_id; $file = openstation_agent_face_filename( $user_id ); if ( '' === $file ) { openstation_agent_face_delete( $user_id ); return ''; } $base = openstation_agent_faces_ensure_dir(); if ( is_wp_error( $base ) ) { return $base; } $path = $base . '/' . $file; if ( file_exists( $path ) ) { return $path; } $look = openstation_mio_clamp_look( openstation_agent_get_face( $user_id ) ); $svg = openstation_mio_portrait_svg( $look, OPENSTATION_AGENT_FACE_SIZE ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $written = file_put_contents( $path, $svg ); if ( false === $written ) { return new WP_Error( 'openstation_agent_face_write_failed', __( 'Could not write the agent face.', 'desktop-mode' ), array( 'status' => 500 ) ); } openstation_agent_face_delete( $user_id, $file ); return $path; } /** * Remove an agent's face files. * * @param int $user_id Agent user id. * @param string $keep Filename to leave in place, if any. * @return void */ function openstation_agent_face_delete( $user_id, $keep = '' ) { $base = openstation_agent_faces_dir(); if ( ! is_dir( $base ) ) { return; } $found = glob( $base . '/' . (int) $user_id . '-*.svg' ); if ( ! is_array( $found ) ) { return; } foreach ( $found as $path ) { if ( '' !== $keep && basename( $path ) === $keep ) { continue; } wp_delete_file( $path ); } } /** * Keep the file in step with the meta. * * @param int $user_id Agent user id. * @param array $changed Map of field => { from, to }. * @return void */ function openstation_agent_face_sync_on_update( $user_id, $changed ) { if ( ! is_array( $changed ) || ! array_key_exists( 'face', $changed ) ) { return; } openstation_agent_face_write( $user_id ); } add_action( 'openstation_agent_updated', 'openstation_agent_face_sync_on_update', 10, 2 ); /** * Write the face for a freshly created agent. * * @param int $user_id Agent user id. * @return void */ function openstation_agent_face_sync_on_create( $user_id ) { openstation_agent_face_write( $user_id ); } add_action( 'openstation_agent_created', 'openstation_agent_face_sync_on_create', 10, 1 ); /** * Clean up when an agent is deleted. * * @param int $user_id Agent user id. * @return void */ function openstation_agent_face_cleanup( $user_id ) { openstation_agent_face_delete( $user_id ); } add_action( 'openstation_agent_deleted', 'openstation_agent_face_cleanup', 10, 1 );