hash・試行回数・最後の失敗。成功したら消す define( 'DATESEALED_RULE', 2 ); // 本文ハッシュの作り方の版(readme の「How is the hash made」) /* ---------- 設定 ---------- */ function datesealed_options() { $o = wp_parse_args( get_option( 'datesealed_options', array() ), array( 'api_base' => DATESEALED_DEFAULT_API, 'auto' => 1, // 公開・更新のたびに自動で記録 'image' => 0, // アイキャッチ画像も記録 'badge' => 'after', // after / off 'post_types' => array( 'post' ), ) ); $o['post_types'] = array_values( array_diff( (array) $o['post_types'], array( 'attachment' ) ) ); // 0.1.0 で選べた「メディア」は記録されないので外す return $o; } /** 選べる投稿タイプ(公開型。添付ファイルは状態が inherit で公開にならないので除く) */ function datesealed_post_types() { $types = get_post_types( array( 'public' => true ), 'objects' ); unset( $types['attachment'] ); return $types; } /** WordPress の言語 → DateSealed の言語接頭辞(英語は接頭辞なし。未対応の言語も英語) */ function datesealed_lang_prefix() { $map = array( 'ja', 'ru', 'ko', 'es', 'pt', 'fr', 'de', 'it', 'id', 'vi', 'th', 'tr', 'ar', 'hi', 'pl', 'nl', 'uk', 'zh' ); $locale = strtolower( str_replace( '-', '_', get_locale() ) ); if ( in_array( $locale, array( 'zh_tw', 'zh_hk' ), true ) ) { return '/zh-tw'; } $code = substr( $locale, 0, 2 ); return in_array( $code, $map, true ) ? '/' . $code : ''; } function datesealed_cert_url( $id ) { return datesealed_api_base() . datesealed_lang_prefix() . '/c/' . $id; } function datesealed_api_base() { $o = datesealed_options(); return untrailingslashit( esc_url_raw( $o['api_base'] ) ); } function datesealed_user_agent() { return 'DateSealed-WP/' . DATESEALED_VERSION . '; ' . home_url(); } add_action( 'admin_menu', function () { add_options_page( 'DateSealed', 'DateSealed', 'manage_options', 'datesealed', 'datesealed_settings_page' ); } ); add_action( 'admin_init', function () { register_setting( 'datesealed', 'datesealed_options', array( 'sanitize_callback' => 'datesealed_sanitize' ) ); } ); function datesealed_sanitize( $in ) { $types = array_keys( datesealed_post_types() ); $sel = isset( $in['post_types'] ) && is_array( $in['post_types'] ) ? array_values( array_intersect( $types, array_map( 'sanitize_key', $in['post_types'] ) ) ) : array( 'post' ); $base = isset( $in['api_base'] ) ? esc_url_raw( trim( $in['api_base'] ) ) : DATESEALED_DEFAULT_API; return array( 'api_base' => $base ? $base : DATESEALED_DEFAULT_API, 'auto' => empty( $in['auto'] ) ? 0 : 1, 'image' => empty( $in['image'] ) ? 0 : 1, 'badge' => ( isset( $in['badge'] ) && 'off' === $in['badge'] ) ? 'off' : 'after', 'post_types' => $sel ? $sel : array( 'post' ), ); } function datesealed_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $o = datesealed_options(); $types = datesealed_post_types(); ?>
post_title . "\n" . $post->post_content; $text = preg_replace( '//s', ' ', $text ); $text = preg_replace( '@<(script|style)\b[^>]*>.*?\1\s*>@is', ' ', $text ); $text = preg_replace( '/<[^>]*>/', ' ', $text ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); if ( class_exists( 'Normalizer' ) ) { $n = Normalizer::normalize( $text, Normalizer::FORM_C ); if ( false !== $n ) { $text = $n; } } return trim( preg_replace( '/\s+/u', ' ', $text ) ); } function datesealed_post_hash( $post ) { return hash( 'sha256', datesealed_post_text( $post ) ); } function datesealed_image_hash( $post_id ) { $thumb = get_post_thumbnail_id( $post_id ); if ( ! $thumb ) { return ''; } $file = get_attached_file( $thumb ); return ( $file && is_readable( $file ) ) ? (string) hash_file( 'sha256', $file ) : ''; } /* ---------- 送信待ちの管理(WP-Cron で非同期。公開操作を遅くしない) ---------- */ add_action( 'wp_after_insert_post', function ( $post_id, $post, $update ) { $o = datesealed_options(); if ( ! $o['auto'] || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( 'publish' !== $post->post_status || ! in_array( $post->post_type, $o['post_types'], true ) ) { return; } datesealed_queue( $post ); }, 20, 3 ); function datesealed_pending( $post_id ) { $p = get_post_meta( $post_id, DATESEALED_PENDING, true ); return is_array( $p ) ? $p : array(); } function datesealed_save_pending( $post_id, $p ) { if ( $p ) { update_post_meta( $post_id, DATESEALED_PENDING, $p ); } else { delete_post_meta( $post_id, DATESEALED_PENDING ); } } function datesealed_queue( $post, $force = false ) { $o = datesealed_options(); datesealed_want( $post->ID, 'text', datesealed_post_hash( $post ), $force ); if ( $o['image'] ) { $ih = datesealed_image_hash( $post->ID ); if ( $ih ) { datesealed_want( $post->ID, 'image', $ih, $force ); } } } /** このハッシュを記録したい。記録済みなら何もしない。送信待ちに入れて、送れる時刻に予約する */ function datesealed_want( $post_id, $kind, $hash, $force = false ) { $last = get_post_meta( $post_id, 'image' === $kind ? '_datesealed_last_image' : DATESEALED_LAST, true ); $p = datesealed_pending( $post_id ); if ( ! $force && $last === $hash ) { if ( isset( $p[ $kind ] ) ) { // 編集を元に戻した: 待っていた別のハッシュは要らない unset( $p[ $kind ] ); datesealed_save_pending( $post_id, $p ); } return; } if ( ! $force && isset( $p[ $kind ]['hash'] ) && $p[ $kind ]['hash'] === $hash ) { return; } $p[ $kind ] = array( 'hash' => $hash, 'attempt' => 0, 'since' => time(), 'error' => '' ); datesealed_save_pending( $post_id, $p ); datesealed_schedule( $post_id, $kind, datesealed_next_slot( 'image' === $kind ? 5 : 0 ) ); } /** 送ってよい最も早い時刻。上限(429)で断られたら、サーバーの Retry-After まで全体で待つ */ function datesealed_next_slot( $delay = 0 ) { $until = (int) get_option( 'datesealed_backoff_until', 0 ); return $until > time() ? $until + wp_rand( 0, 300 ) : time() + $delay; } function datesealed_schedule( $post_id, $kind, $when ) { $args = array( (int) $post_id, $kind ); $next = wp_next_scheduled( 'datesealed_stamp', $args ); if ( $next ) { if ( abs( $next - $when ) < 60 ) { return; } wp_unschedule_event( $next, 'datesealed_stamp', $args ); } wp_schedule_single_event( $when, 'datesealed_stamp', $args ); } /* ---------- 記録の発行 ---------- */ add_action( 'datesealed_stamp', 'datesealed_stamp', 10, 4 ); /** * 送信待ちの 1 件を送る。成功してから「記録済み」にする。 * 429 は Retry-After まで待って何度でも送り直す。それ以外の失敗は間隔を広げて 8 回まで、その後は 1 日 2 回の見回りで再開。 * 0.1.0 で予約された event は (post, hash, kind, attempt) の形なので、送信待ちに移してから送る。 */ function datesealed_stamp( $post_id, $kind = 'text', $legacy_kind = null, $legacy_attempt = null ) { $post_id = (int) $post_id; if ( preg_match( '/^[0-9a-f]{64}$/', (string) $kind ) ) { $hash = $kind; $kind = ( 'image' === $legacy_kind ) ? 'image' : 'text'; $p = datesealed_pending( $post_id ); if ( ! isset( $p[ $kind ] ) ) { $p[ $kind ] = array( 'hash' => $hash, 'attempt' => 0, 'since' => time(), 'error' => '' ); datesealed_save_pending( $post_id, $p ); } } $kind = ( 'image' === $kind ) ? 'image' : 'text'; $p = datesealed_pending( $post_id ); if ( empty( $p[ $kind ]['hash'] ) || ! preg_match( '/^[0-9a-f]{64}$/', $p[ $kind ]['hash'] ) ) { return; } if ( (int) get_option( 'datesealed_backoff_until', 0 ) > time() ) { datesealed_schedule( $post_id, $kind, datesealed_next_slot() ); return; } $hash = $p[ $kind ]['hash']; $res = wp_remote_post( datesealed_api_base() . '/api/v1/proofs', array( 'timeout' => 20, 'user-agent' => datesealed_user_agent(), 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'sha256' => $hash ) ), ) ); $code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res ); $body = is_wp_error( $res ) ? null : json_decode( wp_remote_retrieve_body( $res ), true ); if ( 201 === $code && is_array( $body ) && ! empty( $body['id'] ) && preg_match( '/^[0-9a-f]{16}$/', $body['id'] ) ) { $list = get_post_meta( $post_id, DATESEALED_META, true ); $list = is_array( $list ) ? $list : array(); $entry = array( 'id' => $body['id'], 'sha256' => $hash, 'kind' => $kind, 'time' => isset( $body['tsa_time'] ) ? sanitize_text_field( $body['tsa_time'] ) : gmdate( 'c' ), 'status' => isset( $body['ots_status'] ) ? sanitize_key( $body['ots_status'] ) : 'pending', ); if ( 'text' === $kind ) { $entry['rule'] = DATESEALED_RULE; } $list[] = $entry; update_post_meta( $post_id, DATESEALED_META, $list ); update_post_meta( $post_id, 'image' === $kind ? '_datesealed_last_image' : DATESEALED_LAST, $hash ); $p = datesealed_pending( $post_id ); if ( isset( $p[ $kind ]['hash'] ) && $p[ $kind ]['hash'] === $hash ) { // 送っている間に編集されたら、新しいハッシュは待たせたまま unset( $p[ $kind ] ); datesealed_save_pending( $post_id, $p ); } if ( 'pending' === $entry['status'] ) { datesealed_watch( $post_id, true ); } return; } $p = datesealed_pending( $post_id ); if ( ! isset( $p[ $kind ] ) ) { return; } $p[ $kind ]['error'] = $code ? (string) $code : 'network'; if ( 429 === $code ) { $wait = (int) wp_remote_retrieve_header( $res, 'retry-after' ); $wait = $wait > 0 ? min( $wait, 6 * HOUR_IN_SECONDS ) : HOUR_IN_SECONDS; update_option( 'datesealed_backoff_until', time() + $wait, false ); datesealed_save_pending( $post_id, $p ); datesealed_schedule( $post_id, $kind, datesealed_next_slot() ); return; } $p[ $kind ]['attempt'] = (int) $p[ $kind ]['attempt'] + 1; datesealed_save_pending( $post_id, $p ); if ( $p[ $kind ]['attempt'] <= 8 ) { // 10 分, 20 分, 40 分 … 最大 6 時間 datesealed_schedule( $post_id, $kind, time() + min( 600 * ( 2 ** ( $p[ $kind ]['attempt'] - 1 ) ), 6 * HOUR_IN_SECONDS ) ); } } /* ---------- 見回り(1 日 2 回): 送信待ちの再開と、ビットコインで確定したかの確認 ---------- */ register_activation_hook( __FILE__, function () { if ( ! wp_next_scheduled( 'datesealed_refresh' ) ) { wp_schedule_event( time() + 600, 'twicedaily', 'datesealed_refresh' ); } } ); register_deactivation_hook( __FILE__, function () { wp_unschedule_hook( 'datesealed_refresh' ); wp_unschedule_hook( 'datesealed_stamp' ); // 送信待ちは記事に残るので、有効にし直すと見回りが送り直す } ); /** 確定待ち(pending)の記録を持つ投稿の一覧。無い間は作り直す(0.1.0 からの更新時) */ function datesealed_watch( $post_id = 0, $add = true ) { $ids = get_option( 'datesealed_watch', null ); if ( ! is_array( $ids ) ) { $ids = array(); $q = new WP_Query( array( 'post_type' => 'any', 'post_status' => 'any', 'meta_key' => DATESEALED_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- 0.1.0 からの更新時に 1 回だけ 'posts_per_page' => 500, 'fields' => 'ids', 'no_found_rows' => true ) ); foreach ( $q->posts as $pid ) { foreach ( (array) get_post_meta( $pid, DATESEALED_META, true ) as $e ) { if ( isset( $e['status'] ) && 'pending' === $e['status'] ) { $ids[] = (int) $pid; break; } } } } if ( $post_id ) { $ids = $add ? array_merge( $ids, array( (int) $post_id ) ) : array_diff( $ids, array( (int) $post_id ) ); } $ids = array_values( array_unique( array_map( 'intval', $ids ) ) ); update_option( 'datesealed_watch', $ids, false ); return $ids; } function datesealed_pending_count() { $q = new WP_Query( array( 'post_type' => 'any', 'post_status' => 'any', 'meta_key' => DATESEALED_PENDING, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- 設定画面と見回りだけ 'posts_per_page' => 1, 'fields' => 'ids' ) ); return (int) $q->found_posts; } /** 版が上がったら、すぐに 1 回見回る(0.1.0 で送れなかった記事の拾い直しのため) */ add_action( 'admin_init', function () { if ( get_option( 'datesealed_version' ) !== DATESEALED_VERSION ) { update_option( 'datesealed_version', DATESEALED_VERSION, false ); wp_schedule_single_event( time() + 60, 'datesealed_refresh' ); } } ); /** 0.1.0 は送る前に「記録済み」の印を付けていたので、上限で断られた記事は印だけ残って記録が無い。それを送信待ちに戻す(1 回だけ) */ function datesealed_recover_010() { if ( get_option( 'datesealed_recovered_010' ) ) { return; } $o = datesealed_options(); $q = new WP_Query( array( 'post_type' => $o['post_types'], 'post_status' => 'publish', 'meta_key' => DATESEALED_LAST, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- 更新時に 1 回だけ 'posts_per_page' => 1000, 'no_found_rows' => true ) ); foreach ( $q->posts as $post ) { $e = datesealed_latest( $post->ID ); if ( ! $e || $e['sha256'] !== get_post_meta( $post->ID, DATESEALED_LAST, true ) ) { datesealed_want( $post->ID, 'text', datesealed_post_hash( $post ) ); } } update_option( 'datesealed_recovered_010', 1, false ); } add_action( 'datesealed_refresh', function () { datesealed_recover_010(); // 1) 送信待ちで予約が無いもの(無効化・失敗の打ち切り・0.1.0 からの更新)を送り直す $q = new WP_Query( array( 'post_type' => 'any', 'post_status' => 'any', 'meta_key' => DATESEALED_PENDING, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- 1 日 2 回 'posts_per_page' => 100, 'fields' => 'ids', 'no_found_rows' => true ) ); foreach ( $q->posts as $pid ) { $p = datesealed_pending( $pid ); foreach ( array_keys( $p ) as $kind ) { if ( ! wp_next_scheduled( 'datesealed_stamp', array( (int) $pid, $kind ) ) ) { $p[ $kind ]['attempt'] = 0; datesealed_schedule( $pid, $kind, datesealed_next_slot( wp_rand( 0, 600 ) ) ); } } datesealed_save_pending( $pid, $p ); } // 2) 確定待ちの記録だけを問い合わせる(1 回 30 件まで。tsa_only は変わらないので問い合わせない) $n = 0; foreach ( datesealed_watch() as $pid ) { $list = get_post_meta( $pid, DATESEALED_META, true ); if ( ! is_array( $list ) ) { datesealed_watch( $pid, false ); continue; } $changed = false; $left = false; foreach ( $list as $i => $e ) { if ( ! isset( $e['status'] ) || 'pending' !== $e['status'] ) { continue; } if ( $n >= 30 ) { $left = true; continue; } $n++; $res = wp_remote_get( datesealed_api_base() . '/api/v1/proofs/' . $e['id'], array( 'timeout' => 15, 'user-agent' => datesealed_user_agent() ) ); $st = $e['status']; if ( ! is_wp_error( $res ) && 200 === (int) wp_remote_retrieve_response_code( $res ) ) { $b = json_decode( wp_remote_retrieve_body( $res ), true ); if ( is_array( $b ) && ! empty( $b['ots_status'] ) ) { $st = sanitize_key( $b['ots_status'] ); } } if ( $st !== $e['status'] ) { $list[ $i ]['status'] = $st; $changed = true; } if ( 'pending' === $st ) { $left = true; } } if ( $changed ) { update_post_meta( $pid, DATESEALED_META, $list ); } if ( ! $left ) { datesealed_watch( $pid, false ); } } } ); /* ---------- バッジ(画像を配らず、インラインで出力。リンク文字は サイト名のみ) ---------- */ function datesealed_latest( $post_id, $kind = 'text' ) { $list = get_post_meta( $post_id, DATESEALED_META, true ); if ( ! is_array( $list ) ) { return null; } for ( $i = count( $list ) - 1; $i >= 0; $i-- ) { if ( isset( $list[ $i ]['kind'] ) && $kind === $list[ $i ]['kind'] ) { return $list[ $i ]; } } return null; } add_filter( 'the_content', function ( $content ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress コアのフィルター $o = datesealed_options(); if ( 'off' === $o['badge'] || ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $content; } $e = datesealed_latest( get_the_ID() ); if ( ! $e ) { return $content; } $when = mysql2date( get_option( 'date_format' ), get_date_from_gmt( gmdate( 'Y-m-d H:i:s', strtotime( $e['time'] ) ) ), true ); $url = datesealed_cert_url( $e['id'] ); $badge = sprintf( '%s %s DateSealed
', esc_html__( 'Publication date recorded:', 'datesealed' ), esc_html( $when ), esc_url( $url ) ); return $content . $badge; }, 20 ); /* ---------- 管理画面: 一覧の列と、編集画面の枠 ---------- */ add_filter( 'manage_posts_columns', function ( $cols ) { $cols['datesealed'] = 'DateSealed'; return $cols; } ); add_filter( 'manage_pages_columns', function ( $cols ) { $cols['datesealed'] = 'DateSealed'; return $cols; } ); function datesealed_column( $col, $post_id ) { if ( 'datesealed' !== $col ) { return; } $e = datesealed_latest( $post_id ); $p = datesealed_pending( $post_id ); if ( $e ) { $label = array( 'confirmed' => __( 'Anchored', 'datesealed' ), 'pending' => __( 'Accepted', 'datesealed' ), 'tsa_only' => __( 'Signed', 'datesealed' ) ); printf( '%s', esc_url( datesealed_cert_url( $e['id'] ) ), esc_html( isset( $label[ $e['status'] ] ) ? $label[ $e['status'] ] : $e['status'] ) ); } if ( isset( $p['text'] ) ) { echo $e ? '' . esc_html( $msg ) . '
'; } if ( ! $list ) { if ( ! $p ) { echo '' . esc_html__( 'No record yet. One is created when the post is published or updated.', 'datesealed' ) . '
'; } } else { echo '