PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / agents / face.php

face.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.9, at includes/agents/face.php

279 lines 9.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Agents: faces on disk.
4 *
5 * An agent's face is a Mio look in user meta. This turns that look into
6 * a file, because the thing that has to consume it is `get_avatar()`,
7 * and `get_avatar()` wants a URL.
8 *
9 * **Why a file rather than a REST route.** Both would satisfy the
10 * "must be a real URL" constraint that `openstation_agent_avatar_url()`
11 * documents. A file wins on the read path: a busy post rendering forty
12 * comment avatars costs zero PHP, the web server serves static bytes,
13 * and the content hash in the filename gives cache-busting for free. A
14 * route would run the whole renderer per avatar per request.
15 *
16 * So the write is the interesting half, and it happens on save:
17 * `openstation_agent_created` and `openstation_agent_updated`. The read
18 * is pure and never writes: a write inside `pre_get_avatar_data` would
19 * be a write during a front-end GET.
20 *
21 * **The files are SVG in uploads, which is only safe because of what
22 * the renderer refuses to emit.** `openstation_mio_portrait_svg()`
23 * writes numbers and a fixed vocabulary of elements: no text nodes, no
24 * caller-supplied string anywhere. The directory is hardened exec-off
25 * rather than deny-all, because unlike a theme's PHP these files have
26 * to stay servable.
27 *
28 * **The .htaccess is the second line, not the first.** It is Apache
29 * only: the `php_flag` and `<FilesMatch>` rules do nothing on nginx,
30 * the same limitation WordPress's own `uploads/.htaccess` carries. So
31 * what actually makes this directory safe is that the renderer cannot
32 * be made to emit anything but inert SVG. Worth knowing before putting
33 * a different kind of file in here: a new file type does not inherit
34 * that guarantee, and the .htaccess alone will not cover it.
35 *
36 * **Known limitation, multisite.** `wp_users` and `wp_usermeta` are
37 * network-wide but `wp_get_upload_dir()` is per-site, so an agent
38 * created on site A has no face file on site B and degrades to the
39 * shipped robot there. That is a graceful fallback rather than a
40 * breakage, and fixing it properly means deciding whether faces belong
41 * to the network.
42 *
43 * @package OpenStation
44 */
45
46 defined( 'ABSPATH' ) || exit;
47
48 /**
49 * Size the stored portrait is rendered at.
50 *
51 * The SVG scales, so this only sets the intrinsic size an `img` with no
52 * dimensions falls back to. 96 matches what `get_avatar()` asks for.
53 */
54 const OPENSTATION_AGENT_FACE_SIZE = 96;
55
56 /**
57 * Absolute path to the face directory.
58 *
59 * The directory name keeps the frozen `desktop-mode-` prefix its
60 * siblings use: it is a path on live filesystems the moment it ships.
61 *
62 * @return string Absolute path, no trailing slash.
63 */
64 function openstation_agent_faces_dir() {
65 $uploads = wp_get_upload_dir();
66 $base = trailingslashit( $uploads['basedir'] ) . 'desktop-mode-agent-faces';
67 /**
68 * Filters the agent-face storage directory.
69 *
70 * Whatever this points at must be web-servable: the portraits are
71 * loaded by the browser as avatars.
72 *
73 * @param string $base Absolute path, no trailing slash.
74 */
75 return (string) apply_filters( 'openstation_agent_faces_base_dir', $base );
76 }
77
78 /**
79 * Base URL of the face directory.
80 *
81 * @return string Absolute URL, no trailing slash.
82 */
83 function openstation_agent_faces_url() {
84 $uploads = wp_get_upload_dir();
85 $url = untrailingslashit( $uploads['baseurl'] ) . '/desktop-mode-agent-faces';
86 /**
87 * Filters the agent-face base URL. Must resolve to the same bytes
88 * `openstation_agent_faces_base_dir` points at.
89 *
90 * @param string $url Absolute URL, no trailing slash.
91 */
92 return (string) apply_filters( 'openstation_agent_faces_base_url', $url );
93 }
94
95 /**
96 * Create the face directory and harden it.
97 *
98 * Exec-off, not deny-all: the portraits must stay servable.
99 *
100 * @return string|WP_Error Absolute path, or an error.
101 */
102 function openstation_agent_faces_ensure_dir() {
103 $base = openstation_agent_faces_dir();
104 if ( ! wp_mkdir_p( $base ) ) {
105 return new WP_Error(
106 'openstation_agent_faces_mkdir_failed',
107 __( 'Could not create the agent-faces directory.', 'desktop-mode' ),
108 array( 'status' => 500 )
109 );
110 }
111
112 $index = $base . '/index.php';
113 if ( ! file_exists( $index ) ) {
114 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
115 file_put_contents( $index, "<?php // Silence is golden.\n" );
116 }
117
118 $htaccess = $base . '/.htaccess';
119 if ( ! file_exists( $htaccess ) ) {
120 $rules = "Options -Indexes\n"
121 . "<IfModule mod_php.c>\n\tphp_flag engine off\n</IfModule>\n"
122 . "<IfModule mod_php7.c>\n\tphp_flag engine off\n</IfModule>\n"
123 . "<FilesMatch \"\\.(?i:php|phtml|phar|php3|php4|php5|php7|php8|pht|phps|cgi|pl|asp|aspx|jsp|shtml|htaccess)$\">\n"
124 . "\t<IfModule mod_authz_core.c>\n\t\tRequire all denied\n\t</IfModule>\n"
125 . "\t<IfModule !mod_authz_core.c>\n\t\tOrder deny,allow\n\t\tDeny from all\n\t</IfModule>\n"
126 . "</FilesMatch>\n";
127 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
128 file_put_contents( $htaccess, $rules );
129 }
130
131 return $base;
132 }
133
134 /**
135 * The filename an agent's current face would have.
136 *
137 * The hash is of the stored look, so a shuffled face lands on a new
138 * filename and every cache that held the old one is bypassed without a
139 * query string.
140 *
141 * @param int $user_id Agent user id.
142 * @return string Filename, or '' when the agent has no face.
143 */
144 function openstation_agent_face_filename( $user_id ) {
145 $raw = (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_FACE_META, true );
146 if ( '' === $raw ) {
147 return '';
148 }
149 return (int) $user_id . '-' . substr( md5( $raw ), 0, 8 ) . '.svg';
150 }
151
152 /**
153 * URL of an agent's face file, if it has been written.
154 *
155 * Pure: never writes, never renders. A missing file returns '' and the
156 * caller falls back to the shipped robot, which is also what happens on
157 * hosts that refuse to serve SVG from uploads.
158 *
159 * @param int $user_id Agent user id.
160 * @return string URL, or '' when there is no face on disk.
161 */
162 function openstation_agent_face_url( $user_id ) {
163 $file = openstation_agent_face_filename( $user_id );
164 if ( '' === $file ) {
165 return '';
166 }
167 if ( ! file_exists( openstation_agent_faces_dir() . '/' . $file ) ) {
168 return '';
169 }
170 return openstation_agent_faces_url() . '/' . $file;
171 }
172
173 /**
174 * Render an agent's face and write it to disk.
175 *
176 * Idempotent: a face whose file already exists is left alone. Stale
177 * files for the same agent are removed, so an admin shuffling a face
178 * ten times leaves one file behind rather than ten.
179 *
180 * @param int $user_id Agent user id.
181 * @return string|WP_Error Absolute path written (or already present),
182 * '' when the agent has no face, or an error.
183 */
184 function openstation_agent_face_write( $user_id ) {
185 $user_id = (int) $user_id;
186 $file = openstation_agent_face_filename( $user_id );
187 if ( '' === $file ) {
188 openstation_agent_face_delete( $user_id );
189 return '';
190 }
191
192 $base = openstation_agent_faces_ensure_dir();
193 if ( is_wp_error( $base ) ) {
194 return $base;
195 }
196
197 $path = $base . '/' . $file;
198 if ( file_exists( $path ) ) {
199 return $path;
200 }
201
202 $look = openstation_mio_clamp_look( openstation_agent_get_face( $user_id ) );
203 $svg = openstation_mio_portrait_svg( $look, OPENSTATION_AGENT_FACE_SIZE );
204
205 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
206 $written = file_put_contents( $path, $svg );
207 if ( false === $written ) {
208 return new WP_Error(
209 'openstation_agent_face_write_failed',
210 __( 'Could not write the agent face.', 'desktop-mode' ),
211 array( 'status' => 500 )
212 );
213 }
214
215 openstation_agent_face_delete( $user_id, $file );
216 return $path;
217 }
218
219 /**
220 * Remove an agent's face files.
221 *
222 * @param int $user_id Agent user id.
223 * @param string $keep Filename to leave in place, if any.
224 * @return void
225 */
226 function openstation_agent_face_delete( $user_id, $keep = '' ) {
227 $base = openstation_agent_faces_dir();
228 if ( ! is_dir( $base ) ) {
229 return;
230 }
231 $found = glob( $base . '/' . (int) $user_id . '-*.svg' );
232 if ( ! is_array( $found ) ) {
233 return;
234 }
235 foreach ( $found as $path ) {
236 if ( '' !== $keep && basename( $path ) === $keep ) {
237 continue;
238 }
239 wp_delete_file( $path );
240 }
241 }
242
243 /**
244 * Keep the file in step with the meta.
245 *
246 * @param int $user_id Agent user id.
247 * @param array $changed Map of field => { from, to }.
248 * @return void
249 */
250 function openstation_agent_face_sync_on_update( $user_id, $changed ) {
251 if ( ! is_array( $changed ) || ! array_key_exists( 'face', $changed ) ) {
252 return;
253 }
254 openstation_agent_face_write( $user_id );
255 }
256 add_action( 'openstation_agent_updated', 'openstation_agent_face_sync_on_update', 10, 2 );
257
258 /**
259 * Write the face for a freshly created agent.
260 *
261 * @param int $user_id Agent user id.
262 * @return void
263 */
264 function openstation_agent_face_sync_on_create( $user_id ) {
265 openstation_agent_face_write( $user_id );
266 }
267 add_action( 'openstation_agent_created', 'openstation_agent_face_sync_on_create', 10, 1 );
268
269 /**
270 * Clean up when an agent is deleted.
271 *
272 * @param int $user_id Agent user id.
273 * @return void
274 */
275 function openstation_agent_face_cleanup( $user_id ) {
276 openstation_agent_face_delete( $user_id );
277 }
278 add_action( 'openstation_agent_deleted', 'openstation_agent_face_cleanup', 10, 1 );
279