prefix}equasign_ Engine: InnoDB (transactions required * for atomic sealing). Charset: from get_charset_collate(). * ───────────────────────────────────────────────────────────────────────── * equasign_tasks (the act; 1 row = 1 single-signer task) * id, opaque_reference[UNIQUE], external_reference, origin, caller, state, * channel, signer_name, signer_email, signer_phone, * redirect_url, document_type, language, * link_token[UNIQUE], validity_days, * created_at, issued_at, first_viewed_at, sealed_at, refused_at, * revoked_at, expires_at, * otp_hash, otp_expires_at, otp_sent_at, otp_attempts_remaining, otp_sent, * otp_validated, otp_channel, otp_destination, * signature_ip, signature_user_agent, * proof_available, proof_purged, signed_pdf_file, * signed_pdf_sha256, proof_file, send_sealed_copy, send_report_file, * license_tier, license_remaining, license_source, license_timestamp * equasign_documents (1..n per task) * id, task_id, position, name, source_sha256, signed_sha256, * zone_page, zone_x, zone_y, zone_width, zone_height, * signature_mode, applied_signature_file, source_file, size_bytes * equasign_checkboxes (0..n per document) * id, document_id, task_id, position, checkbox_key, label, required, * checked, checked_at * equasign_events (audit log / proof trail; no secrets) * id, task_id, type, data, ip, user_agent, created_at * equasign_idempotency ("create" idempotency, 24h retention) * id, idempotency_key[UNIQUE], opaque_reference, response, created_at * ───────────────────────────────────────────────────────────────────────── * * @package EquaSign */ defined( 'ABSPATH' ) || exit; class EquaSign_Installer { /** Capability for managing the EquaSign admin. */ const CAP = 'manage_equasign'; /** Private vault subfolder, under wp-content/uploads/. */ const PRIVATE_DIR = 'equasign-private'; /** * Activation: creates everything needed, idempotently. * * @return void */ public static function activate() { // create_tables() runs unconditionally on activation (the standard WordPress // self-heal: dbDelta re-creates any missing table). It is safe and output-free // on every path — the schema uses no reserved-word columns, so dbDelta finds // nothing to change on an up-to-date install (per-page-load runs stay gated in // upgrade_if_needed()). All of these are idempotent. self::create_tables(); self::create_vault(); self::seed_options(); self::add_capability(); self::maybe_autogenerate_kek(); // The signer page rewrite rule must exist before the flush. require_once EQUASIGN_DIR . 'public/class-equasign-signer-page.php'; EquaSign_Signer_Page::register_rules(); flush_rewrite_rules(); update_option( 'equasign_db_version', EQUASIGN_DB_VERSION ); } /** * On first activation, if NO KEK is configured, generate one and write it to * wp-config.php automatically. Idempotent: an existing KEK is never overwritten. * When wp-config.php is not writable, we flag a manual setup step instead of * blocking (the operator finishes it from Settings › Encryption key). No key is * ever stored outside wp-config.php — on the non-writable path nothing is kept. * * @return void */ private static function maybe_autogenerate_kek() { if ( EquaSign_Crypto::kek_present() ) { return; // Idempotent — never touch an existing key. } if ( ! class_exists( 'EquaSign_Kek' ) ) { require_once EQUASIGN_DIR . 'includes/class-equasign-kek.php'; } if ( EquaSign_Kek::wpconfig_writable() ) { $kek = EquaSign_Kek::generate(); $ok = EquaSign_Kek::write_to_wpconfig( $kek ); EquaSign_Crypto::wipe( $kek ); if ( $ok ) { delete_option( 'equasign_kek_setup_needed' ); delete_option( 'equasign_kek_backed_up' ); // Fresh key → not backed up: reminder will show. update_option( 'equasign_kek_autogen_notice', 1 ); return; } } // Not writable (or write refused) → guide the operator, store no key. update_option( 'equasign_kek_setup_needed', 1 ); } /** * Deactivation: nothing is destroyed (act vault). We only purge the * rewrite rules. * * @return void */ public static function deactivate() { require_once EQUASIGN_DIR . 'includes/class-equasign-purge.php'; EquaSign_Purge::unschedule(); flush_rewrite_rules(); } /** * Upgrades the schema when the stored version is outdated. Called on every * load by the orchestrator (cheap: a simple option comparison). * * @return void */ public static function upgrade_if_needed() { $stored = get_option( 'equasign_db_version' ); if ( $stored !== EQUASIGN_DB_VERSION ) { self::create_tables(); self::create_vault(); self::add_capability(); self::migrate_data( (string) $stored ); update_option( 'equasign_db_version', EQUASIGN_DB_VERSION ); } } /** * Idempotent data migrations (safe to replay). * * Normalizes the legacy signature mode "clavier" -> "keyboard" in the * database. Already-frozen proof bundles are NEVER rewritten (immutability): * the display layer maps legacy values at read time. * * Also migrates the legacy single API key (option equasign_api_key) into the * multi-key store (option equasign_api_keys) as a 'default' entry. * * @param string $from The stored DB version being upgraded from ('' if unknown). * @return void */ private static function migrate_data( $from = '' ) { global $wpdb; $documents = $wpdb->prefix . 'equasign_documents'; $wpdb->query( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fixed internal table name (wpdb prefix + hard-coded 'equasign_*'), no user input; direct query on our own tables by design. $wpdb->prepare( "UPDATE {$documents} SET signature_mode = %s WHERE signature_mode = %s", 'keyboard', 'clavier' ) ); // Multi-key store: seed it and fold in any legacy single key. EquaSign_Repo::api_keys_bootstrap(); // v0.6.2: the mail sender ADDRESS is no longer a setting — the Brevo plugin forces // its own configured sender (diagnostic), so the field was inoperative. Purge the // orphaned option (idempotent, non-destructive; delete_option no-ops if absent). delete_option( 'equasign_brevo_sender_email' ); // v5 added the access_validated column. It is a NEW column: grandfather every // task that predates v5 (set access_validated) so an in-flight or already- // completed task keeps its expected state after the upgrade. Version-scoped so a // later migration cannot wrongly reset it on genuinely-pending v5 tasks. if ( '' === (string) $from || (int) $from < 5 ) { $tasks = $wpdb->prefix . 'equasign_tasks'; $wpdb->query( "UPDATE {$tasks} SET access_validated = 1 WHERE access_validated = 0" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fixed internal table name (wpdb prefix + hard-coded 'equasign_*'), no user input; direct query on our own tables by design. } } /** * Creates / upgrades the 5 tables via dbDelta. * * @return void */ private static function create_tables() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $collate = $wpdb->get_charset_collate(); $p = $wpdb->prefix . 'equasign_'; // Bring any legacy reserved-word `key` columns to their non-reserved names // before dbDelta diffs the schema (see rename_reserved_columns()). self::rename_reserved_columns( $p ); $tasks = "CREATE TABLE {$p}tasks ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, opaque_reference VARCHAR(64) NOT NULL, external_reference VARCHAR(191) NULL DEFAULT NULL, origin VARCHAR(20) NOT NULL DEFAULT 'api', caller VARCHAR(60) NULL DEFAULT NULL, state VARCHAR(20) NOT NULL DEFAULT 'draft', channel VARCHAR(10) NOT NULL DEFAULT 'email', signer_name VARCHAR(191) NOT NULL DEFAULT '', signer_email VARCHAR(191) NULL DEFAULT NULL, signer_phone VARCHAR(32) NULL DEFAULT NULL, redirect_url VARCHAR(512) NULL DEFAULT NULL, document_type VARCHAR(64) NOT NULL DEFAULT 'default', language VARCHAR(8) NULL DEFAULT NULL, access_verification TINYINT(1) NOT NULL DEFAULT 1, access_validated TINYINT(1) NOT NULL DEFAULT 0, link_token VARCHAR(64) NULL DEFAULT NULL, validity_days SMALLINT UNSIGNED NOT NULL DEFAULT 14, created_at DATETIME NOT NULL, issued_at DATETIME NULL DEFAULT NULL, first_viewed_at DATETIME NULL DEFAULT NULL, sealed_at DATETIME NULL DEFAULT NULL, refused_at DATETIME NULL DEFAULT NULL, revoked_at DATETIME NULL DEFAULT NULL, expires_at DATETIME NULL DEFAULT NULL, otp_hash VARCHAR(255) NULL DEFAULT NULL, otp_expires_at DATETIME NULL DEFAULT NULL, otp_sent_at DATETIME NULL DEFAULT NULL, otp_attempts_remaining TINYINT UNSIGNED NULL DEFAULT NULL, otp_sent TINYINT(1) NOT NULL DEFAULT 0, otp_validated TINYINT(1) NOT NULL DEFAULT 0, otp_channel VARCHAR(10) NULL DEFAULT NULL, otp_destination VARCHAR(191) NULL DEFAULT NULL, signature_ip VARCHAR(45) NULL DEFAULT NULL, signature_user_agent VARCHAR(255) NULL DEFAULT NULL, proof_available TINYINT(1) NOT NULL DEFAULT 0, proof_purged TINYINT(1) NOT NULL DEFAULT 0, signed_pdf_file VARCHAR(255) NULL DEFAULT NULL, signed_pdf_sha256 CHAR(64) NULL DEFAULT NULL, proof_file VARCHAR(255) NULL DEFAULT NULL, send_sealed_copy TINYINT(1) NOT NULL DEFAULT 0, send_report_file VARCHAR(255) NULL DEFAULT NULL, license_tier VARCHAR(10) NULL DEFAULT NULL, license_remaining INT NULL DEFAULT NULL, license_source VARCHAR(20) NULL DEFAULT NULL, license_timestamp DATETIME NULL DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY opaque_reference (opaque_reference), UNIQUE KEY link_token (link_token), KEY state (state), KEY expires_at (expires_at), KEY external_reference (external_reference) ) {$collate};"; $documents = "CREATE TABLE {$p}documents ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, task_id BIGINT UNSIGNED NOT NULL, position SMALLINT UNSIGNED NOT NULL DEFAULT 0, name VARCHAR(191) NOT NULL DEFAULT '', source_sha256 CHAR(64) NOT NULL, signed_sha256 CHAR(64) NULL DEFAULT NULL, zone_page SMALLINT UNSIGNED NOT NULL DEFAULT 1, zone_x DECIMAL(8,2) NOT NULL DEFAULT 0, zone_y DECIMAL(8,2) NOT NULL DEFAULT 0, zone_width DECIMAL(8,2) NOT NULL DEFAULT 0, zone_height DECIMAL(8,2) NOT NULL DEFAULT 0, signature_mode VARCHAR(10) NULL DEFAULT NULL, applied_signature_file VARCHAR(255) NULL DEFAULT NULL, source_file VARCHAR(255) NOT NULL DEFAULT '', size_bytes INT UNSIGNED NULL DEFAULT NULL, PRIMARY KEY (id), KEY task_id (task_id) ) {$collate};"; $checkboxes = "CREATE TABLE {$p}checkboxes ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, document_id BIGINT UNSIGNED NOT NULL, task_id BIGINT UNSIGNED NOT NULL, position SMALLINT UNSIGNED NOT NULL DEFAULT 0, checkbox_key VARCHAR(100) NOT NULL DEFAULT '', label TEXT NOT NULL, required TINYINT(1) NOT NULL DEFAULT 0, checked TINYINT(1) NOT NULL DEFAULT 0, checked_at DATETIME NULL DEFAULT NULL, PRIMARY KEY (id), KEY document_id (document_id), KEY task_id (task_id) ) {$collate};"; $events = "CREATE TABLE {$p}events ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, task_id BIGINT UNSIGNED NOT NULL, type VARCHAR(40) NOT NULL DEFAULT '', data LONGTEXT NULL, ip VARCHAR(45) NULL DEFAULT NULL, user_agent VARCHAR(255) NULL DEFAULT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY task_id (task_id), KEY type (type) ) {$collate};"; $idempotency = "CREATE TABLE {$p}idempotency ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, idempotency_key VARCHAR(191) NOT NULL, opaque_reference VARCHAR(64) NOT NULL, response LONGTEXT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY idempotency_key (idempotency_key), KEY created_at (created_at) ) {$collate};"; dbDelta( $tasks ); dbDelta( $documents ); dbDelta( $checkboxes ); dbDelta( $events ); dbDelta( $idempotency ); } /** * Renames the legacy reserved-word `key` columns (schema v5 and earlier) to * non-reserved names, BEFORE dbDelta runs. Historically `checkboxes` and * `idempotency` used a backtick-quoted `key` column; on its DESCRIBE-diff path * dbDelta mis-parses that quoted reserved word and emits a malformed * `ALTER TABLE ... ADD `` (``)` plus PHP notices — "unexpected output during * activation" (and on every schema upgrade). Renaming removes the reserved word * entirely so dbDelta parses cleanly. CHANGE COLUMN preserves all existing data; * the operation is guarded (only fires when the old column is still present) and * idempotent. Frozen proof bundles are immutable JSON and are never touched. * * @param string $p Prefixed table stem ("{$wpdb->prefix}equasign_"). * @return void */ private static function rename_reserved_columns( $p ) { global $wpdb; $renames = array( 'checkboxes' => array( 'checkbox_key', "VARCHAR(100) NOT NULL DEFAULT ''" ), 'idempotency' => array( 'idempotency_key', 'VARCHAR(191) NOT NULL' ), ); foreach ( $renames as $suffix => $spec ) { $table = $p . $suffix; // Fresh install: the table does not exist yet — nothing to rename. if ( (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fixed internal table name (wpdb prefix + hard-coded 'equasign_*'), no user input; direct query on our own tables by design. continue; } $has_legacy = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'key' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fixed internal table name (wpdb prefix + hard-coded 'equasign_*'), no user input; direct query on our own tables by design. if ( 'key' === $has_legacy ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fixed internal table name (wpdb prefix + hard-coded 'equasign_*') and hard-coded column spec, no user input; direct query on our own tables by design. $wpdb->query( "ALTER TABLE {$table} CHANGE `key` {$spec[0]} {$spec[1]}" ); } // CHANGE renames the column but NOT its index: idempotency kept a UNIQUE // index still named `key`. Drop it so dbDelta owns the correctly-named // index (otherwise dbDelta adds a second, redundant one). Guarded so it is // a no-op once clean (and on tables that never had such an index). if ( $wpdb->get_var( "SHOW INDEX FROM {$table} WHERE Key_name = 'key'" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fixed internal table name (wpdb prefix + hard-coded 'equasign_*'), no user input; direct query on our own tables by design. $wpdb->query( "ALTER TABLE {$table} DROP INDEX `key`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fixed internal table name (wpdb prefix + hard-coded 'equasign_*'), no user input; direct query on our own tables by design. } } } /** * Creates the private file vault and its web-access guards. * * @return void */ private static function create_vault() { $base = self::vault_path(); if ( ! is_dir( $base ) ) { wp_mkdir_p( $base ); } // Apache: full denial. Both directives are wrapped in mutually exclusive // guards. An UNGUARDED "Require all denied" does not // degrade on Apache 2.2 without mod_authz_core — it returns HTTP 500 for the whole // directory ("Require not allowed here" / "AuthType not set!"). Guarding on the // Base module mod_authz_core.c (present on every 2.4) makes each block invisible to // the version that would choke on it. $htaccess = $base . '/.htaccess'; if ( ! file_exists( $htaccess ) ) { file_put_contents( $htaccess, "\nRequire all denied\n\n\nOrder allow,deny\nDeny from all\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions } // IIS: full denial. $webconfig = $base . '/web.config'; if ( ! file_exists( $webconfig ) ) { file_put_contents( $webconfig, "\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions } // Silence the directory listing. $index = $base . '/index.php'; if ( ! file_exists( $index ) ) { file_put_contents( $index, " 14, 'equasign_pdf_max_bytes' => 10 * 1024 * 1024, 'equasign_otp_length' => 6, 'equasign_otp_ttl' => 300, 'equasign_otp_attempts' => 3, 'equasign_email_credit_low' => 100, 'equasign_brevo_sender_name' => get_bloginfo( 'name' ), 'equasign_email_fallback_wpmail' => 0, 'equasign_display_timezone' => 'wp', 'equasign_purge_on_uninstall' => 0, 'equasign_purge_signed_mode' => 'partial', 'equasign_purge_days' => array( 'draft' => 90, 'active' => 90, 'read' => 90, 'expired' => 365, 'revoked' => 365, 'refused' => 365, 'signed' => 0, // 0 = never auto-purged. ), ); foreach ( $defaults as $key => $value ) { if ( false === get_option( $key, false ) ) { add_option( $key, $value ); } } // API keys (multiple, revocable). Seeds the store and folds in any // legacy single key (option equasign_api_key) as a 'default' entry. EquaSign_Repo::api_keys_bootstrap(); // Fake free license (FR- prefix), generated once. if ( ! get_option( 'equasign_free_license' ) ) { add_option( 'equasign_free_license', self::generate_free_license() ); } // Empty but present slots (Pro key, cache). The Brevo key lives in the local // Brevo plugin, never here. add_option( 'equasign_pro_license', '' ); add_option( 'equasign_license_cache', '' ); } /** * Generates a fake free-license key "FR-XXXXX-XXXXX-XXXXX-XXXXX" (Crockford * Base32, consistent with the plugin-side EquaLicense convention). * * @return string */ private static function generate_free_license() { $alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford (without I L O U). $groups = array(); for ( $g = 0; $g < 4; $g++ ) { $block = ''; for ( $i = 0; $i < 5; $i++ ) { $block .= $alphabet[ random_int( 0, 31 ) ]; } $groups[] = $block; } return 'FR-' . implode( '-', $groups ); } /** * Grants the management capability to the administrator. * * @return void */ private static function add_capability() { $role = get_role( 'administrator' ); if ( $role && ! $role->has_cap( self::CAP ) ) { $role->add_cap( self::CAP ); } } }