who owns the name right now, so the * settings screen can tell an owner that the * key they pasted is not the one the chain * recognises — before their first publish * fails with a bare 403. * GET /api/site// read back what was published. * PUT /api/site// publish, signed BNS-SITE1. * * Every response is treated as untrusted input from a third party. The * gateway is a relay, not an authority: it cannot forge a signature, but it * can lie about what is stored, and nothing here grants it more trust than * "it told us the upload succeeded". * * @package SiriusPress */ defined( 'ABSPATH' ) || exit; final class SP_Gateway { /** Gateway's own cap. Anything larger is rejected before the round trip. */ const MAX_BYTES = 8388608; /** * Publish one file into the name's bucket. * * @param string $path Path inside the bucket, e.g. 'blog/hello/index.html'. * @param string $body Raw bytes. * @param string $mime Content type; guessed from the extension if ''. * @return array{ok:bool,status:int,error:string,bytes:int,sia_key:string} */ public static function put( $path, $body, $mime = '' ) { $name = SP_Settings::name(); if ( '' === $name ) { return self::fail( 'This site has no BCNR name configured yet.' ); } $path = self::clean_path( $path ); if ( '' === $path ) { return self::fail( 'Refusing to publish to an unusable path.' ); } if ( strlen( $body ) > self::MAX_BYTES ) { return self::fail( sprintf( 'That file is %s; the gateway accepts 8 MB per file.', size_format( strlen( $body ) ) ) ); } try { $priv = SP_Settings::publishing_private_key(); } catch ( Exception $e ) { return self::fail( $e->getMessage() ); } if ( '' === $priv ) { return self::fail( 'No publishing key is stored, so this site cannot sign an upload. Add the name\'s recovery phrase in Sirius Press settings, or switch to manual publishing.' ); } try { $headers = SP_Message::site_headers( $name, $path, $body, $priv ); } catch ( Exception $e ) { return self::fail( 'Signing failed: ' . $e->getMessage() ); } $headers['content-type'] = '' !== $mime ? $mime : self::mime_for( $path ); $res = wp_remote_request( self::url( $name, $path ), array( 'method' => 'PUT', 'timeout' => 45, 'headers' => $headers, 'body' => $body, ) ); return self::interpret( $res ); } /** * Remove a file from the bucket — used when a post is unpublished or its * permalink changes, so the static mirror does not keep serving a page the * site no longer has. */ public static function delete( $path ) { $name = SP_Settings::name(); $path = self::clean_path( $path ); if ( '' === $name || '' === $path ) { return self::fail( 'Nothing to delete.' ); } try { $priv = SP_Settings::publishing_private_key(); if ( '' === $priv ) { return self::fail( 'No publishing key is stored.' ); } $headers = SP_Message::site_headers( $name, $path, '', $priv ); } catch ( Exception $e ) { return self::fail( $e->getMessage() ); } $res = wp_remote_request( self::url( $name, $path ), array( 'method' => 'DELETE', 'timeout' => 30, 'headers' => $headers, ) ); return self::interpret( $res ); } /** Read a published file back. @return string|null */ public static function get( $path ) { $name = SP_Settings::name(); $path = self::clean_path( $path ); if ( '' === $name || '' === $path ) { return null; } $res = wp_remote_get( self::url( $name, $path ), array( 'timeout' => 30 ) ); if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { return null; } return wp_remote_retrieve_body( $res ); } /** List what the bucket currently holds. @return array|null */ public static function listing() { $name = SP_Settings::name(); if ( '' === $name ) { return null; } $res = wp_remote_get( SP_Settings::gateway() . '/api/site/' . rawurlencode( $name ), array( 'timeout' => 30 ) ); if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { return null; } $json = json_decode( wp_remote_retrieve_body( $res ), true ); return is_array( $json ) && isset( $json['files'] ) ? $json : null; } /** * The name's current on-chain owner address, or '' if the gateway will not * say. Used to warn about a key mismatch before it becomes a 403. */ public static function owner_of_name() { $name = SP_Settings::name(); if ( '' === $name ) { return ''; } $res = wp_remote_get( SP_Settings::gateway() . '/api/name/' . rawurlencode( $name ), array( 'timeout' => 20 ) ); if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { return ''; } $json = json_decode( wp_remote_retrieve_body( $res ), true ); if ( ! is_array( $json ) ) { return ''; } foreach ( array( 'owner', 'owner_address', 'address' ) as $k ) { if ( ! empty( $json[ $k ] ) && is_string( $json[ $k ] ) ) { return SP_CashAddr::normalize( $json[ $k ] ); } } return ''; } // ----------------------------------------------------------------- utils private static function url( $name, $path ) { $encoded = implode( '/', array_map( 'rawurlencode', explode( '/', $path ) ) ); return SP_Settings::gateway() . '/api/site/' . rawurlencode( $name ) . '/' . $encoded; } /** * Squeeze a path into what the gateway's own validator accepts: * `[A-Za-z0-9._\-\/]{1,200}`, no `..`, no leading or trailing slash. * * Anything that cannot be represented comes back '' rather than being * silently mangled into a different file. */ public static function clean_path( $path ) { $path = ltrim( str_replace( '\\', '/', (string) $path ), '/' ); $path = preg_replace( '#/+#', '/', $path ); if ( '' === $path || '/' === substr( $path, -1 ) ) { return ''; } if ( false !== strpos( $path, '..' ) || strlen( $path ) > 200 ) { return ''; } return preg_match( '#^[A-Za-z0-9._\-/]+$#', $path ) ? $path : ''; } /** Content types the gateway itself recognises, keyed by extension. */ public static function mime_for( $path ) { static $types = array( 'html' => 'text/html; charset=utf-8', 'htm' => 'text/html; charset=utf-8', 'css' => 'text/css; charset=utf-8', 'js' => 'text/javascript; charset=utf-8', 'mjs' => 'text/javascript; charset=utf-8', 'json' => 'application/json', 'txt' => 'text/plain; charset=utf-8', 'md' => 'text/markdown; charset=utf-8', 'xml' => 'application/xml', 'svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif', 'ico' => 'image/x-icon', 'woff' => 'font/woff', 'woff2' => 'font/woff2', 'ttf' => 'font/ttf', 'pdf' => 'application/pdf', 'mp3' => 'audio/mpeg', 'mp4' => 'video/mp4', 'webm' => 'video/webm', 'wasm' => 'application/wasm', ); $ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ); return isset( $types[ $ext ] ) ? $types[ $ext ] : 'application/octet-stream'; } private static function fail( $message ) { return array( 'ok' => false, 'status' => 0, 'error' => $message, 'bytes' => 0, 'sia_key' => '', ); } /** Turn a wp_remote_* result into the shape the queue stores. */ private static function interpret( $res ) { if ( is_wp_error( $res ) ) { return self::fail( $res->get_error_message() ); } $status = (int) wp_remote_retrieve_response_code( $res ); $json = json_decode( wp_remote_retrieve_body( $res ), true ); if ( 200 === $status && is_array( $json ) && ! empty( $json['ok'] ) ) { return array( 'ok' => true, 'status' => $status, 'error' => '', 'bytes' => isset( $json['bytes'] ) ? (int) $json['bytes'] : 0, 'sia_key' => isset( $json['sia_key'] ) ? (string) $json['sia_key'] : '', ); } $error = is_array( $json ) && ! empty( $json['error'] ) ? (string) $json['error'] : sprintf( 'gateway returned HTTP %d', $status ); // The two failures an operator will actually hit, translated out of // gateway-speak into something that says what to do about it. if ( 403 === $status ) { $error .= ' — the stored publishing key is not the address that currently owns this name.'; } elseif ( 503 === $status ) { $error .= ' — the gateway has not indexed this name yet; it usually catches up within a minute.'; } $out = self::fail( $error ); $out['status'] = $status; return $out; } }