'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 = ''; }; $media = static function ( $m ) use ( $e ) { if ( empty( $m['file'] ) ) { return ''; } if ( ! empty( $m['video']['file'] ) ) { return ''; } return '' . esc_attr( $m['alt'] ) . ''; }; $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 .= '' . esc_attr( $first['alt'] ) . '' . ( 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, ) ); } }