266 lines
8.7 KiB
PHP
266 lines
8.7 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* The BNS gateway client.
|
||
|
|
*
|
||
|
|
* Three endpoints matter to this fork:
|
||
|
|
*
|
||
|
|
* GET /api/name/<name> 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/<name>/<path> read back what was published.
|
||
|
|
* PUT /api/site/<name>/<path> 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;
|
||
|
|
}
|
||
|
|
}
|