PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / trunk
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz vtrunk
2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / client-logger.php
sureforms / inc Last commit date
abilities 14 hours ago admin 3 months ago ai-form-builder 1 month ago blocks 2 months ago compatibility 3 weeks ago database 14 hours ago email 3 weeks ago fields 3 weeks ago global-settings 14 hours ago lib 1 month ago migrator 2 months ago page-builders 3 weeks ago payments 14 hours ago single-form-settings 2 months ago traits 2 months ago activator.php 1 year ago admin-ajax.php 14 hours ago background-process.php 9 months ago client-logger.php 14 hours ago create-new-form.php 3 months ago duplicate-form.php 14 hours ago entries.php 3 weeks ago events-scheduler.php 2 years ago export.php 14 hours ago field-validation.php 3 weeks ago form-restriction.php 2 months ago form-styling.php 1 month ago form-submit.php 14 hours ago form-views.php 14 hours ago forms-data.php 14 hours ago frontend-assets.php 14 hours ago generate-form-markup.php 14 hours ago gutenberg-hooks.php 2 weeks ago helper.php 14 hours ago learn.php 4 months ago onboarding.php 2 months ago post-types.php 14 hours ago rest-api.php 14 hours ago smart-tags.php 1 week ago submit-token.php 14 hours ago translatable.php 1 month ago updater-callbacks.php 3 weeks ago updater.php 3 weeks ago
client-logger.php
696 lines
1 <?php
2 /**
3 * Client error logging.
4 *
5 * Owns the debug log file that the "Enable Logs" setting writes to: where it
6 * lives, what may be written to it, and how large it is allowed to get. Nothing
7 * else in the plugin should open that file.
8 *
9 * The log records form-submission failures reported by the visitor's browser —
10 * the HTTP status and duration of the submit request, and the error behind a
11 * failure — so a site owner can reproduce a bug and hand the file to support
12 * instead of being talked through DevTools.
13 *
14 * @package SureForms
15 * @since 2.12.6
16 */
17
18 namespace SRFM\Inc;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * Client_Logger
26 *
27 * Design notes that are load-bearing:
28 *
29 * - Every method fails silently. The uploads directory is not writable on
30 * hardened or read-only-deploy hosts, and a logger that warns or fatals on a
31 * visitor-facing request is worse than one that records nothing.
32 * - The file name is unguessable and never disclosed. `.htaccess` does nothing
33 * on nginx, so the name is the real protection; the download handler derives
34 * the path itself and takes no filename parameter.
35 * - Over the size cap the log stops accepting writes rather than trimming or
36 * rotating. Someone reproducing a bug must not have the tail of their repro
37 * evicted by newer noise from an unrelated visitor.
38 *
39 * @since 2.12.6
40 */
41 class Client_Logger {
42 /**
43 * Option holding the random component of the log file name.
44 *
45 * @since 2.12.6
46 */
47 public const FILENAME_OPTION = 'srfm_client_log_file';
48
49 /**
50 * Consecutive faults before the site owner is told something is wrong.
51 *
52 * One. A fault is already filtered down to what a visitor cannot fix by trying
53 * again -- the request never reached PHP, the response was not JSON, the server
54 * errored, an email could not be sent -- so waiting for a run of them means
55 * staying quiet through the first several lost submissions.
56 *
57 * @since 2.12.6
58 */
59 public const FAULT_THRESHOLD = 1;
60
61 /**
62 * Maximum size of the log file in bytes.
63 *
64 * @since 2.12.6
65 */
66 public const MAX_FILE_SIZE = 1048576;
67
68 /**
69 * Longest free-text value stored on a single entry, in characters.
70 *
71 * @since 2.12.6
72 */
73 public const MAX_TEXT_LENGTH = 500;
74
75 /**
76 * Longest single field key stored on an entry, in characters.
77 *
78 * @since 2.12.6
79 */
80 public const MAX_KEY_LENGTH = 100;
81
82 /**
83 * Option holding per-category failure state, keyed by category.
84 *
85 * Shape: [ category => [ 'count' => int, 'form_id' => int, 'form_title' => string,
86 * 'at' => int, 'acked' => int ] ].
87 *
88 * Kept per category because the three read completely differently to a site
89 * owner: submissions failing means visitors cannot reach you, a notification
90 * failing means you are not hearing about entries that did save, and an
91 * integration failing means a third party is not receiving them. Collapsing
92 * them into one warning would describe none of those accurately.
93 *
94 * @since 2.12.6
95 */
96 public const FAILURES_OPTION = 'srfm_client_log_failures';
97
98 /**
99 * Categories a failure can belong to.
100 *
101 * @since 2.12.6
102 */
103 public const CATEGORIES = [ 'submission', 'notification', 'integration' ];
104
105 /**
106 * Whether client error logging is currently switched on.
107 *
108 * On by default, including on installs whose stored settings predate the
109 * option. The point of the log is that the evidence already exists when a
110 * support ticket arrives -- a default of off would mean asking the reporter to
111 * enable it and reproduce, which is the round trip this feature removes.
112 *
113 * Costs nothing on a healthy site: only failures are ever written, so a site
114 * whose forms work never creates the file at all.
115 *
116 * This is the one authority. The frontend also carries a flag, but that flag is
117 * baked into cached HTML and can be a full cache TTL out of date, so every
118 * write path re-checks here.
119 *
120 * @since 2.12.6
121 * @return bool
122 */
123 public static function is_enabled() {
124 $general = get_option( 'srfm_general_settings_options', [] );
125
126 if ( ! is_array( $general ) || ! isset( $general['srfm_enable_logs'] ) ) {
127 return true;
128 }
129
130 return (bool) $general['srfm_enable_logs'];
131 }
132
133 /**
134 * Whether an entry means the site is broken, rather than the visitor.
135 *
136 * This distinction is the whole basis of the failure notice. Most of what the
137 * log records is routine: a mistyped email, an expired captcha, a declined
138 * card, a submission the server rejected by naming the field to fix. Those are
139 * the form working correctly, and counting them would tell healthy sites to
140 * contact support -- on every install, because logging is on by default.
141 *
142 * A fault is what a visitor cannot resolve by trying again correctly: the
143 * request never reached PHP, the response was not JSON, the server returned an
144 * error status, or a notification email could not be sent.
145 *
146 * @param array<string,mixed> $entry Entry as returned by sanitize_entry().
147 * @since 2.12.6
148 * @return bool
149 */
150 public static function is_fault( array $entry ) {
151 $type = $entry['type'] ?? '';
152
153 // Allowlist, so an unrecognised or new category is not a fault by default.
154 // 'blocked' is deliberately absent: it is the label the browser puts on a
155 // stop the visitor can clear themselves.
156 if ( in_array( $type, [ 'error', 'response', 'message' ], true ) ) {
157 return true;
158 }
159
160 if ( 'network' !== $type ) {
161 return false;
162 }
163
164 $status = isset( $entry['status'] ) ? Helper::get_integer_value( $entry['status'] ) : 0;
165
166 // 403 is the submit token being refused, which on a cached site means the
167 // page is serving a token the server will not accept.
168 return $status >= 500 || 403 === $status;
169 }
170
171 /**
172 * Record a failure against a category and the form it happened on.
173 *
174 * The form is carried because "a form is failing" is not actionable on a site
175 * with twenty of them -- the first thing anyone asks is which one.
176 *
177 * @param string $category One of self::CATEGORIES.
178 * @param int $form_id Form the failure happened on.
179 * @param string $form_title Form title, resolved by the caller.
180 * @since 2.12.6
181 * @return void
182 */
183 public static function record_failure( $category, $form_id = 0, $form_title = '' ) {
184 if ( ! in_array( $category, self::CATEGORIES, true ) ) {
185 return;
186 }
187
188 $failures = self::get_failures();
189 $existing = $failures[ $category ] ?? [];
190
191 $failures[ $category ] = [
192 'count' => Helper::get_integer_value( $existing['count'] ?? 0 ) + 1,
193 'form_id' => absint( $form_id ),
194 'form_title' => mb_substr( sanitize_text_field( $form_title ), 0, 100 ),
195 'at' => time(),
196 // Preserved: a report already made still stands until this new count
197 // overtakes it, which is what get_open_failures() compares.
198 'acked' => Helper::get_integer_value( $existing['acked'] ?? 0 ),
199 ];
200
201 update_option( self::FAILURES_OPTION, $failures, false );
202 }
203
204 /**
205 * All recorded failure state.
206 *
207 * @since 2.12.6
208 * @return array<string,array<string,mixed>>
209 */
210 public static function get_failures() {
211 return Helper::get_array_value( get_option( self::FAILURES_OPTION, [] ) );
212 }
213
214 /**
215 * Categories with failures the site owner has not already reported.
216 *
217 * @since 2.12.6
218 * @return array<string,array<string,mixed>>
219 */
220 public static function get_open_failures() {
221 $open = [];
222
223 foreach ( self::get_failures() as $category => $failure ) {
224 if ( ! in_array( $category, self::CATEGORIES, true ) ) {
225 continue;
226 }
227
228 $count = Helper::get_integer_value( $failure['count'] ?? 0 );
229
230 // Compared on the count, not the clock: both are written to the second,
231 // so a failure landing in the same second as the report would look
232 // not-newer and be hidden.
233 if ( $count > 0 && $count > Helper::get_integer_value( $failure['acked'] ?? 0 ) ) {
234 $open[ $category ] = $failure;
235 }
236 }
237
238 return $open;
239 }
240
241 /**
242 * Mark one category as reported.
243 *
244 * @param string $category One of self::CATEGORIES.
245 * @since 2.12.6
246 * @return void
247 */
248 public static function acknowledge_category( $category ) {
249 $failures = self::get_failures();
250
251 if ( ! isset( $failures[ $category ] ) ) {
252 return;
253 }
254
255 $failures[ $category ]['acked'] = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 );
256
257 update_option( self::FAILURES_OPTION, $failures, false );
258 }
259
260 /**
261 * Forget one category's failures entirely.
262 *
263 * @param string $category One of self::CATEGORIES.
264 * @since 2.12.6
265 * @return void
266 */
267 public static function clear_category( $category ) {
268 $failures = self::get_failures();
269
270 if ( ! isset( $failures[ $category ] ) ) {
271 return;
272 }
273
274 unset( $failures[ $category ] );
275
276 update_option( self::FAILURES_OPTION, $failures, false );
277 }
278
279 /**
280 * How many submission faults have been recorded.
281 *
282 * @since 2.12.6
283 * @return int
284 */
285 public static function get_fault_streak() {
286 $failures = self::get_failures();
287
288 return Helper::get_integer_value( $failures['submission']['count'] ?? 0 );
289 }
290
291 /**
292 * Whether submissions are failing and it has not already been reported.
293 *
294 * @since 2.12.6
295 * @return bool
296 */
297 public static function has_persistent_failures() {
298 return self::get_fault_streak() >= self::FAULT_THRESHOLD
299 && isset( self::get_open_failures()['submission'] );
300 }
301
302 /**
303 * The recorded acknowledgement for submission failures, if there is one.
304 *
305 * @since 2.12.6
306 * @return array<string,mixed> Empty when nothing has been acknowledged.
307 */
308 public static function get_acknowledgement() {
309 $failures = self::get_failures();
310 $acked = Helper::get_integer_value( $failures['submission']['acked'] ?? 0 );
311
312 if ( $acked < 1 ) {
313 return [];
314 }
315
316 return [
317 'at' => Helper::get_integer_value( $failures['submission']['at'] ?? 0 ),
318 'streak' => $acked,
319 ];
320 }
321
322 /**
323 * When the most recent submission fault happened.
324 *
325 * @since 2.12.6
326 * @return int Unix timestamp, or 0 when nothing has failed.
327 */
328 public static function get_last_fault_time() {
329 $failures = self::get_failures();
330
331 return Helper::get_integer_value( $failures['submission']['at'] ?? 0 );
332 }
333
334 /**
335 * Record that the site owner has reported the current submission failures.
336 *
337 * @since 2.12.6
338 * @return void
339 */
340 public static function acknowledge_failures() {
341 self::acknowledge_category( 'submission' );
342 }
343
344 /**
345 * Forget the submission failures after one gets through.
346 *
347 * Hooked - srfm_form_submit, which fires only on the success path.
348 *
349 * Only the submission category is cleared. A submission getting through says
350 * nothing about whether its notification email sent or its integrations ran,
351 * so those clear when they next succeed or when the owner reports them.
352 *
353 * @since 2.12.6
354 * @return void
355 */
356 public static function reset_fault_streak() {
357 self::clear_category( 'submission' );
358 }
359
360 /**
361 * Absolute path to the log file, creating its directory if needed.
362 *
363 * @param bool $create Whether to create the directory when it is absent.
364 * @since 2.12.6
365 * @return string Absolute path, or '' when the location is unusable.
366 */
367 public static function get_log_path( $create = true ) {
368 $uploads = wp_upload_dir();
369
370 if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) {
371 return '';
372 }
373
374 $dir = trailingslashit( $uploads['basedir'] ) . 'sureforms/logs/';
375
376 if ( ! is_dir( $dir ) ) {
377 if ( ! $create || ! wp_mkdir_p( $dir ) ) {
378 return '';
379 }
380
381 self::protect_directory( $dir );
382 }
383
384 return $dir . 'srfm-debug-' . self::get_filename_hash() . '.log';
385 }
386
387 /**
388 * Append one validated entry to the log.
389 *
390 * @param array<string,mixed> $entry Entry as returned by sanitize_entry().
391 * @since 2.12.6
392 * @return bool True when the line was written.
393 */
394 public static function append( array $entry ) {
395 // Checked here as well as at the route, so the guard sits on the function
396 // that writes rather than only on today's single caller. Without it any
397 // future caller writes to disk on a site that never switched logging on.
398 if ( ! self::is_enabled() ) {
399 return false;
400 }
401
402 if ( empty( $entry ) ) {
403 return false;
404 }
405
406 // Counted before the file is touched. A full log or an unwritable uploads
407 // directory must not stop the site owner being told the form is failing --
408 // on a badly broken site those are exactly the conditions that occur.
409 // A notification or integration failure records its own category at the call
410 // site; everything else reaching here is the submission itself.
411 if ( self::is_fault( $entry ) && 'message' !== ( $entry['type'] ?? '' ) ) {
412 self::record_failure(
413 'submission',
414 Helper::get_integer_value( $entry['form_id'] ?? 0 ),
415 Helper::get_string_value( $entry['form_title'] ?? '' )
416 );
417 }
418
419 $path = self::get_log_path();
420
421 if ( '' === $path ) {
422 return false;
423 }
424
425 // Cap and stop. Deliberately not a trim or a rotate: the person who
426 // reproduced the bug is the one whose lines would be discarded.
427 if ( self::is_full() ) {
428 return false;
429 }
430
431 $entry['time'] = gmdate( 'Y-m-d H:i:s' );
432
433 $line = wp_json_encode( $entry );
434
435 if ( ! is_string( $line ) ) {
436 return false;
437 }
438
439 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents -- WP_Filesystem can prompt for credentials and is not initialised on a public REST request; this mirrors the raw-handle pattern already used in inc/entries.php. LOCK_EX is insurance for NFS/Windows -- appends of this size are already atomic on POSIX.
440 return false !== file_put_contents( $path, $line . "\n", FILE_APPEND | LOCK_EX );
441 }
442
443 /**
444 * The most recent whole log lines, up to a character budget.
445 *
446 * For pasting into a support email, where the transport imposes the limit: a
447 * mailto URL has to survive percent-encoding and every mail client's own
448 * length cap, so only a tail fits. Newest entries are the ones that describe
449 * the failure being reported, so the tail is the useful end.
450 *
451 * Whole lines only -- half a JSON object helps nobody.
452 *
453 * @param int $max_chars Character budget for the returned text.
454 * @since 2.12.6
455 * @return array{text:string,shown:int,total:int}
456 */
457 public static function get_tail( $max_chars = 1200 ) {
458 $empty = [
459 'text' => '',
460 'shown' => 0,
461 'total' => 0,
462 ];
463
464 $path = self::get_log_path( false );
465
466 if ( '' === $path || ! file_exists( $path ) ) {
467 return $empty;
468 }
469
470 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file, WordPress.WP.AlternativeFunctions.file_system_read_file -- Reading a file this class owns; WP_Filesystem would prompt for credentials and is unavailable here.
471 $lines = file( $path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
472
473 if ( ! is_array( $lines ) || empty( $lines ) ) {
474 return $empty;
475 }
476
477 $total = count( $lines );
478 $kept = [];
479 $used = 0;
480
481 foreach ( array_reverse( $lines ) as $line ) {
482 $length = strlen( $line ) + 1;
483
484 // Always keep one line, even if it alone exceeds the budget: an empty
485 // excerpt is worse than a long one.
486 if ( $used + $length > $max_chars && ! empty( $kept ) ) {
487 break;
488 }
489
490 array_unshift( $kept, $line );
491 $used += $length;
492 }
493
494 return [
495 'text' => implode( "\n", $kept ),
496 'shown' => count( $kept ),
497 'total' => $total,
498 ];
499 }
500
501 /**
502 * Whether the log has reached its size cap.
503 *
504 * @since 2.12.6
505 * @return bool
506 */
507 public static function is_full() {
508 return self::get_file_size() >= self::MAX_FILE_SIZE;
509 }
510
511 /**
512 * Current size of the log file in bytes.
513 *
514 * @since 2.12.6
515 * @return int
516 */
517 public static function get_file_size() {
518 $path = self::get_log_path( false );
519
520 if ( '' === $path || ! file_exists( $path ) ) {
521 return 0;
522 }
523
524 $size = filesize( $path );
525
526 return is_int( $size ) ? $size : 0;
527 }
528
529 /**
530 * Delete the log file.
531 *
532 * @since 2.12.6
533 * @return bool
534 */
535 public static function clear() {
536 $path = self::get_log_path( false );
537
538 if ( '' === $path || ! file_exists( $path ) ) {
539 return true;
540 }
541
542 return wp_delete_file_from_directory( $path, dirname( $path ) );
543 }
544
545 /**
546 * Reduce a caller-supplied payload to the fixed shape the log accepts.
547 *
548 * The endpoint never appends caller text directly. Redaction governs values;
549 * this governs shape. Without it, "we redact the field values" would still
550 * leave an anonymous caller writing arbitrary content into a file an
551 * administrator later opens.
552 *
553 * @param array<string,mixed> $raw Decoded request payload.
554 * @since 2.12.6
555 * @return array<string,mixed> Empty when nothing usable survived.
556 */
557 public static function sanitize_entry( array $raw ) {
558 $allowed_types = [ 'network', 'response', 'error', 'message', 'blocked' ];
559 $type = isset( $raw['type'] ) ? sanitize_key( Helper::get_string_value( $raw['type'] ) ) : '';
560
561 if ( ! in_array( $type, $allowed_types, true ) ) {
562 return [];
563 }
564
565 $entry = [
566 'type' => $type,
567 'form_id' => isset( $raw['form_id'] ) ? absint( Helper::get_integer_value( $raw['form_id'] ) ) : 0,
568 ];
569
570 foreach ( [ 'message', 'source', 'body', 'form_title' ] as $key ) {
571 if ( ! isset( $raw[ $key ] ) ) {
572 continue;
573 }
574
575 $text = self::scrub_text( Helper::get_string_value( $raw[ $key ] ) );
576
577 if ( '' !== $text ) {
578 $entry[ $key ] = $text;
579 }
580 }
581
582 foreach ( [ 'status', 'duration_ms', 'line' ] as $key ) {
583 if ( isset( $raw[ $key ] ) ) {
584 $entry[ $key ] = absint( Helper::get_integer_value( $raw[ $key ] ) );
585 }
586 }
587
588 if ( isset( $raw['field_keys'] ) && is_array( $raw['field_keys'] ) ) {
589 $keys = [];
590
591 // Both the count and each key's length. Capping only the count left one
592 // request able to write ~1MB of field keys and fill the log in a single
593 // call -- and because the log stops rather than evicting, that silently
594 // disabled the feature until an admin cleared it. A real key is
595 // `srfm-input-lbl-<base64>`, far inside this bound.
596 foreach ( array_slice( $raw['field_keys'], 0, 100 ) as $field_key ) {
597 $keys[] = mb_substr( sanitize_text_field( Helper::get_string_value( $field_key ) ), 0, self::MAX_KEY_LENGTH );
598 }
599
600 $entry['field_keys'] = $keys;
601 }
602
603 // A type alone says nothing. Require at least one substantive value.
604 $has_detail = isset( $entry['message'] ) || isset( $entry['body'] ) || isset( $entry['status'] );
605
606 return $has_detail ? $entry : [];
607 }
608
609 /**
610 * Strip identifying detail out of free text and clamp its length.
611 *
612 * Redacting submitted field values is not sufficient on its own: error text
613 * interpolates user input constantly ("Invalid email: someone@example.com"),
614 * and a page URL routinely carries an address or a reset key in its query
615 * string. Whitespace is collapsed as a log-injection guard, matching
616 * inc/ai-form-builder/ai-helper.php.
617 *
618 * @param string $text Raw text.
619 * @since 2.12.6
620 * @return string
621 */
622 public static function scrub_text( $text ) {
623 if ( '' === $text ) {
624 return '';
625 }
626
627 // Drop query strings and fragments wholesale rather than allowlisting
628 // parameters. A token after # is just as sensitive as one after ?.
629 $text = (string) preg_replace( '#(https?://[^\s?\#]+)[?\#]\S*#i', '$1', $text );
630
631 // Email addresses.
632 $text = (string) preg_replace( '/[\w.+-]+@[\w-]+\.[\w.-]+/', '[email]', $text );
633
634 // Long digit runs: card numbers, phone numbers, ids. Separators are matched
635 // too, because a real phone number is written 555-123-4567 or (555) 123-4567
636 // and a contiguous-digits rule never sees it.
637 $text = (string) preg_replace( '/\+?\d[\d\s().-]{5,}\d/', '[number]', $text );
638
639 $text = (string) preg_replace( '/\s+/', ' ', $text );
640
641 return mb_substr( trim( wp_strip_all_tags( $text ) ), 0, self::MAX_TEXT_LENGTH );
642 }
643
644 /**
645 * Random component of the log file name, generated once and reused.
646 *
647 * The blog id is part of the input because wp_salt() is network-wide: a
648 * salt-only hash would be identical on every site of a multisite network, and
649 * older subdirectory installs can share one uploads directory.
650 *
651 * @since 2.12.6
652 * @return string
653 */
654 private static function get_filename_hash() {
655 $hash = get_option( self::FILENAME_OPTION, '' );
656
657 if ( is_string( $hash ) && 32 === strlen( $hash ) && ctype_xdigit( $hash ) ) {
658 return $hash;
659 }
660
661 $hash = hash_hmac( 'md5', 'srfm-client-log|' . get_current_blog_id(), wp_salt( 'auth' ) );
662
663 update_option( self::FILENAME_OPTION, $hash, false );
664
665 return $hash;
666 }
667
668 /**
669 * Write the directory guards, best effort.
670 *
671 * An index.html rather than index.php: the nginx failure mode is `autoindex on`
672 * producing a listing, and an index.html suppresses that. .htaccess covers
673 * Apache and is inert on nginx, which is why the unguessable file name — not
674 * these files — is what actually protects the log.
675 *
676 * @param string $dir Directory to guard.
677 * @since 2.12.6
678 * @return void
679 */
680 private static function protect_directory( $dir ) {
681 $guards = [
682 '.htaccess' => "# Apache 2.4\n<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\nOrder deny,allow\nDeny from all\n</IfModule>\n",
683 'index.html' => '',
684 ];
685
686 foreach ( $guards as $name => $contents ) {
687 if ( file_exists( $dir . $name ) ) {
688 continue;
689 }
690
691 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents -- Best-effort directory guard written at creation time; WP_Filesystem may prompt for credentials and is unavailable on the public request that first creates this directory.
692 file_put_contents( $dir . $name, $contents );
693 }
694 }
695 }
696