| 1 | <?php |
|---|
| 2 | /** |
|---|
| 3 | * Plugin Name: Signed Posts |
|---|
| 4 | * Plugin URI: https://wordpress.org/plugins/signed-posts/ |
|---|
| 5 | * Description: Signed Posts allows authors to sign posts, assuring content integrity. Signature verification proves post-signing alteration hasn't occurred. |
|---|
| 6 | * Version: 0.5 |
|---|
| 7 | * Requires at least: 6.9 |
|---|
| 8 | * Tested up to: 6.9 |
|---|
| 9 | * Requires PHP: 8.2 |
|---|
| 10 | * Tested up to PHP: 8.3 |
|---|
| 11 | * Author: Marc Armengou |
|---|
| 12 | * Author URI: https://www.marcarmengou.com/ |
|---|
| 13 | * Text Domain: signed-posts |
|---|
| 14 | * License: GPLv2 or later |
|---|
| 15 | * |
|---|
| 16 | * @package SignedPosts |
|---|
| 17 | */ |
|---|
| 18 | |
|---|
| 19 | if ( ! defined( 'ABSPATH' ) ) { |
|---|
| 20 | exit; // Exit if accessed directly. |
|---|
| 21 | } |
|---|
| 22 | |
|---|
| 23 | // ----------------------------------------------------------- |
|---|
| 24 | // Utilities |
|---|
| 25 | // ----------------------------------------------------------- |
|---|
| 26 | |
|---|
| 27 | /** |
|---|
| 28 | * Returns the canonical post content string used for signing and verification. |
|---|
| 29 | * |
|---|
| 30 | * Normalises line endings to LF, trims surrounding whitespace, and appends a |
|---|
| 31 | * trailing newline so the string matches what the author signed. |
|---|
| 32 | * |
|---|
| 33 | * @param int $post_id The ID of the post whose content should be normalised. |
|---|
| 34 | * @return string Normalised post content ready for signature verification. |
|---|
| 35 | */ |
|---|
| 36 | function wppgps_normalize_post_content_for_signing( int $post_id ): string { |
|---|
| 37 | $signed_message = get_post_field( 'post_content', $post_id ); |
|---|
| 38 | $signed_message = str_replace( "\r\n", "\n", $signed_message ); |
|---|
| 39 | $signed_message = trim( $signed_message ); |
|---|
| 40 | if ( ! empty( $signed_message ) ) { |
|---|
| 41 | $signed_message .= "\n"; |
|---|
| 42 | } |
|---|
| 43 | return $signed_message; |
|---|
| 44 | } |
|---|
| 45 | |
|---|
| 46 | /** |
|---|
| 47 | * Returns cached signature data for a given post. |
|---|
| 48 | * |
|---|
| 49 | * Fetches and caches (in a static variable) the signature, method, author |
|---|
| 50 | * identity meta, and derived flags for the post. This avoids repeated |
|---|
| 51 | * get_post_meta / get_user_meta calls across wppgps_enqueue_scripts, |
|---|
| 52 | * wppgps_append_signature_block, and wppgps_should_append_badge within the |
|---|
| 53 | * same request. |
|---|
| 54 | * |
|---|
| 55 | * @param int $post_id The post ID to retrieve signature data for. |
|---|
| 56 | * @return array{ |
|---|
| 57 | * signature: string, |
|---|
| 58 | * method: string, |
|---|
| 59 | * author_id: int, |
|---|
| 60 | * pgp_key_url: string, |
|---|
| 61 | * did_identifier: string, |
|---|
| 62 | * has_identity: bool |
|---|
| 63 | * } Associative array of signature-related data for the post. |
|---|
| 64 | */ |
|---|
| 65 | function wppgps_get_post_signature_data( int $post_id ): array { |
|---|
| 66 | static $cache = array(); |
|---|
| 67 | |
|---|
| 68 | if ( isset( $cache[ $post_id ] ) ) { |
|---|
| 69 | return $cache[ $post_id ]; |
|---|
| 70 | } |
|---|
| 71 | |
|---|
| 72 | $signature = get_post_meta( $post_id, '_wppgps_signature', true ); |
|---|
| 73 | $method = get_post_meta( $post_id, '_wppgps_sig_method', true ); |
|---|
| 74 | if ( ! in_array( $method, array( 'openpgp', 'did:key', 'did:web' ), true ) ) { |
|---|
| 75 | $method = 'openpgp'; |
|---|
| 76 | } |
|---|
| 77 | |
|---|
| 78 | $author_id = (int) get_post_field( 'post_author', $post_id ); |
|---|
| 79 | $pgp_key_url = (string) get_user_meta( $author_id, 'pgp_key_url', true ); |
|---|
| 80 | $did_identifier = (string) get_user_meta( $author_id, 'did_identifier', true ); |
|---|
| 81 | $has_identity = ( 'openpgp' === $method ) ? ( '' !== $pgp_key_url ) : ( '' !== $did_identifier ); |
|---|
| 82 | |
|---|
| 83 | $cache[ $post_id ] = array( |
|---|
| 84 | 'signature' => (string) $signature, |
|---|
| 85 | 'method' => $method, |
|---|
| 86 | 'author_id' => $author_id, |
|---|
| 87 | 'pgp_key_url' => $pgp_key_url, |
|---|
| 88 | 'did_identifier' => $did_identifier, |
|---|
| 89 | 'has_identity' => $has_identity, |
|---|
| 90 | ); |
|---|
| 91 | |
|---|
| 92 | return $cache[ $post_id ]; |
|---|
| 93 | } |
|---|
| 94 | |
|---|
| 95 | // ----------------------------------------------------------- |
|---|
| 96 | // Activation |
|---|
| 97 | // ----------------------------------------------------------- |
|---|
| 98 | |
|---|
| 99 | /** |
|---|
| 100 | * Registers default plugin options when the plugin is activated. |
|---|
| 101 | * |
|---|
| 102 | * Sets all cleanup-on-uninstall preferences to '0' (preserve data) if they |
|---|
| 103 | * have not been set before. Uses add_option so existing values are never |
|---|
| 104 | * overwritten on re-activation. |
|---|
| 105 | * |
|---|
| 106 | * @return void |
|---|
| 107 | */ |
|---|
| 108 | function wppgps_activate(): void { |
|---|
| 109 | if ( false === get_option( 'wppgps_delete_post_signatures', false ) ) { |
|---|
| 110 | add_option( 'wppgps_delete_post_signatures', '0' ); |
|---|
| 111 | } |
|---|
| 112 | if ( false === get_option( 'wppgps_delete_user_key_urls', false ) ) { |
|---|
| 113 | add_option( 'wppgps_delete_user_key_urls', '0' ); |
|---|
| 114 | } |
|---|
| 115 | if ( false === get_option( 'wppgps_delete_user_did_identifiers', false ) ) { |
|---|
| 116 | add_option( 'wppgps_delete_user_did_identifiers', '0' ); |
|---|
| 117 | } |
|---|
| 118 | } |
|---|
| 119 | register_activation_hook( __FILE__, 'wppgps_activate' ); |
|---|
| 120 | |
|---|
| 121 | // ----------------------------------------------------------- |
|---|
| 122 | // 1. BACKEND: Author profile fields (OpenPGP URL + DID) |
|---|
| 123 | // ----------------------------------------------------------- |
|---|
| 124 | |
|---|
| 125 | /** |
|---|
| 126 | * Renders the OpenPGP public key URL field in the user profile screen. |
|---|
| 127 | * |
|---|
| 128 | * Only the profile owner can edit the field; other users see it as read-only. |
|---|
| 129 | * |
|---|
| 130 | * @param WP_User $user The user object whose profile is being displayed. |
|---|
| 131 | * @return void |
|---|
| 132 | */ |
|---|
| 133 | function wppgps_add_user_profile_fields( WP_User $user ): void { |
|---|
| 134 | $pgp_key_url = (string) get_user_meta( $user->ID, 'pgp_key_url', true ); |
|---|
| 135 | $readonly_attr = ( (int) $user->ID === get_current_user_id() ) ? '' : 'readonly'; |
|---|
| 136 | ?> |
|---|
| 137 | <h2><?php esc_html_e( 'Signed Posts Settings', 'signed-posts' ); ?></h2> |
|---|
| 138 | <h3><?php esc_html_e( 'OpenPGP', 'signed-posts' ); ?></h3> |
|---|
| 139 | <table class="form-table" role="presentation"> |
|---|
| 140 | <tr> |
|---|
| 141 | <th><label for="pgp_key_url"><?php esc_html_e( 'URL of your OpenPGP Public Key', 'signed-posts' ); ?></label></th> |
|---|
| 142 | <td> |
|---|
| 143 | <input type="url" name="pgp_key_url" id="pgp_key_url" value="<?php echo esc_attr( $pgp_key_url ); ?>" class="regular-text" <?php echo esc_attr( $readonly_attr ); ?> /> |
|---|
| 144 | <p class="description"> |
|---|
| 145 | <?php esc_html_e( 'Full URL to your OpenPGP public key. Must be hosted externally with CORS enabled. This URL is the trusted source for verification.', 'signed-posts' ); ?> |
|---|
| 146 | </p> |
|---|
| 147 | </td> |
|---|
| 148 | </tr> |
|---|
| 149 | </table> |
|---|
| 150 | <?php |
|---|
| 151 | } |
|---|
| 152 | add_action( 'show_user_profile', 'wppgps_add_user_profile_fields' ); |
|---|
| 153 | add_action( 'edit_user_profile', 'wppgps_add_user_profile_fields' ); |
|---|
| 154 | |
|---|
| 155 | /** |
|---|
| 156 | * Renders the Decentralized Identifier (DID) field in the user profile screen. |
|---|
| 157 | * |
|---|
| 158 | * The profile owner and administrators can edit the field; other users see it |
|---|
| 159 | * as read-only. |
|---|
| 160 | * |
|---|
| 161 | * @param WP_User $user The user object whose profile is being displayed. |
|---|
| 162 | * @return void |
|---|
| 163 | */ |
|---|
| 164 | function wppgps_add_user_profile_fields_did( WP_User $user ): void { |
|---|
| 165 | $did = (string) get_user_meta( $user->ID, 'did_identifier', true ); |
|---|
| 166 | $readonly_attr = ( (int) $user->ID === get_current_user_id() || current_user_can( 'manage_options' ) ) ? '' : 'readonly'; |
|---|
| 167 | ?> |
|---|
| 168 | <h3><?php esc_html_e( 'Decentralized Identifiers (DID)', 'signed-posts' ); ?></h3> |
|---|
| 169 | <table class="form-table" role="presentation"> |
|---|
| 170 | <tr> |
|---|
| 171 | <th><label for="did_identifier"><?php esc_html_e( 'Your DID (did:key or did:web)', 'signed-posts' ); ?></label></th> |
|---|
| 172 | <td> |
|---|
| 173 | <input type="text" name="did_identifier" id="did_identifier" value="<?php echo esc_attr( $did ); ?>" class="regular-text" <?php echo esc_attr( $readonly_attr ); ?> placeholder="did:key:z6Mk... or did:web:example.com" /> |
|---|
| 174 | <p class="description"> |
|---|
| 175 | <?php esc_html_e( 'Used for verifying detached JWS (Ed25519). For did:web, ensure a valid https://<host>/.well-known/did.json exists.', 'signed-posts' ); ?> |
|---|
| 176 | </p> |
|---|
| 177 | </td> |
|---|
| 178 | </tr> |
|---|
| 179 | </table> |
|---|
| 180 | <?php |
|---|
| 181 | } // <--- LLAVE CORREGIDA AQUÍ |
|---|
| 182 | add_action( 'show_user_profile', 'wppgps_add_user_profile_fields_did', 11 ); |
|---|
| 183 | add_action( 'edit_user_profile', 'wppgps_add_user_profile_fields_did', 11 ); |
|---|
| 184 | |
|---|
| 185 | /** |
|---|
| 186 | * Saves the OpenPGP key URL and DID identifier from the user profile form. |
|---|
| 187 | * |
|---|
| 188 | * The OpenPGP URL can only be changed by the profile owner. The DID can be |
|---|
| 189 | * changed by the profile owner or a site administrator. |
|---|
| 190 | * |
|---|
| 191 | * @param int $user_id The ID of the user whose profile is being saved. |
|---|
| 192 | * @return void |
|---|
| 193 | */ |
|---|
| 194 | function wppgps_save_user_profile_fields( int $user_id ): void { // <--- TIPO CORREGIDO A VOID |
|---|
| 195 | if ( ! current_user_can( 'edit_user', $user_id ) ) { |
|---|
| 196 | return; |
|---|
| 197 | } |
|---|
| 198 | if ( ! isset( $_POST['_wpnonce'] ) ) { |
|---|
| 199 | return; |
|---|
| 200 | } |
|---|
| 201 | $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ); |
|---|
| 202 | if ( ! wp_verify_nonce( $nonce, 'update-user_' . $user_id ) ) { |
|---|
| 203 | return; |
|---|
| 204 | } |
|---|
| 205 | |
|---|
| 206 | // OpenPGP URL: only the profile owner may change it. |
|---|
| 207 | if ( (int) $user_id === get_current_user_id() && isset( $_POST['pgp_key_url'] ) ) { |
|---|
| 208 | $url = esc_url_raw( wp_unslash( $_POST['pgp_key_url'] ) ); |
|---|
| 209 | if ( $url && wp_is_valid_url( $url ) ) { |
|---|
| 210 | update_user_meta( $user_id, 'pgp_key_url', $url ); |
|---|
| 211 | } |
|---|
| 212 | } |
|---|
| 213 | |
|---|
| 214 | // DID: profile owner or administrators may change it. |
|---|
| 215 | if ( isset( $_POST['did_identifier'] ) && ( (int) $user_id === get_current_user_id() || current_user_can( 'manage_options' ) ) ) { |
|---|
| 216 | $val = sanitize_text_field( wp_unslash( $_POST['did_identifier'] ) ); |
|---|
| 217 | if ( '' === $val || preg_match( '#^did:(key|web):#', $val ) ) { |
|---|
| 218 | update_user_meta( $user_id, 'did_identifier', $val ); |
|---|
| 219 | } |
|---|
| 220 | } |
|---|
| 221 | } |
|---|
| 222 | add_action( 'personal_options_update', 'wppgps_save_user_profile_fields' ); |
|---|
| 223 | add_action( 'edit_user_profile_update', 'wppgps_save_user_profile_fields' ); |
|---|
| 224 | |
|---|
| 225 | // ----------------------------------------------------------- |
|---|
| 226 | // 1.1 ADMIN: Uninstall cleanup preferences UI |
|---|
| 227 | // ----------------------------------------------------------- |
|---|
| 228 | |
|---|
| 229 | /** |
|---|
| 230 | * Renders the uninstall cleanup preference checkboxes in the user profile screen. |
|---|
| 231 | * |
|---|
| 232 | * Only visible to administrators (manage_options capability). The checkboxes |
|---|
| 233 | * control which plugin data is deleted when the plugin is uninstalled. |
|---|
| 234 | * |
|---|
| 235 | * @param WP_User $user The user object whose profile is being displayed. |
|---|
| 236 | * @return void |
|---|
| 237 | */ |
|---|
| 238 | function wppgps_render_cleanup_checkboxes( WP_User $user ): void { |
|---|
| 239 | if ( ! current_user_can( 'manage_options' ) ) { |
|---|
| 240 | return; |
|---|
| 241 | } |
|---|
| 242 | $del_sigs = get_option( 'wppgps_delete_post_signatures', '0' ) === '1'; |
|---|
| 243 | $del_urls = get_option( 'wppgps_delete_user_key_urls', '0' ) === '1'; |
|---|
| 244 | $del_dids = get_option( 'wppgps_delete_user_did_identifiers', '0' ) === '1'; |
|---|
| 245 | ?> |
|---|
| 246 | <h2><?php esc_html_e( 'Cleanup preferences for Signed Posts', 'signed-posts' ); ?></h2> |
|---|
| 247 | <table class="form-table" role="presentation"> |
|---|
| 248 | <tr> |
|---|
| 249 | <th scope="row"><?php esc_html_e( 'On plugin uninstall', 'signed-posts' ); ?></th> |
|---|
| 250 | <td> |
|---|
| 251 | <fieldset> |
|---|
| 252 | <?php wp_nonce_field( 'wppgps_cleanup_save', 'wppgps_cleanup_nonce' ); ?> |
|---|
| 253 | <p> |
|---|
| 254 | <label for="wppgps_delete_user_key_urls"> |
|---|
| 255 | <input type="checkbox" name="wppgps_delete_user_key_urls" id="wppgps_delete_user_key_urls" value="1" <?php checked( $del_urls ); ?> /> |
|---|
| 256 | <?php esc_html_e( 'Delete all public key URLs stored in user meta', 'signed-posts' ); ?> |
|---|
| 257 | </label> |
|---|
| 258 | </p> |
|---|
| 259 | <p> |
|---|
| 260 | <label for="wppgps_delete_user_did_identifiers" style="margin-left: 12px;"> |
|---|
| 261 | <input type="checkbox" name="wppgps_delete_user_did_identifiers" id="wppgps_delete_user_did_identifiers" value="1" <?php checked( $del_dids ); ?> /> |
|---|
| 262 | <?php esc_html_e( 'Delete all DID identifiers stored in user meta', 'signed-posts' ); ?> |
|---|
| 263 | </label> |
|---|
| 264 | </p> |
|---|
| 265 | <p> |
|---|
| 266 | <label for="wppgps_delete_post_signatures" style="margin-left: 12px;"> |
|---|
| 267 | <input type="checkbox" name="wppgps_delete_post_signatures" id="wppgps_delete_post_signatures" value="1" <?php checked( $del_sigs ); ?> /> |
|---|
| 268 | <?php esc_html_e( 'Delete all signatures stored in post meta. You will need to re-sign and paste the signature on all posts if you check this box.', 'signed-posts' ); ?> |
|---|
| 269 | </label> |
|---|
| 270 | </p><br /> |
|---|
| 271 | </fieldset> |
|---|
| 272 | </td> |
|---|
| 273 | </tr> |
|---|
| 274 | </table> |
|---|
| 275 | <?php |
|---|
| 276 | } |
|---|
| 277 | add_action( 'show_user_profile', 'wppgps_render_cleanup_checkboxes', 20 ); |
|---|
| 278 | add_action( 'edit_user_profile', 'wppgps_render_cleanup_checkboxes', 20 ); |
|---|
| 279 | |
|---|
| 280 | /** |
|---|
| 281 | * Saves the uninstall cleanup preferences submitted from the user profile form. |
|---|
| 282 | * |
|---|
| 283 | * Requires the manage_options capability and a valid nonce. Each preference |
|---|
| 284 | * defaults to '0' (preserve data) when its checkbox is not present in the |
|---|
| 285 | * POST data. |
|---|
| 286 | * |
|---|
| 287 | * @param int $user_id The ID of the user whose profile form was submitted. |
|---|
| 288 | * @return void |
|---|
| 289 | */ |
|---|
| 290 | function wppgps_save_cleanup_checkboxes( int $user_id ): void { |
|---|
| 291 | if ( ! current_user_can( 'manage_options' ) ) { |
|---|
| 292 | return; |
|---|
| 293 | } |
|---|
| 294 | $nonce = isset( $_POST['wppgps_cleanup_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['wppgps_cleanup_nonce'] ) ) : ''; |
|---|
| 295 | if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wppgps_cleanup_save' ) ) { |
|---|
| 296 | return; |
|---|
| 297 | } |
|---|
| 298 | update_option( 'wppgps_delete_post_signatures', isset( $_POST['wppgps_delete_post_signatures'] ) ? '1' : '0' ); |
|---|
| 299 | update_option( 'wppgps_delete_user_key_urls', isset( $_POST['wppgps_delete_user_key_urls'] ) ? '1' : '0' ); |
|---|
| 300 | update_option( 'wppgps_delete_user_did_identifiers', isset( $_POST['wppgps_delete_user_did_identifiers'] ) ? '1' : '0' ); |
|---|
| 301 | } |
|---|
| 302 | add_action( 'personal_options_update', 'wppgps_save_cleanup_checkboxes' ); |
|---|
| 303 | add_action( 'edit_user_profile_update', 'wppgps_save_cleanup_checkboxes' ); |
|---|
| 304 | |
|---|
| 305 | // ----------------------------------------------------------- |
|---|
| 306 | // 2. BACKEND: Metabox (signature + method selector) |
|---|
| 307 | // ----------------------------------------------------------- |
|---|
| 308 | |
|---|
| 309 | /** |
|---|
| 310 | * Registers the Signed Posts metabox on the post edit screen. |
|---|
| 311 | * |
|---|
| 312 | * @return void |
|---|
| 313 | */ |
|---|
| 314 | function wppgps_add_metabox(): void { |
|---|
| 315 | add_meta_box( |
|---|
| 316 | 'wppgps_signature_box', |
|---|
| 317 | esc_html__( 'Signed Posts', 'signed-posts' ), |
|---|
| 318 | 'wppgps_metabox_callback', |
|---|
| 319 | 'post', |
|---|
| 320 | 'normal', |
|---|
| 321 | 'high' |
|---|
| 322 | ); |
|---|
| 323 | } |
|---|
| 324 | add_action( 'add_meta_boxes', 'wppgps_add_metabox' ); |
|---|
| 325 | |
|---|
| 326 | /** |
|---|
| 327 | * Renders the content of the Signed Posts metabox. |
|---|
| 328 | * |
|---|
| 329 | * Displays the signature method selector, the current verification source, |
|---|
| 330 | * and the signature textarea. Only the post author can edit the signature |
|---|
| 331 | * field; other users see it as read-only. |
|---|
| 332 | * |
|---|
| 333 | * @param WP_Post $post The post object being edited. |
|---|
| 334 | * @return void |
|---|
| 335 | */ |
|---|
| 336 | function wppgps_metabox_callback( WP_Post $post ): void { |
|---|
| 337 | wp_nonce_field( 'wppgps_save_data', 'wppgps_nonce' ); |
|---|
| 338 | |
|---|
| 339 | $sig = (string) get_post_meta( $post->ID, '_wppgps_signature', true ); |
|---|
| 340 | $method = (string) get_post_meta( $post->ID, '_wppgps_sig_method', true ); |
|---|
| 341 | if ( ! in_array( $method, array( 'openpgp', 'did:key', 'did:web' ), true ) ) { |
|---|
| 342 | $method = 'openpgp'; |
|---|
| 343 | } |
|---|
| 344 | |
|---|
| 345 | $post_author_id = (int) $post->post_author; |
|---|
| 346 | $current_user_id = get_current_user_id(); |
|---|
| 347 | $is_author = ( $post_author_id === $current_user_id ); |
|---|
| 348 | $pgp_key_url = (string) get_user_meta( $post_author_id, 'pgp_key_url', true ); |
|---|
| 349 | $did_id = (string) get_user_meta( $post_author_id, 'did_identifier', true ); |
|---|
| 350 | ?> |
|---|
| 351 | <p> |
|---|
| 352 | <label for="wppgps_sig_method"><strong><?php esc_html_e( 'Signature method', 'signed-posts' ); ?></strong></label><br/> |
|---|
| 353 | <select id="wppgps_sig_method" name="wppgps_sig_method"> |
|---|
| 354 | <option value="openpgp" <?php selected( $method, 'openpgp' ); ?>>OpenPGP</option> |
|---|
| 355 | <option value="did:key" <?php selected( $method, 'did:key' ); ?>>DID (did:key)</option> |
|---|
| 356 | <option value="did:web" <?php selected( $method, 'did:web' ); ?>>DID (did:web)</option> |
|---|
| 357 | </select> |
|---|
| 358 | </p> |
|---|
| 359 | <?php |
|---|
| 360 | if ( 'openpgp' === $method && empty( $pgp_key_url ) ) { |
|---|
| 361 | echo '<p style="color: red; font-weight: bold;">⚠️ ' . esc_html__( 'ERROR: The post author has NOT configured their OpenPGP Public Key URL.', 'signed-posts' ) . '</p>'; |
|---|
| 362 | echo '<p>' . esc_html__( 'Please edit your', 'signed-posts' ) . ' <a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'User Profile', 'signed-posts' ) . '</a> ' . esc_html__( 'to add your key URL before signing.', 'signed-posts' ) . '</p>'; |
|---|
| 363 | } |
|---|
| 364 | if ( ( 'did:key' === $method || 'did:web' === $method ) && empty( $did_id ) ) { |
|---|
| 365 | echo '<p style="color: #d98300; font-weight: bold;">⚠️ ' . esc_html__( 'WARNING: The post author has NOT configured a DID in their profile.', 'signed-posts' ) . '</p>'; |
|---|
| 366 | } |
|---|
| 367 | $readonly = $is_author ? '' : 'readonly'; |
|---|
| 368 | ?> |
|---|
| 369 | <p> |
|---|
| 370 | <strong><?php esc_html_e( 'Verification source:', 'signed-posts' ); ?></strong> |
|---|
| 371 | <code><?php echo esc_html( ( 'openpgp' === $method ? $pgp_key_url : $did_id ) ?: '—' ); ?></code> |
|---|
| 372 | </p> |
|---|
| 373 | <label for="wppgps_signature"><?php esc_html_e( 'Content signature (OpenPGP ASCII armor OR JWS compact detached, depending on method):', 'signed-posts' ); ?></label> |
|---|
| 374 | <textarea id="wppgps_signature" name="wppgps_signature" rows="8" style="width:100%;" placeholder="<?php echo ( 'openpgp' === $method ) ? esc_attr__( '-----BEGIN PGP SIGNATURE-----...', 'signed-posts' ) : esc_attr__( 'eyJhbGciOiJFZERTQSIsImI2NCI6ZmFsc2UsImtpZCI6ImRpZDp...<detached JWS>', 'signed-posts' ); ?>" <?php echo esc_attr( $readonly ); ?>><?php echo esc_textarea( $sig ); ?></textarea> |
|---|
| 375 | <p style="font-size: 0.9em; color: #555;"> |
|---|
| 376 | *<?php esc_html_e( 'Only the original author can save or modify the signature. This prevents manipulation by Editors or Administrators.', 'signed-posts' ); ?>* |
|---|
| 377 | </p> |
|---|
| 378 | <?php |
|---|
| 379 | } |
|---|
| 380 | |
|---|
| 381 | /** |
|---|
| 382 | * Saves the signature method and signature value from the post edit screen. |
|---|
| 383 | * |
|---|
| 384 | * Only the original post author can save or modify signature data. Runs on |
|---|
| 385 | * save_post but skips autosaves. |
|---|
| 386 | * |
|---|
| 387 | * @param int $post_id The ID of the post being saved. |
|---|
| 388 | * @return int The post ID, unchanged. |
|---|
| 389 | */ |
|---|
| 390 | function wppgps_save_post_data( int $post_id ): int { |
|---|
| 391 | if ( ! isset( $_POST['wppgps_nonce'] ) ) { |
|---|
| 392 | return $post_id; |
|---|
| 393 | } |
|---|
| 394 | $nonce = sanitize_text_field( wp_unslash( $_POST['wppgps_nonce'] ) ); |
|---|
| 395 | if ( ! wp_verify_nonce( $nonce, 'wppgps_save_data' ) ) { |
|---|
| 396 | return $post_id; |
|---|
| 397 | } |
|---|
| 398 | if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { |
|---|
| 399 | return $post_id; |
|---|
| 400 | } |
|---|
| 401 | $post_author_id = (int) get_post_field( 'post_author', $post_id ); |
|---|
| 402 | if ( get_current_user_id() !== $post_author_id ) { |
|---|
| 403 | return $post_id; |
|---|
| 404 | } |
|---|
| 405 | if ( isset( $_POST['wppgps_sig_method'] ) ) { |
|---|
| 406 | $method = sanitize_text_field( wp_unslash( $_POST['wppgps_sig_method'] ) ); |
|---|
| 407 | if ( in_array( $method, array( 'openpgp', 'did:key', 'did:web' ), true ) ) { |
|---|
| 408 | update_post_meta( $post_id, '_wppgps_sig_method', $method ); |
|---|
| 409 | } |
|---|
| 410 | } |
|---|
| 411 | if ( isset( $_POST['wppgps_signature'] ) ) { |
|---|
| 412 | update_post_meta( |
|---|
| 413 | $post_id, |
|---|
| 414 | '_wppgps_signature', |
|---|
| 415 | sanitize_textarea_field( wp_unslash( $_POST['wppgps_signature'] ) ) |
|---|
| 416 | ); |
|---|
| 417 | } |
|---|
| 418 | return $post_id; |
|---|
| 419 | } |
|---|
| 420 | add_action( 'save_post', 'wppgps_save_post_data' ); |
|---|
| 421 | |
|---|
| 422 | // ----------------------------------------------------------- |
|---|
| 423 | // 3. FRONTEND: Enqueue, Signature block & Author badge |
|---|
| 424 | // ----------------------------------------------------------- |
|---|
| 425 | |
|---|
| 426 | /** |
|---|
| 427 | * Enqueues front-end styles and scripts for signature verification. |
|---|
| 428 | * |
|---|
| 429 | * Only runs on singular post views. Loads the appropriate verification script |
|---|
| 430 | * (OpenPGP.js or DID/WebCrypto) based on the post's signature method, and |
|---|
| 431 | * passes the required data to JavaScript via wp_localize_script. |
|---|
| 432 | * |
|---|
| 433 | * @return void |
|---|
| 434 | */ |
|---|
| 435 | function wppgps_enqueue_scripts(): void { |
|---|
| 436 | if ( ! is_singular( 'post' ) ) { |
|---|
| 437 | return; |
|---|
| 438 | } |
|---|
| 439 | global $post; |
|---|
| 440 | $data = wppgps_get_post_signature_data( (int) $post->ID ); |
|---|
| 441 | |
|---|
| 442 | if ( empty( $data['signature'] ) || ! $data['has_identity'] ) { |
|---|
| 443 | return; |
|---|
| 444 | } |
|---|
| 445 | |
|---|
| 446 | wp_enqueue_style( 'wppgps-styles', plugin_dir_url( __FILE__ ) . 'signed-posts.css', array(), '0.5', 'all' ); |
|---|
| 447 | if ( ! wp_style_is( 'dashicons', 'enqueued' ) ) { |
|---|
| 448 | wp_enqueue_style( 'dashicons' ); |
|---|
| 449 | } |
|---|
| 450 | |
|---|
| 451 | $message = wppgps_normalize_post_content_for_signing( (int) $post->ID ); |
|---|
| 452 | |
|---|
| 453 | if ( 'openpgp' === $data['method'] ) { |
|---|
| 454 | wp_enqueue_script( 'openpgp-js', plugin_dir_url( __FILE__ ) . 'openpgp.min.js', array(), '6.2.2', true ); |
|---|
| 455 | wp_enqueue_script( 'wppgps-verifier', plugin_dir_url( __FILE__ ) . 'signed-posts.js', array( 'openpgp-js' ), '0.5', true ); |
|---|
| 456 | wp_localize_script( |
|---|
| 457 | 'wppgps-verifier', |
|---|
| 458 | 'wppgpsData', |
|---|
| 459 | array( |
|---|
| 460 | 'postId' => (int) $post->ID, |
|---|
| 461 | 'method' => 'openpgp', |
|---|
| 462 | 'message' => $message, |
|---|
| 463 | 'signature' => $data['signature'], |
|---|
| 464 | 'publicKeyUrl' => $data['pgp_key_url'], |
|---|
| 465 | 'badgeToken' => '[[WPPGPS_BADGE:' . (int) $post->ID . ']]', |
|---|
| 466 | ) |
|---|
| 467 | ); |
|---|
| 468 | } else { |
|---|
| 469 | wp_enqueue_script( 'wppgps-did', plugin_dir_url( __FILE__ ) . 'signed-posts.did.js', array(), '0.5', true ); |
|---|
| 470 | wp_localize_script( |
|---|
| 471 | 'wppgps-did', |
|---|
| 472 | 'wppgpsData', |
|---|
| 473 | array( |
|---|
| 474 | 'postId' => (int) $post->ID, |
|---|
| 475 | 'method' => $data['method'], |
|---|
| 476 | 'message' => $message, |
|---|
| 477 | 'signature' => $data['signature'], |
|---|
| 478 | 'did' => $data['did_identifier'], |
|---|
| 479 | 'badgeToken' => '[[WPPGPS_BADGE:' . (int) $post->ID . ']]', |
|---|
| 480 | ) |
|---|
| 481 | ); |
|---|
| 482 | } |
|---|
| 483 | } |
|---|
| 484 | add_action( 'wp_enqueue_scripts', 'wppgps_enqueue_scripts' ); |
|---|
| 485 | |
|---|
| 486 | /** |
|---|
| 487 | * Appends the signature verification status block to the post content. |
|---|
| 488 | * |
|---|
| 489 | * Only runs on singular post views, within the main loop. The block is |
|---|
| 490 | * populated client-side by the verification script. |
|---|
| 491 | * |
|---|
| 492 | * @param string $content The original post content. |
|---|
| 493 | * @return string The post content with the signature block appended, or the |
|---|
| 494 | * original content if the post is not signed. |
|---|
| 495 | */ |
|---|
| 496 | function wppgps_append_signature_block( string $content ): string { |
|---|
| 497 | if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) { |
|---|
| 498 | return $content; |
|---|
| 499 | } |
|---|
| 500 | global $post; |
|---|
| 501 | $data = wppgps_get_post_signature_data( (int) $post->ID ); |
|---|
| 502 | |
|---|
| 503 | if ( empty( $data['signature'] ) || ! $data['has_identity'] ) { |
|---|
| 504 | return $content; |
|---|
| 505 | } |
|---|
| 506 | |
|---|
| 507 | $signature_block = ' |
|---|
| 508 | <div id="pgp-signature-block" class="pgp-signature-block"> |
|---|
| 509 | <div class="pgp-header"> |
|---|
| 510 | <h3 class="pgp-title">' . esc_html__( 'Status of the Signature', 'signed-posts' ) . '</h3> |
|---|
| 511 | <span class="pgp-icon">🔒</span> |
|---|
| 512 | </div> |
|---|
| 513 | <div class="pgp-content"> |
|---|
| 514 | <div id="pgp-verification-result" class="pgp-status-container"> |
|---|
| 515 | <span class="pgp-status-text pgp-status-pending">' . esc_html__( 'Starting verification...', 'signed-posts' ) . '</span> |
|---|
| 516 | </div> |
|---|
| 517 | <div class="pgp-details"> |
|---|
| 518 | <p> |
|---|
| 519 | <span class="pgp-detail-label">' . esc_html__( 'Method:', 'signed-posts' ) . '</span> |
|---|
| 520 | <span id="pgp-method-value" class="pgp-detail-value">' . ( 'openpgp' === $data['method'] ? esc_html__( 'Verified in your browser with OpenPGP.js', 'signed-posts' ) : esc_html__( 'Verified in your browser with DID (Ed25519 JWS)', 'signed-posts' ) ) . '</span> |
|---|
| 521 | </p> |
|---|
| 522 | <p> |
|---|
| 523 | <span class="pgp-detail-label">' . esc_html__( 'Source:', 'signed-posts' ) . '</span> |
|---|
| 524 | <span id="pgp-key-url-link" class="pgp-detail-value pgp-link" style="word-break: break-all;"></span> |
|---|
| 525 | </p> |
|---|
| 526 | <p class="pgp-result-message"> |
|---|
| 527 | <span class="pgp-detail-label">' . esc_html__( 'Result:', 'signed-posts' ) . '</span> |
|---|
| 528 | <span id="pgp-result-details" class="pgp-detail-value"></span> |
|---|
| 529 | </p> |
|---|
| 530 | </div> |
|---|
| 531 | </div> |
|---|
| 532 | </div> |
|---|
| 533 | '; |
|---|
| 534 | |
|---|
| 535 | return $content . $signature_block; |
|---|
| 536 | } |
|---|
| 537 | add_filter( 'the_content', 'wppgps_append_signature_block' ); |
|---|
| 538 | |
|---|
| 539 | // ----------------------------------------------------------- |
|---|
| 540 | // 3.1 AUTHOR BADGE |
|---|
| 541 | // ----------------------------------------------------------- |
|---|
| 542 | |
|---|
| 543 | /** |
|---|
| 544 | * Determines whether the author badge should be appended to author output. |
|---|
| 545 | * |
|---|
| 546 | * Returns true only on singular post views where the post has a signature and |
|---|
| 547 | * the author has the required identity configured (OpenPGP URL or DID). |
|---|
| 548 | * |
|---|
| 549 | * @return bool True if the badge should be appended; false otherwise. |
|---|
| 550 | */ |
|---|
| 551 | function wppgps_should_append_badge(): bool { |
|---|
| 552 | if ( ! is_singular( 'post' ) ) { |
|---|
| 553 | return false; |
|---|
| 554 | } |
|---|
| 555 | global $post; |
|---|
| 556 | if ( ! $post ) { |
|---|
| 557 | return false; |
|---|
| 558 | } |
|---|
| 559 | $data = wppgps_get_post_signature_data( (int) $post->ID ); |
|---|
| 560 | return ( ! empty( $data['signature'] ) && $data['has_identity'] ); |
|---|
| 561 | } |
|---|
| 562 | |
|---|
| 563 | /** |
|---|
| 564 | * Returns the placeholder token string used to locate the badge insertion point. |
|---|
| 565 | * |
|---|
| 566 | * @param int $post_id The post ID the badge belongs to. |
|---|
| 567 | * @return string The badge placeholder token. |
|---|
| 568 | */ |
|---|
| 569 | function wppgps_get_author_badge_token( int $post_id ): string { |
|---|
| 570 | return '[[WPPGPS_BADGE:' . $post_id . ']]'; |
|---|
| 571 | } |
|---|
| 572 | |
|---|
| 573 | /** |
|---|
| 574 | * Appends the author badge placeholder token to author display output. |
|---|
| 575 | * |
|---|
| 576 | * The token is later replaced with the actual badge HTML by the front-end |
|---|
| 577 | * verification script. Skips appending if a token is already present in the |
|---|
| 578 | * output to avoid duplication. |
|---|
| 579 | * |
|---|
| 580 | * @param string $output The current author display string (name or link). |
|---|
| 581 | * @return string The output with the badge token appended, or unchanged. |
|---|
| 582 | */ |
|---|
| 583 | function wppgps_append_badge_to_output( string $output ): string { |
|---|
| 584 | if ( ! wppgps_should_append_badge() ) { |
|---|
| 585 | return $output; |
|---|
| 586 | } |
|---|
| 587 | if ( false !== strpos( $output, '[[WPPGPS_BADGE:' ) ) { |
|---|
| 588 | return $output; |
|---|
| 589 | } |
|---|
| 590 | global $post; |
|---|
| 591 | return $output . wppgps_get_author_badge_token( (int) $post->ID ); |
|---|
| 592 | } |
|---|
| 593 | |
|---|
| 594 | add_filter( 'the_author_posts_link', 'wppgps_append_badge_to_output', 20 ); |
|---|
| 595 | add_filter( 'the_author', 'wppgps_append_badge_to_output', 20 ); |
|---|
| 596 | add_filter( 'get_the_author', 'wppgps_append_badge_to_output', 20 ); |
|---|
| 597 | add_filter( 'get_the_author_display_name', 'wppgps_append_badge_to_output', 20 ); |
|---|