settings = $settings; } /** * Offer the document through the SAME public registration action a third-party * provider uses — late, so explicit providers (priority 10) get first refusal. */ public function register() { add_action( AGENTIMUS_CANONICAL_HOOK, array( $this, 'provide' ), 99 ); } /** * Register the generated security.txt with the collector — unless something * else already provides it (see the class docblock for the precedence rules). * * @param Registry $registry The collector. */ public function provide( Registry $registry ) { // Opt-in, and short-circuitable by a site that wants another owner. if ( ! $this->should_serve() ) { return; } // A real file always wins — don't register a generator that would shadow it. if ( $this->file_on_disk() ) { return; } // RFC 9116 requires a Contact; without one we'd emit an invalid document. if ( empty( $this->contacts() ) ) { return; } $registry->add_well_known( array( 'name' => self::NAME, // send() appends "; charset=UTF-8"; RFC 9116 mandates text/plain utf-8. 'content_type' => 'text/plain', 'callback' => array( $this, 'body' ), ) ); } /** * Whether to offer a generated security.txt. Off by default; filterable so a * dedicated security.txt plugin can claim ownership with one line. * * @return bool */ public function should_serve() { /** * Short-circuit Agentimus's security.txt entirely. * * @param bool $serve Whether to offer a generated document. */ return (bool) apply_filters( 'agentimus_serve_security_txt', $this->settings->enabled( 'enable_security_txt' ) ); } /** * The RFC 9116 document, assembled from identity + security settings. Cached an * hour and busted by Cache::flush() on any settings/content change. * * @return string */ public function body() { $cached = Cache::get( Cache::SECURITY_TXT ); if ( is_string( $cached ) && '' !== $cached ) { return $cached; } $sec = (array) $this->settings->get( 'security', array() ); $lines = array( '# Generated by Agentimus per RFC 9116. Set the fields under Settings', '# > Agentimus, or drop a real /.well-known/security.txt and this', '# generator stands aside automatically.', '', ); // Contact + Expires are the two REQUIRED fields. foreach ( $this->contacts() as $contact ) { $lines[] = 'Contact: ' . $contact; } $lines[] = 'Expires: ' . $this->expires(); // Canonical (RECOMMENDED) — where this document is meant to live. $lines[] = 'Canonical: ' . home_url( '/.well-known/security.txt' ); $langs = $this->preferred_languages(); if ( '' !== $langs ) { $lines[] = 'Preferred-Languages: ' . $langs; } // Optional URL fields — emitted only when configured. foreach ( array( 'Policy' => 'policy', 'Acknowledgments' => 'acknowledgments', 'Encryption' => 'encryption', 'Hiring' => 'hiring', ) as $field => $key ) { $url = isset( $sec[ $key ] ) ? esc_url_raw( (string) $sec[ $key ] ) : ''; if ( '' !== $url ) { $lines[] = $field . ': ' . $url; } } $body = implode( "\n", $lines ) . "\n"; /** * Filter the final security.txt body — full control, e.g. to append a * `Signature:` line pointing at an OpenPGP-signed copy. * * @param string $body The assembled document. */ $body = (string) apply_filters( 'agentimus_security_txt', $body ); Cache::set( Cache::SECURITY_TXT, $body ); return $body; } /** * Contact field values (RFC 9116: at least one REQUIRED). Seeds from the * identity contact email, then any extra contacts from the security settings. * Each is normalised to a URI (bare emails become mailto:) and de-duplicated. * * @return string[] */ public function contacts() { $raw = array(); $email = (string) $this->settings->identity( 'contact_email', '' ); if ( '' !== $email ) { $raw[] = $email; } $sec = (array) $this->settings->get( 'security', array() ); if ( ! empty( $sec['contacts'] ) ) { $raw = array_merge( $raw, (array) $sec['contacts'] ); } $out = array(); foreach ( $raw as $value ) { $uri = $this->normalize_contact( $value ); if ( '' !== $uri ) { $out[ $uri ] = $uri; } } return array_values( $out ); } /** * The Expires timestamp (RFC 9116: exactly one REQUIRED, SHOULD be < 1 year). * Always a fresh future instant — the hour-long body cache cannot push it * stale relative to its multi-month window. * * @return string ISO 8601 UTC, e.g. "2026-12-16T10:00:00Z". */ public function expires() { $sec = (array) $this->settings->get( 'security', array() ); $days = isset( $sec['expires_days'] ) ? (int) $sec['expires_days'] : self::DEFAULT_EXPIRES_DAYS; $days = max( 1, min( 365, $days ) ); /** * Filter the Expires window in days. * * @param int $days Days from now. */ $days = (int) apply_filters( 'agentimus_security_txt_expires_days', $days ); $days = max( 1, min( 365, $days ) ); return gmdate( 'Y-m-d\TH:i:s\Z', time() + ( $days * DAY_IN_SECONDS ) ); } /** * Normalise a contact to an RFC 9116 URI: bare emails → mailto:, otherwise an * http(s)/mailto/tel URI (rejected if it is none of those). * * @param string $value Raw contact. * @return string URI, or '' if invalid. */ private function normalize_contact( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return ''; } if ( is_email( $value ) ) { return 'mailto:' . sanitize_email( $value ); } // Require an explicit, allowed scheme — esc_url_raw() would otherwise invent // one for a bare word, emitting a bogus Contact like "https://not-a-uri". if ( ! preg_match( '#^(https?|mailto|tel):#i', $value ) ) { return ''; } $uri = esc_url_raw( $value, array( 'https', 'http', 'mailto', 'tel' ) ); return $uri ? $uri : ''; } /** * Preferred-Languages value — the explicit setting, else the site locale's * primary subtag (en_US → en). Empty string omits the field. * * @return string */ private function preferred_languages() { $sec = (array) $this->settings->get( 'security', array() ); if ( ! empty( $sec['preferred_languages'] ) ) { return sanitize_text_field( (string) $sec['preferred_languages'] ); } $primary = strtok( str_replace( '_', '-', (string) get_bloginfo( 'language' ) ), '-' ); return is_string( $primary ) ? $primary : ''; } /** * Whether a real security.txt file sits at the web root's /.well-known. * * @return bool */ private function file_on_disk() { return file_exists( \Agentimus\Paths::site_root() . '.well-known/' . self::NAME ); } }