'none',
'supported' => self::supported(),
);
}
$ready = empty( $j['queue'] );
return array(
'state' => $ready ? 'ready' : 'packing',
'supported' => true,
'done' => (int) $j['total'] - count( $j['queue'] ),
'total' => (int) $j['total'],
'size' => $ready ? size_format( (int) filesize( $j['file'] ), 1 ) : '',
'expires' => $ready ? wp_date( 'j F, H:i', (int) $j['created'] + self::KEEP ) : '',
'download' => $ready ? add_query_arg(
array(
'action' => 'stubli_archive_download',
'_wpnonce' => wp_create_nonce( 'stubli_archive_download' ),
),
admin_url( 'admin-post.php' )
) : '',
);
}
/**
* Start a new archive: the pages and data file go in now, the media files in steps.
*
* @return array|\WP_Error
*/
public static function start() {
if ( ! self::supported() ) {
return new \WP_Error( 'stubli_archive_zip', __( 'This host can\'t make zip files, so the archive can\'t be built here. Ask your host to turn on PHP\'s Zip extension.', 'stubli' ), array( 'status' => 500 ) );
}
self::remove();
$host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
$file = self::dir() . '/stubli-' . sanitize_file_name( $host ) . '-' . gmdate( 'Y-m-d' ) . '-' . wp_generate_password( 20, false ) . '.zip';
$posts = array();
foreach ( Bundle::post_ids() as $pid ) {
$p = Bundle::post( $pid );
if ( $p && 'other' !== $p['type'] ) {
$posts[] = $p;
}
}
$queue = array();
$names = array();
$place = static function ( &$m, $date ) use ( &$queue, &$names ) {
if ( ! $m || '' === $m['path'] || ! file_exists( $m['path'] ) ) {
if ( $m ) {
$m['file'] = '';
}
return;
}
$base = 'media/' . substr( $date, 0, 7 ) . '/' . $m['name'];
$name = $base;
$i = 2;
while ( isset( $names[ $name ] ) ) {
$name = preg_replace( '/(\.[^.]+)$/', '-' . $i . '$1', $base );
++$i;
}
$names[ $name ] = true;
$m['file'] = $name;
$queue[] = array( $m['path'], $name );
};
foreach ( $posts as &$p ) {
foreach ( $p['photos'] as &$ph ) {
$place( $ph, $p['date'] );
if ( ! empty( $ph['video'] ) ) {
$place( $ph['video'], $p['date'] );
}
}
unset( $ph );
}
unset( $p );
$profile = Bundle::profile();
if ( $profile['avatar'] ) {
$place( $profile['avatar'], gmdate( 'Y-m-d' ) );
if ( $profile['avatar']['file'] ) {
$profile['avatar']['file'] = 'media/profile-' . $profile['avatar']['name'];
$queue[ count( $queue ) - 1 ][1] = $profile['avatar']['file'];
}
}
$data = array(
'format' => Bundle::FORMAT,
'generator' => 'stubli/' . VERSION,
'exported' => gmdate( 'c' ),
'site' => home_url( '/' ),
'profile' => $profile,
'settings' => Bundle::settings(),
'following' => Bundle::follows(),
'guestbook' => Bundle::guestbook(),
'posts' => $posts,
);
$strip = static function ( $v ) use ( &$strip ) {
if ( is_array( $v ) ) {
unset( $v['path'] );
return array_map( $strip, $v );
}
return $v;
};
$data = $strip( $data );
$zip = new \ZipArchive();
if ( true !== $zip->open( $file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
return new \WP_Error( 'stubli_archive_write', __( 'The archive couldn\'t be started. There may not be enough space on this host.', 'stubli' ), array( 'status' => 500 ) );
}
$zip->addFromString( 'data/stubli.json', (string) wp_json_encode( $data, JSON_PRETTY_PRINT ) );
foreach ( self::pages( $data ) as $name => $html ) {
$zip->addFromString( $name, $html );
}
$zip->addFromString( 'style.css', self::css() );
$zip->addFromString( 'README.txt', self::readme( $data ) );
$zip->close();
$job = array(
'file' => $file,
'queue' => $queue,
'total' => count( $queue ),
'created' => time(),
);
update_option( self::OPTION, $job, false );
return self::status();
}
/**
* Add the next media files.
*
* @return array|\WP_Error
*/
public static function step() {
$j = self::job();
if ( ! $j || ! file_exists( $j['file'] ) ) {
return new \WP_Error( 'stubli_archive_gone', __( 'The archive was removed. Start again.', 'stubli' ), array( 'status' => 404 ) );
}
if ( ! $j['queue'] ) {
return self::status();
}
$zip = new \ZipArchive();
if ( true !== $zip->open( $j['file'] ) ) {
return new \WP_Error( 'stubli_archive_write', __( 'The archive couldn\'t be opened. Start again.', 'stubli' ), array( 'status' => 500 ) );
}
$n = 0;
$bytes = 0;
while ( $j['queue'] && $n < self::PER_STEP && $bytes < self::MAX_STEP ) {
list( $path, $name ) = array_shift( $j['queue'] );
if ( file_exists( $path ) ) {
$zip->addFile( $path, $name );
// Photos and videos are already compressed: store them as they are.
if ( method_exists( $zip, 'setCompressionName' ) ) {
$zip->setCompressionName( $name, \ZipArchive::CM_STORE );
}
$bytes += (int) filesize( $path );
}
++$n;
}
if ( ! $zip->close() ) {
return new \WP_Error( 'stubli_archive_write', __( 'The archive couldn\'t be written. There may not be enough space on this host.', 'stubli' ), array( 'status' => 500 ) );
}
update_option( self::OPTION, $j, false );
return self::status();
}
/**
* Send the finished zip to the owner.
*/
public static function download() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Only the site owner can download this.', 'stubli' ), '', array( 'response' => 403 ) );
}
check_admin_referer( 'stubli_archive_download' );
$j = self::job();
if ( ! $j || $j['queue'] || ! file_exists( $j['file'] ) ) {
wp_die( esc_html__( 'The archive isn\'t ready, or it was removed after a day. Make a new one in the app: You → Settings and more → Yours to keep.', 'stubli' ), '', array( 'response' => 404 ) );
}
$name = preg_replace( '/-[A-Za-z0-9]{20}\.zip$/', '.zip', wp_basename( $j['file'] ) );
nocache_headers();
header( 'Content-Type: application/zip' );
header( 'Content-Disposition: attachment; filename="' . $name . '"' );
header( 'Content-Length: ' . filesize( $j['file'] ) );
while ( ob_get_level() ) {
ob_end_clean();
}
readfile( $j['file'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- sends the owner's own zip as a download.
exit;
}
/**
* Delete the archive.
*/
public static function remove() {
$j = self::job();
if ( $j && ! empty( $j['file'] ) && file_exists( $j['file'] ) ) {
wp_delete_file( $j['file'] );
}
delete_option( self::OPTION );
}
/**
* Remove archives older than a day.
*/
public static function clean_up() {
$j = self::job();
if ( $j && time() - (int) $j['created'] > self::KEEP ) {
self::remove();
}
}
/* ── Offline pages ── */
/**
* The pages inside the zip.
*
* @param array $d Data.
* @return array name => html
*/
private static function pages( array $d ) {
$e = static fn( $s ) => esc_html( (string) $s );
$photos = array_values( array_filter( $d['posts'], static fn( $p ) => 'photo' === $p['type'] ) );
$jour = array_values( array_filter( $d['posts'], static fn( $p ) => 'journal' === $p['type'] ) );
$photos = array_reverse( $photos );
$jour = array_reverse( $jour );
$day = static fn( $iso ) => wp_date( 'j F Y', (int) strtotime( $iso ) );
$nav = static function ( $on ) use ( $e ) {
$items = array(
'index.html' => __( 'Photos', 'stubli' ),
'journal.html' => __( 'Journal', 'stubli' ),
'guestbook.html' => __( 'Guest book', 'stubli' ),
'people.html' => __( 'People', 'stubli' ),
);
$out = '';
};
$comments = static function ( array $cs ) use ( $e, $day ) {
if ( ! $cs ) {
return '';
}
$out = '
';
foreach ( $cs as $c ) {
$out .= '- ' . ( $c['url'] ? '' . $e( $c['name'] ) . '' : $e( $c['name'] ) ) . ' ' . $e( $c['text'] ) . ' ' . $e( $day( $c['date'] ) ) . '
';
}
return $out . '
';
};
$media = static function ( $m ) use ( $e ) {
if ( empty( $m['file'] ) ) {
return '';
}
if ( ! empty( $m['video']['file'] ) ) {
return '';
}
return '
';
};
$p = $d['profile'];
$head = '' . ( ! empty( $p['avatar']['file'] ) ? '
 . ')
' : '' ) . '
' . $e( $p['name'] ) . '
' . ( $p['pronouns'] ? '
' . $e( $p['pronouns'] ) . '
' : '' ) . ( $p['bio'] ? '
' . $e( $p['bio'] ) . '
' : '' ) . '
' . $e( sprintf( /* translators: 1: site address, 2: date */ __( 'Archive of %1$s, made on %2$s', 'stubli' ), $d['site'], $day( $d['exported'] ) ) ) . '
';
// Photos: a grid, then every post with its photos, caption and comments.
$grid = '';
$list = '';
foreach ( $photos as $post ) {
$first = $post['photos'][0] ?? null;
if ( $first && $first['file'] ) {
$grid .= '
' . ( count( $post['photos'] ) > 1 ? '' . count( $post['photos'] ) . '' : '' ) . ( ! empty( $first['video'] ) ? '▶' : '' ) . '';
}
$list .= '
' . $e( $day( $post['date'] ) ) . ( $post['collection'] ? ' · ' . $e( $post['collection'] ) : '' ) . ( $post['note'] ? ' · ' . $e( sprintf( /* translators: %s: content note */ __( 'Content note: %s', 'stubli' ), $post['note'] ) ) : '' ) . ( 'friends' === ( $post['audience'] ?? '' ) ? ' · ' . $e( __( 'Friends only', 'stubli' ) ) : ( 'private' === $post['status'] ? ' · ' . $e( __( 'Only me', 'stubli' ) ) : '' ) ) . '
';
foreach ( $post['photos'] as $ph ) {
$list .= '' . $media( $ph ) . ( $ph['alt'] ? '' . $e( $ph['alt'] ) . '' : '' ) . '';
}
$list .= ( '' !== $post['caption'] ? '' . nl2br( $e( $post['caption'] ) ) . '
' : '' ) . $comments( $post['comments'] ) . '' . $e( __( 'Online at the time', 'stubli' ) ) . '
';
}
$grid .= '
';
// Journal: text with its photos pointing at the files in the zip.
$jhtml = '';
foreach ( $jour as $post ) {
$html = wp_kses_post( $post['html'] );
foreach ( $post['photos'] as $ph ) {
if ( $ph['file'] ) {
$html = Bundle::replace_img( $html, $ph, $media( $ph ) );
}
}
$jhtml .= '' . $e( $day( $post['date'] ) ) . ( 'private' === $post['status'] ? ' · ' . $e( __( 'Only me', 'stubli' ) ) : '' ) . '
' . $html . '
' . $comments( $post['comments'] ) . '';
}
$gb = $comments( $d['guestbook'] );
$pp = '';
$wrap = static function ( $file, $title, $body ) use ( $nav, $head, $e, $p ) {
// A standalone page in the zip, from the template in assets/archive (it links the zip's style.css).
return strtr(
self::template( 'page.html' ),
array(
'{{lang}}' => esc_attr( get_bloginfo( 'language' ) ),
'{{title}}' => $e( $title . ' · ' . $p['name'] ),
'{{content}}' => $head . $nav( $file ) . $body,
)
);
};
return array(
'index.html' => $wrap( 'index.html', __( 'Photos', 'stubli' ), ( $photos ? $grid . $list : '' . $e( __( 'No photos yet.', 'stubli' ) ) . '
' ) ),
'journal.html' => $wrap( 'journal.html', __( 'Journal', 'stubli' ), $jhtml ? $jhtml : '' . $e( __( 'No journal entries.', 'stubli' ) ) . '
' ),
'guestbook.html' => $wrap( 'guestbook.html', __( 'Guest book', 'stubli' ), $gb ? $gb : '' . $e( __( 'No guest book entries.', 'stubli' ) ) . '
' ),
'people.html' => $wrap( 'people.html', __( 'People', 'stubli' ), $d['following'] ? '' . $e( __( 'People you follow', 'stubli' ) ) . '
' . $pp : '' . $e( __( 'You weren\'t following anyone.', 'stubli' ) ) . '
' ),
);
}
/**
* Styles for the offline pages: linen, ink and one green.
*
* @return string
*/
private static function css() {
return self::template( 'style.css' );
}
/**
* A file from assets/archive: the offline pages' template and styles.
*
* @param string $name File name.
* @return string
*/
private static function template( $name ) {
return (string) file_get_contents( STUBLI_PLUGIN_DIR . 'assets/archive/' . $name ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- the plugin's own template file.
}
/**
* README inside the zip.
*
* @param array $d Data.
* @return string
*/
private static function readme( array $d ) {
$n = count( array_filter( $d['posts'], static fn( $p ) => 'photo' === $p['type'] ) );
$j = count( $d['posts'] ) - $n;
return sprintf(
/* translators: 1: name, 2: site, 3: date, 4: photo posts, 5: journal entries */
__( "Your Stubli, yours to keep\n\nAn archive of %1\$s (%2\$s), made on %3\$s.\n\nindex.html Your photos, captions and comments. Open it in any browser; no internet needed.\njournal.html Your journal entries, including the ones only you could see.\nguestbook.html Your guest book.\npeople.html The people you followed.\nstyle.css How these pages look.\nmedia/ Your original photos and videos, by month, with location already removed.\ndata/stubli.json Everything above as data, for other tools.\n\nInside: %4\$d photo posts and %5\$d journal entries.\n\nTo move to another WordPress site instead, use \"Move to another site\" in the Stubli app.\n", 'stubli' ),
$d['profile']['name'],
$d['site'],
wp_date( 'j F Y', (int) strtotime( $d['exported'] ) ),
$n,
$j
);
}
/**
* REST routes (owner only).
*/
public static function routes() {
$owner = array( Rest::class, 'is_owner' );
$wrap = static fn( $res ) => is_wp_error( $res ) ? $res : rest_ensure_response( $res );
Rest::register(
'/archive',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => static fn() => rest_ensure_response( self::status() ),
'permission_callback' => $owner,
),
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => static fn() => $wrap( self::start() ),
'permission_callback' => $owner,
),
array(
'methods' => \WP_REST_Server::DELETABLE,
'callback' => static function () {
self::remove();
return rest_ensure_response( self::status() );
},
'permission_callback' => $owner,
),
)
);
Rest::register(
'/archive/step',
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => static fn() => $wrap( self::step() ),
'permission_callback' => $owner,
)
);
}
}