Tag: php

  • *click* *click* ENHANCE *click* *click* ENHANCE *click *click*

    *click* *click* ENHANCE *click* *click* ENHANCE *click *click*

    I recently learned that there’s a built-in “lightbox” function for the block editor, where you can click on images to expand them. Go ahead, try it on the picture below:

    Isn’t that wild?! Who knew! This should be the default:

    Through the magic of the Internet, now it can be!

    I took the lazy way and vibe coded this with Claude Code, so now all images on my blarg (and yours too if you’d like!) will expand by default.

    This plugin comes with no warranty. I will not be able to help you with it. Use it at your own expense. If it deletes your site, code, house, whatever, that’s on you. Don’t download random things from the Internet and run them. Lesson learned. I am not held liable.

    Go ahead, take this plugin, and do evil terrible things with it! Crush the skulls of your enemies!

  • Extract Transcript from Quill Meetings Files

    Extract Transcript from Quill Meetings Files

    I use Quill Meetings for local on-device transcriptions of calls. It’s pretty great!

    The app definitely has some quirks and is missing some features that I’d prefer, like the ability just export a text file of a call transcript. Sure, I can “copy” it and paste it into a file, but it’s missing things like timestamps:

    So I built a quick script to extract transcripts from .qm files for me. .qm files are basically just JSON files:

    #!/opt/homebrew/bin/php
    <?php
    declare(strict_types=1);
    
    error_reporting( E_ALL );
    ini_set( 'display_errors', '1' );
    
    // Quill export dir is first argument, or current directory if not provided.
    $export_dir = isset( $argv[1] ) ? rtrim( $argv[1], '/' ) : getcwd();
    
    // Find every file that ends in .qm in the export directory.
    $files = glob( $export_dir . '/*.qm' );
    if ( ! $files ) {
    	echo "No .qm files found in the directory: $export_dir\n";
    	exit( 1 );
    }
    
    /**
     * Each QM file is just a JSON file with a .qm extension and the first line being "QMv2"
     * We need to read each file, remove the first line, and decode the JSON.
     */
    foreach( $files as $file ) {
    	if ( ! is_readable( $file ) ) {
    		echo "Cannot read file: $file\n";
    		continue;
    	}
    
    	// Read the file and remove the first line.
    	$content = file_get_contents( $file );
    	if ( false === $content ) {
    		echo "Failed to read file: $file\n";
    		continue;
    	}
    
    	// Remove the first line (QMv2).
    	$lines = explode( "\n", $content );
    	array_shift( $lines ); // Remove the first line.
    	$json_content = implode( "\n", $lines );
    
    	// Decode the JSON content.
    	$data = json_decode( $json_content, true );
    	if ( null === $data && json_last_error() !== JSON_ERROR_NONE ) {
    		echo "Invalid JSON in file: $file\n";
    		continue;
    	}
    
    	// Pretty print the JSON data.
    	$pretty_json = json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
    	if ( false === $pretty_json ) {
    		echo "Failed to encode JSON for file: $file\n";
    		continue;
    	}
    
    	$speakers = array();
    	$transcript = array();
    	$output_string = '';
    	$output_file = '';
    	foreach ( $data as $quill_objects => $quill_object ) {
    	    // Each Quill object is an array. We want to check if it has a 'type' of 'Meeting'.
    		if ( isset( $quill_object['type'] ) && $quill_object['type'] === 'Meeting' ) {
    			$output_file = $quill_object['data']['start'] . '-' . $quill_object['data']['end'] . ': ' . $quill_object['data']['title'] . '.txt';
    			// The "audio_transcript" is just a JSON string that we need to decode.
    			$audio_transcript = json_decode( $quill_object['data']['audio_transcript'], true );
    			$encoded_speakers = $quill_object['data']['speakers'] ?? [];
    			foreach( $encoded_speakers as $encoded_speaker ) {
    				$speakers[ $encoded_speaker['id'] ] = $encoded_speaker['name'] ?? 'Unknown Speaker ' . $encoded_speaker['id'];
    			}
    			if ( ! isset ( $audio_transcript['startTime'] ) ) {
    				echo "Invalid start time in audio transcript for file: $file\n";
    				continue;
    			}
    			$start_time = $audio_transcript['startTime'];
    			$end_time   = $audio_transcript['endTime'];
    			foreach( $audio_transcript['blocks'] as $block ) {
    				$time_block = ms_to_readable( $block['from'] - $start_time );
    				if ( isset( $block['speaker_id' ] ) ) {
    					$speaker_block = $speakers[ $block['speaker_id'] ];
    				} else {
    					echo 'Unkown Speaker found. Please manually mark all speakers in Quill before exporting.' . PHP_EOL;
    					die( 1 );
    				}
    				$output_string .= sprintf( "%s %s: %s\n", $time_block, $speaker_block, $block['text'] );
    			}
    		}
    	}
    
    	if ( ! empty( $output_string ) && ! empty( $output_file ) ) {
    		// Sanitize the filename.
    		$output_file = sanitize_filename( $output_file );
    		// Write the output string to the file.
    		if ( file_put_contents( $output_file, $output_string ) === false ) {
    			echo "Failed to write to file: $output_file\n";
    		} else {
    			echo "Exported to: $output_file\n";
    		}
    	} else {
    		echo "No valid Meeting data found in file: $file\n";
    	}
    }
    
    function ms_to_readable(int $ms): string {
    	// round to nearest second
    	$secs = (int) round($ms / 1000);
    	// gmdate formats seconds since 0 into H:i:s — we just need i:s
    	return '[' . gmdate('i:s', $secs) . ']';
    }
    
    function sanitize_filename(string $filename): string {
    	// strip any path information
    	$fname = basename($filename);
    	// replace any character that is NOT a-z, 0-9, dot, hyphen or underscore with an underscore
    	$clean = preg_replace('/[^\w\.-]+/', '_', $fname);
    	// collapse multiple underscores
    	return preg_replace('/_+/', '_', $clean);
    }Code language: PHP (php)

    and when I say “I” wrote it, it was probably half AI 🙃

    This gives me a nice text file with timestamps:

    So, yeah. Whatever.

  • Rescuing Tampermonkey Scripts from a LevelDB Backup

    Rescuing Tampermonkey Scripts from a LevelDB Backup

    I have a confession to make. I didn’t back up my Tampermonkey scripts. I recently switched to a new computer, and thought I had copied all of my important information over from the old one.

    It turns out, I did not. Luckily I did have a full disk backup that I thought I could just pull the .user.js files off of. OH BOY WAS I WRONG.

    The Oops: No Script Backup

    I’d cobbled together a dozen userscripts or more over years, but never bothered to add them to a backup routine. When I opened Tampermonkey on the new machine, it greeted me with the emptiest dashboard imaginable.

    Digging Into Chrome’s LevelDB

    It turns out Chrome buries extension data in your profile directory. The Tampermonkey store lives in a LevelDB folder named after its extension ID:

    /Volumes/OldHDD/derrick/Library/Application Support/Google/Chrome/Profile 1/IndexedDB/\
    chrome-extension_dhdgffkkebhmkfjojejmpbldmpobfkfo_0.indexeddb.leveldb

    Dumping with leveldbutil

    I used leveldbutil, a C++ command-line tool for dumping LevelDB databases. I had to clone the repo and compile it from source before it would run on macOS.

    ./leveldbutil dump "/Volumes/OldHDD/derrick/Library/Application Support/Google/Chrome/Profile 1/IndexedDB/chrome-extension_dhdgffkkebhmkfjojejmpbldmpobfkfo_0.indexeddb.leveldb" > tampermonkey-dump.txtCode language: Bash (bash)

    This produced a massive text dump (tampermonkey-dump.txt) of JSON blobs—metadata, source code, state… you name it.

    Parsing the Dump: My One-Off Script

    Rather than manually eyeballing hundreds of lines, I whipped up a throwaway PHP script, let’s call it ScriptSalvager, to automate recovery. It:

    1. Loads the dump file.
    2. Scans for script entries via regex.
    3. json_decode()s each blob.
    4. Writes out individual script files.
    <?php
    /**
     * Exports Tampermonkey userscripts from a raw dump to per-script directories.
     *
     * This script reads the Tampermonkey IndexedDB dump, splits it into
     * individual userscript segments, parses each piece (meta, source,
     * rules, state, etc.), and writes each to its own file.
     */
    
    // Path to the raw Tampermonkey dump.
    $file = __DIR__ . '/tampermonkey-dump.txt';
    
    if ( ! is_readable( $file ) ) {
    	fwrite( STDERR, 'Cannot read file: ' . $file . "\n" );
    	exit( 1 );
    }
    
    // Load the file.
    $contents = file_get_contents( $file );
    
    // Split on lines like: "--- offset 123456; sequence 789".
    $segments = preg_split( '/^--- offset \d+; sequence \d+/m', $contents );
    
    // Remove any leading empty segment
    if ( isset( $segments[0] ) && trim( $segments[0] ) === '' ) {
    	array_shift( $segments );
    }
    
    // Collect only the userscript segments (those with both @meta and @source).
    $userscripts = [];
    foreach ( $segments as $segment ) {
    	if ( str_contains( $segment, "put '@meta#" ) && str_contains( $segment, "put '@source#" ) ) {
    		  // Normalize line endings and split into lines for readability.
    		  $normalized = str_replace( [ "\r\n", "\r" ], "\n", $segment );
    		  $lines = explode( "\n", $normalized );
    
    		  // Trim each line, and remove empty or external-resource lines.
    		  $filtered = [];
    		  foreach ( $lines as $line ) {
    			  $line = trim( $line );
    			  // Skip blank lines.
    			  if ( $line === '' ) {
    				  continue;
    			  }
    			  // Skip external-resource entries.
    			  if ( str_starts_with( $line, "put '@ext#" ) ) {
    				  continue;
    			  }
    			  $filtered[] = $line;
    		  }
    
    		  $userscripts[] = $filtered;
    	}
    }
    
    // Free memory we no longer need.
    unset( $contents, $segments );
    
    // Build an associative array of scripts by their UID values.
    $scripts = [];
    foreach ( $userscripts as $lines ) {
    	$items = [];
    	$script_uuid = null;
    	foreach ( $lines as $line ) {
    		  $trimmed = trim( $line );
    		  if ( ! str_starts_with( $trimmed, "put '" ) ) {
    			  echo "Error: Expected line to start with 'put '\n";
    			  die();
    		  }
    		  // Strip off leading "put '".
    		  $rest = substr( $trimmed, strlen( "put '" ) );
    		  // Split into type and remainder.
    		  [ $type, $after_hash ] = explode( '#', $rest, 2 );
    		  // Split remainder into UUID+closing-quote and JSON blob.
    		  [ $uid_quoted, $json_with_quote ] = explode( "' ", $after_hash, 2 );
    		  // Extract the UUID (no quotes).
    		  $uuid = trim( $uid_quoted, "'" );
    		  if ( $script_uuid === null ) {
    			  $script_uuid = $uuid;
    		  }
    
    		  // Trim single-quotes from JSON.
    		  $json_blob = trim( $json_with_quote, "'" );
    		  $value = $json_blob;
    		  $value = json_decode( $json_blob, true );
    		  if ( '@source' === $type ) {
    			  // Decode the source JSON.
    			  $value = json_decode( $json_blob, true );
    			  $value = $value['value'];
    		  } else {
    			$value = json_encode( $value['value'] ) ?? $json_blob;
    		}
    
    		  // Store, allowing duplicates to become arrays.
    		  if ( isset( $items[$type] ) ) {
    			  if ( ! is_array( $items[$type] ) ) {
    				  $items[$type] = [ $items[$type] ];
    			  }
    			  $items[$type][] = $value;
    		  } else {
    			  $items[$type] = $value;
    		  }
    	}
    	// Use the extracted UUID as the script key.
    	if ( $script_uuid === null ) {
    		echo "Error: Could not extract script UUID\n";
    		die();
    	}
    
    	if ( ! isset( $scripts[$script_uuid] ) ) {
    		$scripts[$script_uuid] = [];
    	}
    
    	$scripts[$script_uuid][] = $items;
    }
    
    // Add script names to the array.
    foreach ( $scripts as $uuid => $script ) {
    	foreach ( $script as $index => $version ) {
    		$name = $uuid;
    		// Check if the script has a name.
    		if ( isset( $version['@uid'] ) ) {
    			$uid = $version['@uid'];
    			$name = $uid;
    		} elseif ( isset( $version['@source'] ) ) {
    			$source = $version['@source'];
    			$normalized_source = str_replace( [ "\r\n", "\r" ], "\n", $source ?? '' );
    			$lines_source = explode( "\n", $normalized_source );
    			foreach( $lines_source as $line_source ) {
    				$line_source = trim( $line_source );
    				// Skip blank lines.
    				if ( $line_source === '' ) {
    					continue;
    				}
    
    				if ( str_contains( $line_source, "@name" ) ) {
    					$name_lines = explode( "@name", $line_source );
    					$name = trim( $name_lines[1] );
    					break;
    				}
    			}
    		}
    		if ( $name === $uuid ) {
    			echo "Error: Could not extract script name\n";
    			die();
    		}
    
    		// Remove leading and trailing quotes.
    		$name = trim( $name, '"' );
    
    		// Store the name in the script array.
    		$scripts[$uuid][$index]['@name'] = $name;
    	}
    }
    
    // If a script has multiple meta entries, split them into separate scripts.
    foreach ( $scripts as $uuid => $script ) {
    	foreach ( $script as $index => $version ) {
    		// Check if the script has multiple meta entries.
    		if ( isset( $version['@meta'] ) && is_array( $version['@meta'] ) ) {
    			foreach ( $version['@meta'] as $meta_index => $meta_value ) {
    				// Copy the script data to a new entry.
    				$new_script = $scripts[$uuid][$index];
    				// Remove the meta entry from the original script.
    				unset( $new_script['@meta'] );
    				// Copy the single meta entry to the new script.
    				$new_script['@meta'] = $meta_value;
    				// Unset the meta entry from the original script.
    				unset( $scripts[$uuid][$index]['@meta'] );
    				// Add the new script to the array.
    				$scripts[$uuid][] = $new_script;
    			}
    			// After splitting, remove the original script entry.
    			unset( $scripts[$uuid][$index] );
    		}
    	}
    }
    
    // Add lastModified to the array.
    foreach ( $scripts as $uuid => $script ) {
    	foreach ( $script as $index => $version ) {
    		// Check if the script has a lastModified.
    		if ( isset( $version['@meta'] ) ) {
    			$meta = $version['@meta'];
    			$meta = json_decode( $meta, true );
    			$meta = $meta['value'] ?? $meta;
    		   
    			if ( isset( $meta['lastModified'] ) ) {
    				$last_modified = $meta['lastModified'];
    				$scripts[$uuid][$index]['@lastModified'] = $last_modified;
    				$scripts[$uuid][$index]['@lastModifiedHuman'] = date( 'Y-m-d H:i:s', intval($last_modified/1000 ));
    			}
    
    			if ( isset( $meta['version'] ) ) {
    				$version_number = $meta['version'];
    				$scripts[$uuid][$index]['@version'] = $version_number;
    			}
    
    		}
    	}
    }
    
    // Export each script’s data into its own directory, with a unique name, timestamp, and version.
    foreach ( $scripts as $uuid => $script ) {
    	$latest_version = 0;
    	foreach ( $script as $index => $version ) {
    		// Determine directory name based on script name (fallback to UUID).
    		$name = $version['@name'];
    
    		$directory = $name . '/' . $version['@lastModifiedHuman'] . ' - v' . $version['@version'];
    
    		// Create or clear directory.
    		if ( ! is_dir( $directory ) ) {
    			mkdir( $directory, 0777, true );
    		}
    
    		// Write each part to its own JSON file.
    		foreach ( $version as $type => $value ) {
    			$base = trim( $type, '@' );
    
    			if ( 'source' === $base) {
    				// Export the userscript source unescaped so code remains intact.
    				$file_name = "{$directory}/{$base}.user.js";
    				file_put_contents( $file_name, $value );
    			} else {
    				// Single value, write normally.
    				if ( ! in_array( $base, [ 'name', 'lastModified', 'lastModifiedHuman', 'version' ] ) ) {
    					$value = json_decode( $value, true );
    				}
    				$file_name = "{$directory}/{$base}.json";
    				file_put_contents( $file_name, json_encode( $value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
    			}
    		}
    
    		// Find the most recent lastModified date and set it as the "latest" version.
    		if ( isset( $version['@lastModified'] ) && $version['@lastModified'] > $latest_version ) {
    			$latest_version = $version['@lastModified'];
    
    			// Are we at the end of the array?
    			if ( $index === count( $script ) - 1 ) {
    				// Create the latest version directory.
    				$latest_directory = $name . '/latest';
    				if ( ! is_dir( $latest_directory ) ) {
    					mkdir( $latest_directory, 0777, true );
    				}
    				// Write each part to its own JSON file.
    				foreach ( $version as $type => $value ) {
    					if ( $latest_version !== $version['@lastModified'] ) {
    						continue;
    					}
    					$base = trim( $type, '@' );
    
    					if ( 'source' === $base) {
    						// Export the userscript source unescaped so code remains intact.
    						$file_name = "{$latest_directory}/{$base}.user.js";
    						file_put_contents( $file_name, $value );
    					} else {
    						// Single value, write normally.
    						if ( ! in_array( $base, [ 'name', 'lastModified', 'lastModifiedHuman', 'version' ] ) ) {
    							$value = json_decode( $value, true );
    						}
    						$file_name = "{$latest_directory}/{$base}.json";
    						file_put_contents( $file_name, json_encode( $value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
    					}
    				}
    			}
    		}
    	}
    }Code language: PHP (php)

    Note: This was a one-and-done hack, don’t judge my variable names.

    The Recovery Steps

    1. Mount or locate your backup folder.
    2. Dump the LevelDB files: ./leveldbutil dump "/path/to/<profile>.leveldb" > tampermonkey-dump.txt
    3. Run ScriptSalvager: php scriptsalvager.php
    4. Cross your fingers and hope it dumps out all of your scripts.

    Lessons (That I Probably Won’t Learn)

    • Always export your userscripts from Tampermonkey–seriously.
    • Add them to your actual backup routine.
    • Or just never switch laptops again. 😬

    Happy salvaging!

  • Stop WordPress From Sending Emails (And Log Them Too!)

    Stop WordPress From Sending Emails (And Log Them Too!)

    While doing some development work recently, I wanted to make sure that I disabled all email sending in my test site so that no users imported would get any weird emails.

    To do this I had ChatGPT whip me up a quick plugin, and after cleaning it up here it is to share with the world:

    <?php
    /**
     * Plugin Name: Restrict and Log Emails
     * Description: Blocks emails for users and logs all email attempts.
     * Version: 1.3
     * Author: Emrikol
     */
    
    if ( ! defined( 'ABSPATH' ) ) {
    	exit; // Prevent direct access.
    }
    define( 'REL_EMAIL_LOG_DB_VERSION', '1.2' );
    
    /**
     * Database installation and upgrade function.
     * Creates or updates the email_log table if needed based on a stored version option.
     *
     * @return void
     */
    function rel_install() {
    	global $wpdb;
    	$table_name      = $wpdb->prefix . 'email_log';
    	$charset_collate = $wpdb->get_charset_collate();
    
    	$sql = "CREATE TABLE $table_name (
    	   id mediumint(9) NOT NULL AUTO_INCREMENT,
    	   recipient_email varchar(100) NOT NULL,
    	   subject text NOT NULL,
    	   message text NOT NULL,
    	   status varchar(20) NOT NULL,
    	   sent_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
    	   PRIMARY KEY (id)
    	) $charset_collate;";
    
    	require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    	dbDelta( $sql );
    
    	// Update the database version option.
    	update_option( 'rel_email_log_db_version', REL_EMAIL_LOG_DB_VERSION, false );
    }
    register_activation_hook( __FILE__, 'rel_install' );
    
    /**
     * Adds the Email Log submenu to Tools in the WordPress admin area.
     *
     * @return void
     */
    function rel_add_admin_menu(): void {
    	if ( function_exists( 'add_submenu_page' ) ) {
    		add_submenu_page(
    			'tools.php',
    			'Email Log',
    			'Email Log',
    			'manage_options',
    			'email-log',
    			'rel_email_log_page'
    		);
    	}
    }
    add_action( 'admin_menu', 'rel_add_admin_menu' );
    
    /**
     * Displays the Email Log page in the WordPress admin area.
     *
     * @return void
     */
    function rel_email_log_page(): void {
    	global $wpdb;
    	$table_name = $wpdb->prefix . 'email_log';
    	$logs       = $wpdb->get_results( "SELECT * FROM $table_name ORDER BY sent_at DESC LIMIT 50" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    
    	echo '<div class="wrap">';
    	echo '<h1>Email Log</h1>';
    
    	if ( current_user_can( 'manage_options' ) ) {
    		// Process toggle form submission.
    		if ( isset( $_POST['rel_toggle_blocking'] ) && check_admin_referer( 'rel_toggle_blocking_action', 'rel_toggle_blocking_nonce' ) ) {
    			// Sanitize and update the blocking option.
    			if ( isset( $_POST['rel_email_blocking_enabled'] ) && in_array( $_POST['rel_email_blocking_enabled'], array( 'enabled', 'disabled' ), true ) ) {
    				$blocking = $_POST['rel_email_blocking_enabled']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    			} else {
    				$blocking = 'enabled';
    			}
    
    			update_option( 'rel_email_blocking_enabled', $blocking, false );
    			echo '<div class="updated notice"><p>Email blocking has been ' . ( 'enabled' === $blocking ? 'enabled' : 'disabled' ) . '.</p></div>';
    		}
    		$current_blocking = get_option( 'rel_email_blocking_enabled', true );
    
    		echo '<form method="post" style="margin-bottom:20px;">';
    		wp_nonce_field( 'rel_toggle_blocking_action', 'rel_toggle_blocking_nonce' );
    		echo '<p>Email blocking is currently <strong>' . ( 'enabled' === $current_blocking ? 'enabled' : 'disabled' ) . '</strong>.';
    		echo '<br/><br/><label for="rel_email_blocking_enabled">Toggle: </label>';
    		echo '<select id="rel_email_blocking_enabled" name="rel_email_blocking_enabled">';
    		echo '<option value="enabled"' . selected( $current_blocking, 'enabled', false ) . '>Enabled</option>';
    		echo '<option value="disabled"' . selected( $current_blocking, 'disabled', false ) . '>Disabled</option>';
    		echo '</select> ';
    		echo '<input type="submit" name="rel_toggle_blocking" value="Update" class="button-primary" />';
    		echo '</p>';
    		echo '</form>';
    	}
    
    	echo '<table class="widefat fixed" cellspacing="0">';
    	echo '<thead><tr><th>ID</th><th>Email</th><th>Subject</th><th>Message</th><th>Status</th><th>Sent At</th></tr></thead>';
    	echo '<tbody>';
    
    	if ( $logs ) {
    		foreach ( $logs as $log ) {
    			$truncated_message = ( strlen( $log->message ) > 30 ) ? substr( $log->message, 0, 30 ) . '…' : $log->message;
    			echo wp_kses_post(
    				'<tr>
    					<td>' . $log->id . '</td>
    					<td>' . $log->recipient_email . '</td>
    					<td>' . $log->subject . '</td>
    					<td>' . $truncated_message . '</td>
    					<td>' . $log->status . '</td>
    					<td>' . $log->sent_at . '</td>
    				  </tr>'
    			);
    		}
    	} else {
    		echo '<tr><td colspan="6">No emails logged yet.</td></tr>';
    	}
    
    	echo '</tbody></table>';
    	echo '</div>';
    }
    
    /**
     * Intercepts email sending attempts, restricts emails for users without the 'manage_options' capability, and logs the attempt.
     *
     * @param null|bool $short_circuit Default value if email is allowed.
     * @param array     $atts An array of email attributes (to, subject, etc.).
     * @return bool|null Returns a non-null value to short-circuit email sending if restricted.
     */
    function rel_prevent_email( $short_circuit, $atts ) {
    	global $wpdb;
    	$table_name = $wpdb->prefix . 'email_log';
    
    	$recipient_email = isset( $atts['to'] ) ? $atts['to'] : '';
    	$subject         = isset( $atts['subject'] ) ? $atts['subject'] : '';
    	$message         = isset( $atts['message'] ) ? $atts['message'] : '';
    	$sent_at         = current_time( 'mysql' );
    
    	// Check if email blocking is enabled (default true).
    	$blocking = get_option( 'rel_email_blocking_enabled', 'enabled' );
    	switch ( $blocking ) {
    		case 'enabled':
    			$blocking_enabled = true;
    			break;
    		case 'disabled':
    			$blocking_enabled = false;
    			break;
    		default:
    			$blocking_enabled = true;
    			break;
    	}
    
    	// Determine status based on blocking setting.
    	if ( true === $blocking_enabled ) {
    		$status = 'Blocked';
    	} else {
    		$status = 'Sent';
    	}
    
    	// Log the email attempt.
    	$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
    		$table_name,
    		array(
    			'recipient_email' => $recipient_email,
    			'subject'         => $subject,
    			'message'         => $message,
    			'status'          => $status,
    			'sent_at'         => $sent_at,
    		)
    	);
    
    	// If blocking is enabled and the current user cannot manage options, block the email, otherwise allow sending.
    	if ( $blocking_enabled ) {
    		return true;
    	}
    	return null;
    }
    add_filter( 'pre_wp_mail', 'rel_prevent_email', 10, 2 );
    
    /**
     * Checks the current database version and updates the email_log table if necessary.
     *
     * @return void
     */
    function rel_check_db_version() {
    	if ( get_option( 'rel_email_log_db_version' ) !== REL_EMAIL_LOG_DB_VERSION ) {
    		rel_install();
    	}
    }
    add_action( 'init', 'rel_check_db_version' );
    
    Code language: PHP (php)

    As usual, don’t use this slop, nor anything you read here because this is my blarg and I can’t be trusted to do anything correct.

  • Syntax Highlighting SQL in Terminal

    Syntax Highlighting SQL in Terminal

    Do you ever find yourself doing some debugging with error_log() or its friends? Does that debugging ever involve SQL queries? Are you tired of staring at grey queries all the time? I have just the product for you!

    Introducing Syntax Highlighting SQL in Terminal! Brought to you by our friends at Large Language Models, Incorporated, this new PHP function will use ANSI escape sequences to colorize your SQL queries for easier viewing and debugging!

    <?php
    
    /**
     * Highlights SQL queries.
     *
     * This method takes a SQL query as input and returns the query with syntax highlighting applied.
     *
     * @param string $sql The SQL query to highlight.
     *
     * @return string The highlighted SQL query.
     */
    function highlight_sql( string $sql ): string {
    	$keywords = array(
    		'SELECT',
    		'FROM',
    		'WHERE',
    		'AND',
    		'OR',
    		'INSERT',
    		'INTO',
    		'VALUES',
    		'UPDATE',
    		'SET',
    		'DELETE',
    		'CREATE',
    		'TABLE',
    		'ALTER',
    		'DROP',
    		'JOIN',
    		'INNER',
    		'LEFT',
    		'RIGHT',
    		'ON',
    		'AS',
    		'DISTINCT',
    		'GROUP',
    		'BY',
    		'ORDER',
    		'HAVING',
    		'LIMIT',
    		'OFFSET',
    		'UNION',
    		'ALL',
    		'COUNT',
    		'SUM',
    		'AVG',
    		'MIN',
    		'MAX',
    		'LIKE',
    		'IN',
    		'BETWEEN',
    		'IS',
    		'NULL',
    		'NOT',
    		'PRIMARY',
    		'KEY',
    		'FOREIGN',
    		'REFERENCES',
    		'DEFAULT',
    		'AUTO_INCREMENT',
    	);
    
    	$functions = array(
    		'COUNT',
    		'SUM',
    		'AVG',
    		'MIN',
    		'MAX',
    		'NOW',
    		'CURDATE',
    		'CURTIME',
    		'DATE',
    		'TIME',
    		'YEAR',
    		'MONTH',
    		'DAY',
    		'HOUR',
    		'MINUTE',
    		'SECOND',
    		'TIMESTAMP',
    	);
    
    	$colors = array(
    		'keyword'  => "\033[1;34m", // Blue.
    		'function' => "\033[1;32m", // Green.
    		'string'   => "\033[1;33m", // Yellow.
    		'comment'  => "\033[1;90m", // Bright Black (Gray).
    		'reset'    => "\033[0m",
    	);
    
    	// Highlight comments.
    	$sql = preg_replace( '/\/\*.*?\*\//s', $colors['comment'] . '$0' . $colors['reset'], $sql );
    
    	// Highlight single-quoted strings.
    	$sql = preg_replace( '/\'[^\']*\'/', $colors['string'] . '$0' . $colors['reset'], $sql );
    
    	// Highlight double-quoted strings.
    	$sql = preg_replace( '/"[^"]*"/', $colors['string'] . '$0' . $colors['reset'], $sql );
    
    	// Highlight functions.
    	foreach ( $functions as $function ) {
    		$sql = preg_replace( '/\b' . preg_quote($function, '/') . '\b/i', $colors['function'] . '$0' . $colors['reset'], $sql );
    	}
    
    	// Highlight keywords.
    	foreach ( $keywords as $keyword ) {
    		$sql = preg_replace( '/\b' . preg_quote($keyword, '/') . '\b/i', $colors['keyword'] . '$0' . $colors['reset'], $sql );
    	}
    
    	return $sql;
    }
    
    echo highlight_sql( 'SELECT COUNT(*) AS total FROM users WHERE id = 1 AND user_id = "example"; /* YEAH! Comment! */' ) . PHP_EOL;Code language: PHP (php)

    Don’t blame me if this breaks anything, use at your own debugging risk.

  • Super Simple OpenAI PHP Class

    Super Simple OpenAI PHP Class

    I’ve been playing around with hooking up ChatGPT/Dall-E to WordPress and WP-CLI. To do this, I whipped up a super simple class to make this easier:

    <?php
    class OpenAI_API {
    	public const API_KEY = 'hunter2';  // Get your own darn key!
    
    	/**
    	 * Generates an image based on the provided prompt using the OpenAI API.
    	 *
    	 * @param string $prompt The text prompt to generate the image from. Default is an empty string.
    	 * @return string The response body from the OpenAI API, or a JSON-encoded error message if the request fails.
    	 */
    	public static function generate_image( string $prompt = '' ): string {
    		$data = array(
    			'model'   => 'dall-e-3',
    			'prompt'  => trim( $prompt ),
    			'quality' => 'hd',
    			'n'       => 1,
    			'size'    => '1024x1024',
    		);
    
    		$args = array(
    			'body'        => wp_json_encode( $data ),
    			'headers'     => array(
    				'Content-Type'  => 'application/json',
    				'Authorization' => 'Bearer ' . OpenAI_API::API_KEY,
    			),
    			'method'      => 'POST',
    			'data_format' => 'body',
    		);
    
    		$response = wp_remote_post( 'https://api.openai.com/v1/images/generations', $args );
    
    		if ( is_wp_error( $response ) ) {
    			return wp_json_encode( $response );
    		} else {
    			$body = wp_remote_retrieve_body( $response );
    			return $body;
    		}
    	}
    
    	/**
    	 * Creates a chat completion using the OpenAI GPT-3.5-turbo model.
    	 *
    	 * @param string $prompt The user prompt to be sent to the OpenAI API.
    	 * @param string $system_prompt Optional. The system prompt to be sent to the OpenAI API. Defaults to a predefined prompt.
    	 * 
    	 * @return string The response body from the OpenAI API, or a JSON-encoded error message if the request fails.
    	 */
    	public static function create_chat_completion( string $prompt = '', string $system_prompt = '' ): string {
    		if ( empty( $system_prompt ) ) {
    			$system_prompt = 'You are a virtual assistant designed to provide general support across a wide range of topics. Answer concisely and directly, focusing on essential information only. Maintain a friendly and approachable tone, adjusting response length based on the complexity of the question.';
    		}
    
    		// The data to send in the request body
    		$data = array(
    			'model'    => 'gpt-3.5-turbo',
    			'messages' => array(
    				array(
    					'role'    => 'system',
    					'content' => trim( $system_prompt ),
    				),
    				array(
    					'role'    => 'user',
    					'content' => trim( $prompt ),
    				),
    			),
    		);
    
    		$args = array(
    			'body'        => wp_json_encode( $data ),
    			'headers'     => array(
    				'Content-Type'  => 'application/json',
    				'Authorization' => 'Bearer ' . OpenAI_API::API_KEY,
    			),
    			'method'      => 'POST',
    			'data_format' => 'body',
    			'timeout'     => 15,
    		);
    
    		// Perform the POST request
    		$response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', $args );
    
    		// Error handling
    		if ( is_wp_error( $response ) ) {
    			return wp_json_encode( $response );
    		} else {
    			if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
    				return wp_json_encode( array( 'error' => 'API returned non-200 status code', 'response' => wp_remote_retrieve_body( $response ) ) );
    			}
    
    			// Assuming the request was successful, you can access the response body as follows:
    			$body = wp_remote_retrieve_body( $response );
    			return $body;
    		}
    	}
    }Code language: PHP (php)

    I can generate images and get back text from the LLM. Here’s some examples ChatGPT made to show how you can use these:


    Example 1: Generating an Image

    This example generates an image of a “cozy cabin in the snowy woods at sunset” using the generate_image method and displays it in an <img> tag.

    <?php
    $image_url = OpenAI_API::generate_image("A cozy cabin in the snowy woods at sunset");
    
    if ( ! empty( $image_url ) ) {
        echo '<img src="' . esc_url( $image_url ) . '" alt="Cozy cabin in winter">';
    } else {
        echo 'Image generation failed.';
    }
    ?>Code language: PHP (php)

    Example 2: Simple Chat Completion

    This example sends a question to the create_chat_completion method and prints the response directly.

    <?php
    $response = OpenAI_API::create_chat_completion("How does photosynthesis work?");
    echo $response;
    ?>Code language: PHP (php)

    Example 3: Chat Completion with Custom System Prompt

    This example sets a custom system prompt for a specific tone, here focusing on culinary advice, and asks a relevant question.

    <?php
    $system_prompt = "You are a culinary expert. Please provide advice on healthy meal planning.";
    $response = OpenAI_API::create_chat_completion("What are some good meals for weight loss?", $system_prompt);
    echo $response;
    ?>Code language: PHP (php)

    Here are some key limitations of this simple API implementation and why these are crucial considerations for production:

    • Lack of Robust Error Handling:
      • This API implementation has basic error handling that only checks if an error occurred during the request. It doesn’t provide specific error messages for different types of failures (like rate limits, invalid API keys, or network issues).
      • Importance: In production, detailed error handling allows for clearer diagnostics and faster troubleshooting when issues arise.
    • No Caching:
      • The current API makes a fresh request for each call, even if the response might be identical to a recent query.
      • Importance: Caching can reduce API usage costs, improve response times, and reduce server load, particularly for commonly repeated queries.
    • No API Rate Limiting:
      • This implementation doesn’t limit the number of requests sent within a certain time frame.
      • Importance: Rate limiting prevents hitting API request quotas and helps avoid unexpected costs or blocked access if API limits are exceeded.
    • No Logging for Debugging:
      • There’s no logging in place for tracking request errors or failed attempts.
      • Importance: Logs provide an audit trail that helps diagnose issues over time, which is crucial for maintaining a stable application in production.
    • Lack of Security for API Key Management:
      • The API key is currently hard coded into the class.
      • Importance: In production, it’s best to use environment variables or a secure key management system to protect sensitive information and prevent accidental exposure of the API key.
    • No Response Parsing or Validation:
      • The code assumes that the API response format is always correct, without validation.
      • Importance: Inconsistent or unexpected responses can cause failures. Validation ensures the app handles different cases gracefully.

    Why Not Use in Production?

    Due to these limitations, this API should be considered a prototype or learning tool rather than a production-ready solution. Adding robust error handling, caching, rate limiting, and logging would make it more resilient, secure, and efficient for a production environment.


    Alright, so listen to the LLM and don’t do anything stupid with this, like I am doing.

  • PHPCS Anywhere!

    PHPCS Anywhere!

    Something that I do often is run PHPCS on code I’m working on, almost always inside a git repository. Even more likely is that PHPCS was installed via composer, which means it will live in $GIT_ROOT/vendor/bin. So I always end up doing something like ../../../vendor/bin/phpcs file.php which is hugely annoying.

    Which is why I made this monstrosity:

    # PHPCS
    function phpcs() {
            if git rev-parse --git-dir > /dev/null 2>&1; then
                    git_root=$(git rev-parse --show-toplevel 2>/dev/null)
    
                    # Check if vendor/bin/phpcs exists
                    if [[ -x "$git_root/vendor/bin/phpcs" ]]; then
                            # Call the local vendor/bin/phpcs
                            "$git_root/vendor/bin/phpcs" "$@"
                    fi
            else
                    # Fall back to the system's default phpcs
                    command phpcs "$@"
            fi
    }
    
    # PHPCBF
    function phpcbf() {
            if git rev-parse --git-dir > /dev/null 2>&1; then
                    git_root=$(git rev-parse --show-toplevel 2>/dev/null)
    
                    # Check if vendor/bin/phpcbf exists
                    if [[ -x "$git_root/vendor/bin/phpcbf" ]]; then
                            # Call the local vendor/bin/phpcbf
                            "$git_root/vendor/bin/phpcbf" "$@"
                    fi
            else
                    # Fall back to the system's default phpcbf
                    command phpcbf "$@"
            fi
    }Code language: Bash (bash)

    Basically, what this is doing is any time I run phpcs or phpcbf it will first check if I am inside a git repository. If I am, and $GIT_ROOT/vendor/bin/phpcs exists, it will automatically find it and use it.

    Saves me seconds a day. SECONDS! Maybe you can find it useful as well. Or not. Who knows.

  • How To Restrict User to Self Posts in WordPress

    How To Restrict User to Self Posts in WordPress

    I recently had to work with a third-party integration that used the WordPress REST API to interact with a website. We use this tool internally to move data around from other integrations, and finally into WordPress.

    One of the things that I was worried about was the fact that this plugin would then have full access to the website, which is a private site. We only wanted to use it to post and then update WordPress posts, but there was always the concern that if the third party were to be hacked, then someone could read all of our posts on the private site through the REST API.

    My solution was to hook into user_has_cap so that the user that was set up for the plugin to integrate with, through an application password, would only have access to read and edit its own posts. A bonus is that we wanted to be able to change the author of a post so that it would show up as specific users or project owners. That meant the authorized plugin user would also need access to these posts after the author was changed–so to get past that I scoured each post revision, and if the plugin user was the author of a revision it was also allowed access.

    Finally, to make sure no other published posts were readable, I hooked into posts_results to set any post that didn’t meat the above criteria were marked as private. Below is a cleaned up version of that as an example if anyone else needs this type of functionality–feel free to use it as a starting point:

    <?php
    /**
     * Restricts post capabilities for a specific user.
     *
     * @param bool[]   $allcaps The current user capabilities.
     * @param string[] $caps    The requested capabilities.
     * @param array    $args {
     *     Arguments that accompany the requested capability check.
     *
     *     @type string    $0 Requested capability.
     *     @type int       $1 Concerned user ID.
     *     @type mixed  ...$2 Optional second and further parameters, typically object ID.
     * }
     * @param WP_User  $user    The user object.
     *
     * @return bool[] The modified user capabilities.
     */
    function emrikol_restrict_post_capabilities( array $allcaps, array $caps, array $args, WP_User $user ): array {
    	$post_id = isset( $args[2] ) ? absint( $args[2] ) : false;
    
    	if ( false === $post_id || ! get_post( $post_id ) ) {
    		return $allcaps;
    	}
    
    	if ( 'restricted' === get_user_meta( $user->ID, 'emrikol_restricted_post_capabilities', true ) ) {
    		$allowed_caps  = array( 'read', 'read_private_posts', 'read_post', 'edit_post', 'delete_post', 'edit_others_posts', 'delete_others_posts' );
    		$requested_cap = isset( $caps[0] ) ? $caps[0] : '';
    
    		if ( in_array( $requested_cap, $allowed_caps, true ) ) {
    			if ( emrikol_user_is_author_or_revision_author( $user->ID, $post_id ) ) {
    				$allcaps[ $requested_cap ] = true;
    			} else {
    				$allcaps[ $requested_cap ] = false;
    			}
    		}
    	}
    
    	return $allcaps;
    }
    add_filter( 'user_has_cap', 'emrikol_restrict_post_capabilities', 10, 4 );
    
    /**
     * Restricts the public posts results based on the query.
     *
     * @param WP_Post[] $posts  The array of posts returned by the query.
     * @param WP_Query  $query  The WP_Query instance (passed by reference).
     *
     * @return array           The filtered array of posts.
     */
    function emrikol_restrict_public_posts_results( array $posts, WP_Query $query ): array {
    	if ( ! is_admin() && $query->is_main_query() ) {
    		$current_user = wp_get_current_user();
    
    		if ( 'restricted' === get_user_meta( $user->ID, 'emrikol_restricted_post_capabilities', true ) ) {
    			foreach ( $posts as $key => $post ) {
    				if ( ! emrikol_user_is_author_or_revision_author( $current_user->ID, $post->ID ) ) {
    					$posts[ $key ]->post_status = 'private';
    				}
    			}
    		}
    	}
    
    	return $posts;
    }
    add_filter( 'posts_results', 'emrikol_restrict_public_posts_results', 10, 2 );
    
    /**
     * Checks if the user is the author of the post or the author of a revision.
     *
     * @param int $user_id The ID of the user.
     * @param int $post_id The ID of the post.
     *
     * @return bool True if the user is the author or revision author, false otherwise.
     */
    function emrikol_user_is_author_or_revision_author( int $user_id, int $post_id ): bool {
    	$post_author_id = (int) get_post_field( 'post_author', $post_id );
    
    	if ( $user_id === $post_author_id ) {
    		return true;
    	}
    
    	$revisions = wp_get_post_revisions( $post_id );
    
    	foreach ( $revisions as $revision ) {
    		if ( $user_id === $revision->post_author ) {
    			return true;
    		}
    	}
    
    	return false;
    }
    Code language: PHP (php)