Brutkey

Terence Eden’s Blog
@blog@shkspr.mobi

Regular nonsense about tech and its effects πŸ™ƒπŸ™ƒ
Published by
@Edent@mastodon.social / @edent.tel


If you reply to these posts, your reply may appear as comments on my blog.


Notes
1012
Following
0
Followers
0
Blog
https://shkspr.mobi/blog/

Terence Eden’s Blog
@blog@shkspr.mobi

A big list of things I disable in WordPress


https://shkspr.mobi/blog/2025/11/a-big-list-of-things-i-disable-in-wordpress/



There are many things I like about the WordPress blogging software, and many things I find irritating. The most annoying aspect is that WordPress insists that its way is the best and there shall be no deviance. That means a
lot of forced cruft being injected into my site. Headers that bloat my page size, Gutenberg stuff I've no use for, and ridiculous editorial decisions.

To double-down on the annoyance, there's no simple way to turn them off. In part, that is due to the "
WordPress Philosophy":

Decisions, not options[…] Every time you give a user an option, you are asking them to make a decision. When a user doesn’t care or understand the option this ultimately leads to frustration.
I broadly agree with that. Having hundreds of options is a burden for users and a nightmare for maintainers. Do please read this excellent discussion from Tom McFarlin for a more detailed analysis.

But I
want to turn things off. Luckily, there is a way. If you're a developer, you can remove a fair number of these "enforced" decisions. Add the following to your theme's functions.php file and watch the mandatory WordPress bloat whither away. I've commented each removal and, where possible, given a source for more information. Feel free to leave a comment suggesting how this script can be improved and simplified. PHP`// Remove mandatory classic theme.function disable_classic_theme_styles() {
wp_deregister_style( "classic-theme-styles" );
wp_dequeue_style( "classic-theme-styles" );
}
add_action( "wp_enqueue_scripts", "disable_classic_theme_styles" );

// Remove WP Emoji.//
http://www.denisbouquet.com/remove-wordpress-emoji-code/remove_action( "wp_head", "print_emoji_detection_script", 7 );
remove_action( "wp_print_styles", "print_emoji_styles" );
remove_action( "admin_print_scripts", "print_emoji_detection_script" );
remove_action( "admin_print_styles", "print_emoji_styles" );
//
https://wordpress.org/support/topic/remove-the-new-dns-prefetch-code/add_filter( "emoji_svg_url", "_returnfalse" );

// Stop emoji replacement with images in RSS / Atom Feeds//
https://danq.me/2023/09/04/wordpress-stop-emoji-images/remove_filter( "the_content_feed", "wp_staticize_emoji" );
remove_filter( "comment_text_rss", "wp_staticize_emoji" );

// Remove automatic formatting.//
https://css-tricks.com/snippets/wordpress/disable-automatic-formatting/remove_filter( "the_content", "wptexturize" );
remove_filter( "the_excerpt", "wptexturize" );
remove_filter( "comment_text", "wptexturize" );
remove_filter( "the_title", "wptexturize" );

// More formatting crap.add_action("init", function() {
remove_filter( "the_content", "convert_smilies", 20 );
foreach ( array( "the_content", "the_title", "wp_title", "document_title" ) as $filter ) {
remove_filter( $filter, "capital_P_dangit", 11 );
}
remove_filter( "comment_text", "capital_P_dangit", 31 ); // No idea why this is separate
remove_filter( "the_content", "do_blocks", 9 );
}, 11);

// Remove Gutenberg Styles.//
https://wordpress.org/support/topic/how-to-disable-inline-styling-style-idglobal-styles-inline-css/remove_action( "wp_enqueue_scripts", "wp_enqueue_global_styles" );

// Remove Gutenberg editing widgets.// From
https://wordpress.org/plugins/classic-widgets/// Disables the block editor from managing widgets in the Gutenberg plugin.add_filter( "gutenberg_use_widgets_block_editor", "_returnfalse" );
// Disables the block editor from managing widgets.add_filter( "use_widgets_block_editor", "_
returnfalse" );

// Remove Gutenberg Block Library CSS from loading on the frontend.//
https://smartwp.com/remove-gutenberg-css/function remove_wp_block_library_css() {
wp_dequeue_style( "wp-block-library" );
wp_dequeue_style( "wp-block-library-theme" );
wp_dequeue_style( "wp-components" );
}
add_action( "wp_enqueue_scripts", "remove_wp_block_library_css", 100 );

// Remove hovercards on comment links in admin area.//
https://wordpress.org/support/topic/how-to-disable-mshots-service/#post-12946617add_filter( "akismet_enable_mshots", "_returnfalse" );

// Remove Unused Plugin code.function remove_plugin_css_js() {
wp_dequeue_style( "image-sizes" );
}
add_action( "wp_enqueue_scripts", "remove_plugin_css_js", 100 );

// Remove WordPress forced image size//
https://core.trac.wordpress.org/ticket/62413#comment:40add_filter( "wp_img_tag_add_auto_sizes", "_returnfalse" );

// Remove <img> enhancements//
https://developer.wordpress.org/reference/functions/wp_filter_content_tags/remove_filter( "the_content", "wp_filter_content_tags", 12 );

// Stop rewriting http:// URls for the main domain.//
https://developer.wordpress.org/reference/hooks/wp_should_replace_insecure_home_url/remove_filter( "the_content", "wp_replace_insecure_home_url", 10 );

// Remove the attachment stuff//
https://developer.wordpress.org/news/2024/01/building-dynamic-block-based-attachment-templates-in-themes/remove_filter( "the_content", "prepend_attachment" );

// Remove the block filterremove_filter( "the_content", "apply_block_hooks_to_content_from_post_object", 8 );

// Remove browser check from Admin dashboard.//
https://core.trac.wordpress.org/attachment/ticket/27626/disable-wp-check-browser-version.0.2.phpif ( !empty( $_SERVER["HTTP_USER_AGENT"] ) ) {
add_filter( "pre_site_transient_browser_" . md5( $_SERVER["HTTP_USER_AGENT"] ), "_
returnnull" );
}

// Remove shortlink.//
https://stackoverflow.com/questions/42444063/disable-wordpress-short-linksremove_action( "wp_head", "wp_shortlink_wp_head" );

// Remove RSD.//
https://wpengineer.com/1438/wordpress-header/remove_action( "wp_head", "rsd_link" );

// Remove extra feed links.//
https://developer.wordpress.org/reference/functions/feed_links/add_filter( "feed_links_show_comments_feed", "_returnfalse" );
add_filter( "feed_links_show_posts_feed", "_
returnfalse" );

// Remove api.w.org link.//
https://wordpress.stackexchange.com/questions/211467/remove-json-api-links-in-header-htmlremove_action( "wp_head", "rest_output_link_wp_head" );
//
https://wordpress.stackexchange.com/questions/211817/how-to-remove-rest-api-link-in-http-headers// https://developer.wordpress.org/reference/functions/rest_output_link_header/remove_action( "template_redirect", "rest_output_link_header", 11, 0 );
`

You can find the latest version of
my debloat script in my theme's repo.

If there are other things you find helpful to remove, or a better way to organise this file, please drop a comment in the box.

#blog #HowTo #php #WordPress

Terence Eden’s Blog
@blog@shkspr.mobi

How long does it take to upgrade an eBook?


https://shkspr.mobi/blog/2025/08/how-long-does-it-take-to-upgrade-an-ebook/



The older I get, the more comfortable I become with complaining. Not merely moaning on social media, but writing a direct email to the perpetrator of some annoyance.

I'd purchased an eBook and was
appalled by how crappy the accessibility was. If you don't know, modern ePub books are just HTML wrapped in a zip file. They have all of the accessibility advantages of the web and should be easy to read no matter if you're sighted or not.

But not this eBook! Part of the story concerned WhatsApp messages sent by the protagonist. Here's one of them:



See that smudge in the middle? The one smaller than a full stop? That's meant to be an emoji. Rather than use an
actual emoji, they've stuck in a tiny pixel image which won't scale with text size.

Here is is:



Can't see it? Let me blow it up to a more readable size.



OK, that's annoying for sighted readers, but just about understandable. What about people who are using a screen reader? Luckily, ePubs can use HTML's "alt text" feature which will describe an image which can't be seen.



Curses! That's, perhaps, not
too annoying for a decorative image - but later in the book there are pictures of a ransom note. Despite the plot-twisting text in the illustration, the alt text just says "image".

I found the publisher's website and scoffed at their proclaimed accessibility statement. I sent them an email which basically consisted of the above. I realised it was probably futile, but I've got to spend my privilege points somehow.

The next day, they wrote back!

I wanted to reply to let you know we are taking our commitments to accessibility in our ebooks seriously […] I will get our ebook team to check this as a matter of urgency and get back to you with an update.
Fair play. But talk is cheap, would they actually take action? A few days later, they sent a follow-up:
We have checked the file for this title again and found issues with it. We have sent this back to the conversion house to have the file corrected and expect this to be delivered by the end of the week. We would then expect this corrected version to be with Kobo by the end of the following week, i.e. 25th July.We have also been spot-checking other files to see if there is a wider issue and where necessary will follow the same workflow to ensure fully accessible versions are available as widely as possible.
And, you know what, I think that's totally reasonable. Yeah, they should have caught it before publication - but it is a complex book and they're a small publisher. They took my complaint seriously and actually did something about it.

A week or so rolled by and they sent me this:
Just to update you that we have been back and forth with the conversion house getting this title up to scratch. There were various complicating factors which should now be resolved and an updated file has now been distributed and should be available through your chosen retailer presently. If you have any other queries please do let us know.
I logged on to my eBook provider, clicked "read" and…



It was fixed! All the images had decent alt text as well.

Rather annoyingly, the retailer didn't notify me that there was an update available. I can't blame the publisher for that though.

Still, 3 weeks from report to fix is pretty good I reckon.

When I last contacted a publisher about a mistake in their ebook,
it took over 3 months to fix it. Perhaps things are slowly getting better?

Anyway, please complain about poor accessibility. Don't shout into the void of social media - write a polite but insistent email telling (not asking) people to fix their shit. Sometimes, just sometimes, it does work.

Anyway you should read
The Mysterious Case of the Alperton Angels by Janice Hallett - it is brilliant and accessible.

#a11y #accessibility #books #ebook #ebooks

Terence Eden’s Blog
@blog@shkspr.mobi

ActivityPub Server in a Single PHP File


https://shkspr.mobi/blog/2024/02/activitypub-server-in-a-single-file/



Any computer program can be designed to run from a single file if you architect it wrong enough!

I wanted to create the simplest possible Fediverse server which can be used as an educational tool to show how ActivityPub / Mastodon works.

The design goals were:
Upload a single PHP file to the server.
No databases or separate config files.
Single Actor (i.e. not multi-user).
Allow the Actor to be followed.
Post plain-text messages to followers.
Be
roughly standards compliant.

And those goals have all been met!
Check it out on GitLab. I warn you though, it is the nadir of bad coding. There are no tests, bugger-all security, scalability isn't considered, and it is a mess. But it works.

You can follow the test user
@example@example.viii.fi[Architecture](#architecture)


Firstly, I've slightly cheated on my "single file" stipulation. There's an
.htaccess file which turns example.com/whatever into example.com/index.php?path=whatever

The
index.php file then takes that path and does stuff. It also contains all the configuration variables which is very bad practice.

Rather than using a database, it saves files to disk.

Again, this is
not suitable for any real world use. This is an educational tool to help explain the basics of posting messages to the Fediverse. It requires absolutely no dependencies. You do not need to spin up a dockerised hypervisor to manage your node bundles and re-compile everything to WASM. Just FTP the file up to prod and you're done.[Walkthrough](#walkthrough)


This is a quick ramble through the code. It is reasonably well documented, I hope.
[Preamble](#preamble)


This is where you set up your account's name and bio. You also need to provide a public/private keypair. The posting page is protected with a password that also needs to be set here. PHP
// Set up the Actor's information $username = rawurlencode("example"); // Encoded as it is often used as part of a URl $realName = "E. Xample. Jr."; $summary = "Some text about the user."; $server = $_SERVER["SERVER_NAME"]; // Domain name this is hosted on // Generate locally or from https://cryptotools.net/rsagen // Newlines must be replaced with "\n" $key_private = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"; $key_public = "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"; // Password for sending messages $password = "P4ssW0rd";[Logging](#logging)


ActivityPub is a "chatty" protocol. This takes all the requests your server receives and saves them in
/logs/ as a datestamped text file. PHP // Get all headers and requests sent to this server $headers = print_r( getallheaders(), true ); $postData = print_r( $_POST, true ); $getData = print_r( $_GET, true ); $filesData = print_r( $_FILES, true ); $body = json_decode( file_get_contents( "php://input" ), true ); $bodyData = print_r( $body, true ); $requestData = print_r( $_REQUEST, true ); $serverData = print_r( $_SERVER, true ); // Get the type of request - used in the log filename if ( isset( $body["type"] ) ) { $type = " " . $body["type"]; } else { $type = ""; } // Create a timestamp in ISO 8601 format for the filename $timestamp = date( "c" ); // Filename for the log $filename = "{$timestamp}{$type}.txt"; // Save headers and request data to the timestamped file in the logs directory if( ! is_dir( "logs" ) ) { mkdir( "logs"); } file_put_contents( "logs/{$filename}", "Headers: \n$headers \n\n" . "Body Data: \n$bodyData \n\n" . "POST Data: \n$postData \n\n" . "GET Data: \n$getData \n\n" . "Files Data: \n$filesData \n\n" . "Request Data:\n$requestData\n\n" . "Server Data: \n$serverData \n\n" );[Routing](#routing)


The
.htaccess changes /whatever to /?path=whateverThis runs the function of the path requested. PHP !empty( $_GET["path"] ) ? $path = $_GET["path"] : die(); switch ($path) { case ".well-known/webfinger": webfinger(); case rawurldecode( $username ): username(); case "following": following(); case "followers": followers(); case "inbox": inbox(); case "write": write(); case "send": send(); default: die(); }[WebFinger](#webfinger)


The
WebFinger Protocol is used to identify accounts.It is requested with example.com/.well-known/webfinger?resource=acct:username@example.comThis server only has one user, so it ignores the query string and always returns the same details. PHP function webfinger() { global $username, $server; $webfinger = array( "subject" => "acct:{$username}@{$server}", "links" => array( array( "rel" => "self", "type" => "application/activity+json", "href" => "https://{$server}/{$username}" ) ) ); header( "Content-Type: application/json" ); echo json_encode( $webfinger ); die(); }[Username](#username)


Requesting
example.com/username returns a JSON document with the user's information. PHP function username() { global $username, $realName, $summary, $server, $key_public; $user = array( "@context" => [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "id" => "https://{$server}/{$username}", "type" => "Person", "following" => "https://{$server}/following", "followers" => "https://{$server}/followers", "inbox" => "https://{$server}/inbox", "preferredUsername" => rawurldecode($username), "name" => "{$realName}", "summary" => "{$summary}", "url" => "https://{$server}", "manuallyApprovesFollowers" => true, "discoverable" => true, "published" => "2024-02-12T11:51:00Z", "icon" => [ "type" => "Image", "mediaType" => "image/png", "url" => "https://{$server}/icon.png" ], "publicKey" => [ "id" => "https://{$server}/{$username}#main-key", "owner" => "https://{$server}/{$username}", "publicKeyPem" => $key_public ] ); header( "Content-Type: application/activity+json" ); echo json_encode( $user ); die(); }[Following & Followers](#following-followers)


These JSON documents show how many users are following / followers-of this account.The information here is self-attested. So you can lie and use any number you want. PHP
function following() { global $server; $following = array( "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/following", "type" => "Collection", "totalItems" => 0, "items" => [] ); header( "Content-Type: application/activity+json" ); echo json_encode( $following ); die(); } function followers() { global $server; $followers = array( "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/followers", "type" => "Collection", "totalItems" => 0, "items" => [] ); header( "Content-Type: application/activity+json" ); echo json_encode( $followers ); die(); }[Inbox](#inbox)


The
/inbox is the main server. It receives all requests. This server only responds to "Follow" requests.A remote server sends a follow request which is a JSON file saying who they are.This code does not cryptographically validate the headers of the received message.The name of the remote user's server is saved to a file so that future messages can be delivered to it.An accept request is cryptographically signed and POST'd back to the remote server. PHP function inbox() { global $body, $server, $username, $key_private; // Get the message and type $inbox_message = $body; $inbox_type = $inbox_message["type"]; // This inbox only responds to follow requests if ( "Follow" != $inbox_type ) { die(); } // Get the parameters $inbox_id = $inbox_message["id"]; $inbox_actor = $inbox_message["actor"]; $inbox_host = parse_url( $inbox_actor, PHP_URL_HOST ); // Does this account have any followers? if( file_exists( "followers.json" ) ) { $followers_file = file_get_contents( "followers.json" ); $followers_json = json_decode( $followers_file, true ); } else { $followers_json = array(); } // Add user to list. Don't care about duplicate users, server is what's important $followers_json[$inbox_host]["users"][] = $inbox_actor; // Save the new followers file file_put_contents( "followers.json", print_r( json_encode( $followers_json ), true ) ); // Response Message ID // This isn't used for anything important so could just be a random number $guid = uuid(); // Create the Accept message $message = [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/{$guid}", "type" => "Accept", "actor" => "https://{$server}/{$username}", "object" => [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => $inbox_id, "type" => $inbox_type, "actor" => $inbox_actor, "object" => "https://{$server}/{$username}", ] ]; // The Accept is sent to the server of the user who requested the follow // TODO: The path doesn't *always* end with/inbox $host = $inbox_host; $path = parse_url( $inbox_actor, PHP_URL_PATH ) . "/inbox"; // Get the signed headers $headers = generate_signed_headers( $message, $host, $path ); // Specify the URL of the remote server's inbox // TODO: The path doesn't *always* end with /inbox $remoteServerUrl = $inbox_actor . "/inbox"; // POST the message and header to the requester's inbox $ch = curl_init( $remoteServerUrl ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode($message) ); curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); $response = curl_exec( $ch ); // Check for errors if( curl_errno( $ch ) ) { file_put_contents( "error.txt", curl_error( $ch ) ); } curl_close($ch); die(); }[UUID](#uuid)


Every message sent should have a unique ID. This can be anything you like. Some servers use a random number.I prefer a date-sortable string. PHP
function uuid() { return sprintf( "%08x-%04x-%04x-%04x-%012x", time(), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffffffffffff) ); }[Signing Headers](#signing-headers)


Every message that your server sends needs to be cryptographically signed with your Private Key.This is a complicated process. Please read "
How to make friends and verify requests" for more information. PHP function generate_signed_headers( $message, $host, $path ) { global $server, $username, $key_private; // Encode the message to JSON $message_json = json_encode( $message ); // Location of the Public Key $keyId = "https://{$server}/{$username}#main-key"; // Generate signing variables $hash = hash( "sha256", $message_json, true ); $digest = base64_encode( $hash ); $date = date( "D, d M Y H:i:s \G\M\T" ); // Get the Private Key $signer = openssl_get_privatekey( $key_private ); // Sign the path, host, date, and digest $stringToSign = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest"; // The signing function returns the variable $signature // https://www.php.net/manual/en/function.openssl-sign.php openssl_sign( $stringToSign, $signature, $signer, OPENSSL_ALGO_SHA256 ); // Encode the signature $signature_b64 = base64_encode( $signature ); // Full signature header $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; // Header for POST reply $headers = array( "Host: {$host}", "Date: {$date}", "Digest: SHA-256={$digest}", "Signature: {$signature_header}", "Content-Type: application/activity+json", "Accept: application/activity+json", ); return $headers; }[User Interface for Writing](#user-interface-for-writing)


This creates a basic HTML form. Type in your message and your password. It then POSTs the data to the
/send endpoint. PHP function write() { // Display an HTML form for the user to enter a message.echo <<< HTML<!DOCTYPE html><html lang="en-GB"> <head> <meta charset="UTF-8"> <title>Send Message</title> <style> *{font-family:sans-serif;font-size:1.1em;} </style> </head> <body> <form action="/send" method="post" enctype="multipart/form-data"> <label for="content">Your message:</label><br> <textarea id="content" name="content" rows="5" cols="32"></textarea><br> <label for="password">Password</label><br> <input type="password" name="password" id="password" size="32"><br> <input type="submit" value="Post Message"> </form> </body></html>HTML; die(); }[Send Endpoint](#send-endpoint)


This takes the submitted message and checks the password is correct.It reads the
followers.json file and sends the message to every server that is following this account. PHP function send() { global $password, $server, $username, $key_private; // Does the posted password match the stored password? if( $password != $_POST["password"] ) { die(); } // Get the posted content $content = $_POST["content"]; // Current time - ISO8601 $timestamp = date( "c" ); // Outgoing Message ID $guid = uuid(); // Construct the Note // contentMap is used to prevent unnecessary "translate this post" pop ups // hardcoded to English $note = [ "@context" => array( "https://www.w3.org/ns/activitystreams" ), "id" => "https://{$server}/posts/{$guid}.json", "type" => "Note", "published" => $timestamp, "attributedTo" => "https://{$server}/{$username}", "content" => $content, "contentMap" => ["en" => $content], "to" => ["https://www.w3.org/ns/activitystreams#Public"] ]; // Construct the Message $message = [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/posts/{$guid}.json", "type" => "Create", "actor" => "https://{$server}/{$username}", "to" => [ "https://www.w3.org/ns/activitystreams#Public" ], "cc" => [ "https://{$server}/followers" ], "object" => $note ]; // Create the context for the permalink $note = [ "@context" => "https://www.w3.org/ns/activitystreams", ...$note ]; // Save the permalink $note_json = json_encode( $note ); // Check for posts/ directory and create it if( ! is_dir( "posts" ) ) { mkdir( "posts"); } file_put_contents( "posts/{$guid}.json", print_r( $note_json, true ) ); // Read existing users and get their hosts $followers_file = file_get_contents( "followers.json" ); $followers_json = json_decode( $followers_file, true ); $hosts = array_keys( $followers_json ); // Prepare to use the multiple cURL handle $mh = curl_multi_init(); // Loop through all the severs of the followers // Each server needs its own cURL handle // Each POST to an inbox needs to be signed separately foreach ( $hosts as $host ) { $path = "/inbox"; // Get the signed headers $headers = generate_signed_headers( $message, $host, $path ); // Specify the URL of the remote server $remoteServerUrl = "https://{$host}{$path}"; // POST the message and header to the requester's inbox $ch = curl_init( $remoteServerUrl ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode($message) ); curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); // Add the handle to the multi-handle curl_multi_add_handle( $mh, $ch ); } // Execute the multi-handle do { $status = curl_multi_exec( $mh, $active ); if ( $active ) { curl_multi_select( $mh ); } } while ( $active && $status == CURLM_OK ); // Close the multi-handle curl_multi_close( $mh ); // Render the JSON so the user can see the POST has worked header( "Location: https://{$server}/posts/{$guid}.json" ); die(); }[Next Steps](#next-steps)


This is
not intended to be used in production. Ever. But if you would like to contribute more simple examples of how the protocol works, please come and play on GitLab.

You can follow the test user
@example@example.viii.fi

#activitypub #mastodon #php