sirius-press/plugins/sirius-press-sia-export/includes/class-spe-mapper.php

222 lines
6.7 KiB
PHP
Raw Normal View History

feat(sirius-press): a WordPress where the account is a key, not a mailbox WordPress makes two assumptions this project cannot accept: that identity comes from an email address, and that a site lives at one server. Both are things somebody else can take away — a mailbox is rented from a provider who can close it or be compelled to open it, and a server is one seizure from being gone. Sirius Press replaces the first and hedges the second. Signing in means signing a challenge with the key that controls a CashAddress. The address is recovered from the signature, so nothing is typed but the signature itself, and the result is an ordinary WordPress session cookie — roles, capabilities, nonces and the REST API never learn the login was different. Three ways to produce one: a wallet the browser already exposes, a phrase used once in the page and wiped, or a signature pasted in from any BIP-137 wallet, which needs no JavaScript and lets the key stay on a machine that never touches the web. There is no password reset, and the recovery page says so plainly rather than offering a form that cannot work. A reset mechanism is by construction a way to take an account from its owner, and it is always easier to attack than the cryptography it bypasses. Publishing a post also exports it as static HTML to the name's storage on Sia, signed by the key that owns the name, so the site keeps answering when the server does not. Email as a feature is untouched. wp_mail() still works, SMTP still sends, and contact forms still deliver to addresses real people typed. Only mail to the site's own unroutable placeholder addresses is diverted to an in-app inbox. The objection was to email as identity, not to email. Core is pinned and patched rather than vendored. WordPress 7.1.1 is 149 MB and 5,008 files; the fork's entire core diff is 75 lines in wp-admin/install.php. Carrying the former to express the latter would bury the patch where nobody reviews it and make every clone of the monorepo pay for it. Upstream releases still merge through tools/update-wordpress.sh, which reapplies the series and says exactly which hunk needs a human. The cryptography is implemented twice — PHP on the server, JavaScript in the page — because the server must verify and the browser must sign. Both are pinned against libauth, the library the Sirius portal wallet and the BNS gateway already use, so a disagreement of one byte fails the test suite rather than presenting as a rejected login at three in the morning. 132 checks, no framework, about a second.
2026-09-21 01:39:38 +02:00
<?php
/**
* Where each URL lands in the name's bucket.
*
* WordPress URLs are directories: `/hello-world/`. Object storage has no
* directories and no index resolution of its own, so every such URL becomes
* `hello-world/index.html` and the resolver's directory-index rescue does the
* rest. URLs that already name a file `/robots.txt`, `/wp-content/…/app.css`
* keep their own name.
*
* The one rule this file exists to enforce is that the mapping is total and
* reversible enough to be safe: anything that cannot be represented as a
* gateway-legal path returns '' rather than being coerced into some other
* file's name. A path collision in a static export is silent data loss one
* page overwrites another and nobody notices until a reader hits the wrong
* article.
*
* @package SiriusPress
*/
defined( 'ABSPATH' ) || exit;
final class SPE_Mapper {
/** Extensions treated as assets rather than pages. */
const ASSET_EXTENSIONS = array(
'css', 'js', 'mjs', 'json', 'map',
'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico',
'woff', 'woff2', 'ttf', 'eot', 'otf',
'mp3', 'mp4', 'webm', 'ogg', 'wav', 'pdf', 'zip', 'txt', 'xml',
);
/**
* Bucket path for a site-relative URL path.
*
* @param string $url_path e.g. '/blog/hello/' or '/wp-content/x.css'.
* @return string '' when the path cannot be exported.
*/
public static function path_for_url_path( $url_path ) {
$home_path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
$url_path = '/' . ltrim( (string) $url_path, '/' );
// A site installed in a subdirectory serves its own root at that
// prefix; the export has the name's root, so the prefix comes off.
if ( '' !== $home_path && '/' !== $home_path && 0 === strpos( $url_path, $home_path ) ) {
$url_path = '/' . ltrim( substr( $url_path, strlen( $home_path ) ), '/' );
}
$url_path = rawurldecode( $url_path );
$trimmed = trim( $url_path, '/' );
if ( '' === $trimmed ) {
return 'index.html';
}
// A URL naming a file keeps its name; a URL naming a directory gets an
// index.html inside it. "Names a file" means the last segment has an
// extension AND the URL did not end in a slash — `/about.us/` is a
// page called "about.us", not a file.
$slash = strrpos( $trimmed, '/' );
$last = false === $slash ? $trimmed : substr( $trimmed, $slash + 1 );
$path = ( false !== strpos( $last, '.' ) && '/' !== substr( $url_path, -1 ) )
? $trimmed
: $trimmed . '/index.html';
// Everything the gateway would reject is better refused here, where
// there is still a chance to leave the original URL alone.
return SP_Gateway::clean_path( $path );
}
/** Bucket path for a post, page or any public post type. */
public static function path_for_post( $post ) {
$post = get_post( $post );
if ( ! $post ) {
return '';
}
$permalink = get_permalink( $post );
if ( ! $permalink ) {
return '';
}
return self::path_for_url_path( (string) wp_parse_url( $permalink, PHP_URL_PATH ) );
}
public static function is_asset( $path ) {
$ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
return in_array( $ext, self::ASSET_EXTENSIONS, true ) && 'html' !== $ext;
}
/**
* Everything a full export should cover.
*
* @return array<string,array{url:string,kind:string}> path => descriptor.
*/
public static function full_site() {
$targets = array();
$add = function ( $url, $kind = 'page' ) use ( &$targets ) {
if ( ! $url ) {
return;
}
$path = self::path_for_url_path( (string) wp_parse_url( $url, PHP_URL_PATH ) );
if ( '' === $path ) {
return;
}
$targets[ $path ] = array(
'url' => $url,
'kind' => $kind,
);
};
$add( home_url( '/' ) );
// Every published thing with a public permalink.
$types = get_post_types( array( 'public' => true ), 'names' );
unset( $types['attachment'] );
$posts = get_posts(
array(
'post_type' => array_values( $types ),
'post_status' => 'publish',
'numberposts' => -1,
'fields' => 'ids',
'suppress_filters' => false,
)
);
foreach ( $posts as $post_id ) {
$add( get_permalink( $post_id ) );
}
// Archives people actually navigate through. Date archives are left
// out by default: on a long-running blog they multiply into hundreds
// of near-identical pages that nothing links to.
foreach ( get_taxonomies( array( 'public' => true ), 'names' ) as $taxonomy ) {
$terms = get_terms(
array(
'taxonomy' => $taxonomy,
'hide_empty' => true,
)
);
if ( is_wp_error( $terms ) ) {
continue;
}
foreach ( $terms as $term ) {
$link = get_term_link( $term );
if ( ! is_wp_error( $link ) ) {
$add( $link, 'archive' );
}
}
}
if ( 'page' === get_option( 'show_on_front' ) && get_option( 'page_for_posts' ) ) {
$add( get_permalink( (int) get_option( 'page_for_posts' ) ), 'archive' );
}
/**
* Filters the full list of export targets.
*
* @param array $targets path => {url, kind}.
*/
return apply_filters( 'sirius_press_export_targets', $targets );
}
/**
* The paths that go stale when one post changes.
*
* A post edit does not only change the post: it changes the home page, the
* archives it belongs to, and the posts either side of it if the theme
* shows previous/next links. Being generous here costs a few uploads;
* being stingy leaves a reader looking at a stale index.
*
* @return array<string,array{url:string,kind:string}>
*/
public static function affected_by_post( $post ) {
$post = get_post( $post );
$targets = array();
if ( ! $post ) {
return $targets;
}
$add = function ( $url, $kind = 'page' ) use ( &$targets ) {
if ( ! $url || is_wp_error( $url ) ) {
return;
}
$path = SPE_Mapper::path_for_url_path( (string) wp_parse_url( $url, PHP_URL_PATH ) );
if ( '' !== $path ) {
$targets[ $path ] = array(
'url' => $url,
'kind' => $kind,
);
}
};
if ( 'publish' === $post->post_status ) {
$add( get_permalink( $post ) );
}
$add( home_url( '/' ) );
if ( 'page' === get_option( 'show_on_front' ) && get_option( 'page_for_posts' ) ) {
$add( get_permalink( (int) get_option( 'page_for_posts' ) ), 'archive' );
}
foreach ( get_object_taxonomies( $post->post_type, 'names' ) as $taxonomy ) {
$tax = get_taxonomy( $taxonomy );
if ( ! $tax || ! $tax->public ) {
continue;
}
foreach ( (array) get_the_terms( $post, $taxonomy ) as $term ) {
if ( $term instanceof WP_Term ) {
$add( get_term_link( $term ), 'archive' );
}
}
}
/**
* Filters which paths a post change invalidates.
*
* @param array $targets
* @param WP_Post $post
*/
return apply_filters( 'sirius_press_affected_paths', $targets, $post );
}
}