settings = is_array( $settings ) ? $settings : Destinations::get( 'drive' ); } /** * Endpoints (filterable so tests can use a local stand-in). * * @return array{auth: string, token: string, revoke: string, api: string} */ public static function endpoints() { /** * Filters the Google endpoints (for tests). * * @param array $endpoints auth, token, revoke, api. */ return (array) apply_filters( 'accessnow_replica_drive_endpoints', array( 'auth' => 'https://accounts.google.com/o/oauth2/v2/auth', 'token' => 'https://oauth2.googleapis.com/token', 'revoke' => 'https://oauth2.googleapis.com/revoke', 'api' => 'https://www.googleapis.com', ) ); } /** * Address Google sends the browser back to. Sites that used the old WP Replica plugin already * registered its address with their Google app, so that one is kept for them. * * @param array|null $settings Settings. */ public static function redirect_uri( $settings = null ) { $settings = is_array( $settings ) ? $settings : Destinations::get( 'drive' ); $page = 'legacy' === ( $settings['redirect'] ?? 'own' ) ? 'wp-replica-drive' : 'site-replica-drive'; return admin_url( 'admin.php?page=' . $page ); } /** * Short ID. */ public function id() { return 'drive'; } /** * Name shown to people. */ public function label() { return __( 'Google Drive', 'accessnow-site-replica' ); } /** * Set up and connected. */ public function ready() { return $this->has_app() && self::connected(); } /** * Top folder name. */ public function root_name() { return (string) ( $this->settings['folder'] ?? 'Site Replica' ); } /** * My Drive. */ public function top() { return 'root'; } /** * Whether a Google app (client ID and secret) is set. */ public function has_app() { return '' !== (string) ( $this->settings['client_id'] ?? '' ) && '' !== (string) ( $this->settings['client_secret'] ?? '' ); } /** * Whether the site is connected (has a refresh token). */ public static function connected() { $token = Secrets::get_option( self::TOKEN_OPTION ); return ! empty( $token['refresh_token'] ); } /* ------------------------------------------------------------------------- * OAuth * ---------------------------------------------------------------------- */ /** * Consent screen address. * * @param string $state Random state (checked on return). * @param string $challenge PKCE code challenge (S256). */ public function auth_url( $state, $challenge ) { return add_query_arg( array( 'client_id' => rawurlencode( (string) $this->settings['client_id'] ), 'redirect_uri' => rawurlencode( self::redirect_uri( $this->settings ) ), 'response_type' => 'code', 'scope' => rawurlencode( self::SCOPE ), 'access_type' => 'offline', 'prompt' => 'consent', 'include_granted_scopes' => 'true', 'state' => rawurlencode( $state ), 'code_challenge' => rawurlencode( $challenge ), 'code_challenge_method' => 'S256', ), self::endpoints()['auth'] ); } /** * Exchanges the authorization code for tokens and saves them. * * @param string $code Code. * @param string $verifier PKCE code verifier. * @throws Remote_Exception When Google refuses. */ public function exchange_code( $code, $verifier ) { $token = $this->token_request( array( 'code' => $code, 'client_id' => $this->settings['client_id'], 'client_secret' => $this->settings['client_secret'], 'redirect_uri' => self::redirect_uri( $this->settings ), 'grant_type' => 'authorization_code', 'code_verifier' => $verifier, ) ); if ( empty( $token['refresh_token'] ) ) { throw new Remote_Exception( 'drive_no_refresh', __( 'Google did not grant lasting access. Remove Site Replica under “Third-party apps with account access” in your Google account, then connect again.', 'accessnow-site-replica' ) ); } self::save_token( $token ); } /** * Saves a refresh token taken over from the old WP Replica plugin. * * @param string $refresh_token Refresh token. */ public static function adopt_refresh_token( $refresh_token ) { self::save_token( array( 'refresh_token' => (string) $refresh_token, 'access_token' => '', 'expires_in' => 0, ) ); } /** * A valid access token (refreshed when it is about to expire). * * @param bool $force Refresh even if the current one looks valid. * @return string * @throws Remote_Exception When not connected or Google refuses. */ public function access_token( $force = false ) { $token = Secrets::get_option( self::TOKEN_OPTION ); if ( empty( $token['refresh_token'] ) ) { throw new Remote_Exception( 'drive_not_connected', __( 'Google Drive is not connected. Connect it under Site Replica → Cloud Backups.', 'accessnow-site-replica' ) ); } if ( ! $force && ! empty( $token['access_token'] ) && (int) ( $token['expires_at'] ?? 0 ) > time() + 60 ) { return (string) $token['access_token']; } $fresh = $this->token_request( array( 'refresh_token' => $token['refresh_token'], 'client_id' => $this->settings['client_id'], 'client_secret' => $this->settings['client_secret'], 'grant_type' => 'refresh_token', ) ); self::save_token( array_merge( $token, $fresh ) ); return (string) $fresh['access_token']; } /** * Forgets the tokens (and asks Google to revoke them). */ public static function disconnect() { $token = Secrets::get_option( self::TOKEN_OPTION ); if ( ! empty( $token['refresh_token'] ) ) { wp_remote_post( self::endpoints()['revoke'], array( 'timeout' => 10, 'body' => array( 'token' => $token['refresh_token'] ), ) ); } delete_option( self::TOKEN_OPTION ); } /** * Saves tokens. * * @param array $token Token response. */ private static function save_token( array $token ) { Secrets::update_option( self::TOKEN_OPTION, array( 'access_token' => (string) ( $token['access_token'] ?? '' ), 'refresh_token' => (string) ( $token['refresh_token'] ?? '' ), 'expires_at' => time() + (int) ( $token['expires_in'] ?? 0 ), 'saved' => time(), ) ); } /** * Calls the token endpoint. * * @param array $body Form fields. * @return array Token response. * @throws Remote_Exception When Google refuses or can't be reached. */ private function token_request( array $body ) { $response = wp_remote_post( self::endpoints()['token'], array( 'timeout' => 30, 'body' => $body, ) ); if ( is_wp_error( $response ) ) { throw new Remote_Exception( 'drive_network', sprintf( /* translators: %s: error. */ __( 'Could not reach Google: %s', 'accessnow-site-replica' ), $response->get_error_message() ), true, 30 ); } $code = (int) wp_remote_retrieve_response_code( $response ); $json = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 === $code && ! empty( $json['access_token'] ) ) { return $json; } if ( $code >= 500 || 429 === $code ) { throw new Remote_Exception( 'drive_busy', __( 'Google is busy. Trying again shortly.', 'accessnow-site-replica' ), true, 60 ); } $error = (string) ( $json['error'] ?? '' ); if ( 'invalid_grant' === $error ) { throw new Remote_Exception( 'drive_revoked', __( 'Google no longer accepts this site’s access (it was revoked, the password changed, or the Google app is in “Testing” mode, where access ends after 7 days). Connect Google Drive again.', 'accessnow-site-replica' ) ); } throw new Remote_Exception( 'drive_auth', sprintf( /* translators: %s: Google's message. */ __( 'Google refused: %s', 'accessnow-site-replica' ), (string) ( $json['error_description'] ?? $error ) ) ); } /* ------------------------------------------------------------------------- * Drive API * ---------------------------------------------------------------------- */ /** * Account e-mail and storage. * * @return array{email: string, name: string, limit: int|null, usage: int} */ public function about() { $json = $this->api( 'GET', '/drive/v3/about?fields=' . rawurlencode( 'user(emailAddress,displayName),storageQuota(limit,usage)' ) ); return array( 'email' => (string) ( $json['user']['emailAddress'] ?? '' ), 'name' => (string) ( $json['user']['displayName'] ?? '' ), 'limit' => isset( $json['storageQuota']['limit'] ) ? (int) $json['storageQuota']['limit'] : null, 'usage' => (int) ( $json['storageQuota']['usage'] ?? 0 ), ); } /** * Folder ID for a path of folder names under My Drive (created as needed). * * @param string[] $names Folder names, outermost first. * @return string */ public function folder( array $names ) { $parent = 'root'; foreach ( $names as $name ) { $found = $this->find( $parent, (string) $name, true ); $parent = $found ? $found['id'] : $this->create_folder( $parent, (string) $name ); } return $parent; } /** * Finds a child by name. * * @param string $parent Parent folder ID. * @param string $name Name. * @param bool $folder Only folders. * @return array|null File (id, name, size, md5Checksum, createdTime, mimeType). */ public function find( $parent, $name, $folder = false ) { $q = sprintf( "'%s' in parents and name = '%s' and trashed = false", self::quote( $parent ), self::quote( $name ) ); if ( $folder ) { $q .= " and mimeType = '" . self::FOLDER . "'"; } $json = $this->api( 'GET', '/drive/v3/files?' . http_build_query( array( 'q' => $q, 'fields' => 'files(id,name,size,md5Checksum,createdTime,mimeType)', 'pageSize' => 10, 'spaces' => 'drive' ), '', '&', PHP_QUERY_RFC3986 ) ); return ! empty( $json['files'][0] ) ? $json['files'][0] : null; } /** * Lists the children of a folder. * * @param string $parent Folder ID. * @param bool $folders_only Only folders. * @return array[] Files (id, name, size, md5Checksum, createdTime, mimeType). */ public function children( $parent, $folders_only = false ) { $q = sprintf( "'%s' in parents and trashed = false", self::quote( $parent ) ); if ( $folders_only ) { $q .= " and mimeType = '" . self::FOLDER . "'"; } $out = array(); $page = ''; do { $args = array( 'q' => $q, 'fields' => 'nextPageToken,files(id,name,size,md5Checksum,createdTime,mimeType)', 'pageSize' => 1000, 'spaces' => 'drive', 'orderBy' => 'name', ); if ( '' !== $page ) { $args['pageToken'] = $page; } $json = $this->api( 'GET', '/drive/v3/files?' . http_build_query( $args, '', '&', PHP_QUERY_RFC3986 ) ); $out = array_merge( $out, (array) ( $json['files'] ?? array() ) ); $page = (string) ( $json['nextPageToken'] ?? '' ); } while ( '' !== $page ); return $out; } /** * Creates a folder. * * @param string $parent Parent folder ID. * @param string $name Name. * @return string Folder ID. */ public function create_folder( $parent, $name ) { $json = $this->api( 'POST', '/drive/v3/files?fields=id', (string) wp_json_encode( array( 'name' => $name, 'mimeType' => self::FOLDER, 'parents' => array( $parent ), ) ), array( 'Content-Type' => 'application/json; charset=UTF-8' ) ); return (string) $json['id']; } /** * Uploads a small file in one request. * * @param string $parent Folder ID. * @param string $name Name. * @param string $data Contents. * @param string $mime MIME type. * @return array File (id, name, size, md5Checksum). */ public function upload_small( $parent, $name, $data, $mime = 'application/octet-stream' ) { $boundary = 'srb' . bin2hex( random_bytes( 12 ) ); $body = '--' . $boundary . "\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n" . wp_json_encode( array( 'name' => $name, 'parents' => array( $parent ), ) ) . "\r\n--" . $boundary . "\r\nContent-Type: " . $mime . "\r\n\r\n" . $data . "\r\n--" . $boundary . "--\r\n"; $file = $this->api( 'POST', '/upload/drive/v3/files?uploadType=multipart&fields=id,name,size,md5Checksum', $body, array( 'Content-Type' => 'multipart/related; boundary=' . $boundary ), 120 ); self::check_md5( $file, md5( $data ) ); return $file; } /** * Starts a resumable upload. * * @param string $parent Folder ID. * @param string $name Name. * @param int $size Total size. * @param string $mime MIME type. * @return string Session address. * @throws Remote_Exception When Drive doesn't start the session. */ public function upload_start( $parent, $name, $size, $mime = 'application/zip' ) { $response = $this->request( 'POST', '/upload/drive/v3/files?uploadType=resumable&fields=id,name,size,md5Checksum', (string) wp_json_encode( array( 'name' => $name, 'parents' => array( $parent ), ) ), array( 'Content-Type' => 'application/json; charset=UTF-8', 'X-Upload-Content-Type' => $mime, 'X-Upload-Content-Length' => (string) (int) $size, ) ); $location = (string) wp_remote_retrieve_header( $response, 'location' ); if ( '' === $location ) { throw new Remote_Exception( 'drive_upload', __( 'Google Drive did not start the upload.', 'accessnow-site-replica' ), true, 30 ); } return $location; } /** * Sends one chunk of a resumable upload. * * @param string $session Session address. * @param string $data Bytes (a multiple of 256 KiB, except the last chunk). * @param int $offset Where the chunk starts. * @param int $total Total size. * @return array{next: int, file: array|null} Where to continue, or the finished file. * @throws Remote_Exception On failure (retryable when it makes sense). */ public function upload_chunk( $session, $data, $offset, $total ) { $len = strlen( $data ); $range = $len > 0 ? 'bytes ' . $offset . '-' . ( $offset + $len - 1 ) . '/' . $total : 'bytes */' . $total; $response = $this->session_request( $session, $data, $range ); return self::session_result( $response, $total ) + array( 'session' => $session ); } /** * Asks where an interrupted upload stands. * * @param string $session Session address. * @param int $total Total size. * @return array{next: int, file: array|null} */ public function upload_status( $session, $total ) { return self::session_result( $this->session_request( $session, '', 'bytes */' . $total ), $total ) + array( 'session' => $session ); } /** * Downloads a file or a range of it. * * @param string $id File ID. * @param int $offset Start. * @param int|null $length Length (null: all). * @return string */ public function download( $id, $offset = 0, $length = null ) { $headers = array(); if ( $offset > 0 || null !== $length ) { $headers['Range'] = 'bytes=' . (int) $offset . '-' . ( null === $length ? '' : (int) $offset + (int) $length - 1 ); } $response = $this->request( 'GET', '/drive/v3/files/' . rawurlencode( $id ) . '?alt=media', null, $headers, 120 ); return (string) wp_remote_retrieve_body( $response ); } /** * Deletes a file or a folder (with everything in it) permanently. * * @param string $id File or folder ID. */ public function delete( $id ) { $this->request( 'DELETE', '/drive/v3/files/' . rawurlencode( $id ), null, array(), 60, array( 404 ) ); } /* ------------------------------------------------------------------------- * HTTP * ---------------------------------------------------------------------- */ /** * JSON API call. * * @param string $method Method. * @param string $path Path and query. * @param string|null $body Body. * @param array $headers Headers. * @param int $timeout Timeout. * @return array */ private function api( $method, $path, $body = null, array $headers = array(), $timeout = 30 ) { $response = $this->request( $method, $path, $body, $headers, $timeout ); $json = json_decode( (string) wp_remote_retrieve_body( $response ), true ); return is_array( $json ) ? $json : array(); } /** * Authorized request, refreshing the token once on 401. * * @param string $method Method. * @param string $path Path (on the API host) or a full address. * @param string|null $body Body. * @param array $headers Headers. * @param int $timeout Timeout. * @param int[] $ok Extra status codes that count as success. * @return array WordPress HTTP response. * @throws Remote_Exception On failure. */ private function request( $method, $path, $body = null, array $headers = array(), $timeout = 30, array $ok = array() ) { $url = 0 === strpos( $path, 'http' ) ? $path : rtrim( self::endpoints()['api'], '/' ) . $path; for ( $try = 0; $try < 2; $try++ ) { $args = array( 'method' => $method, 'timeout' => $timeout, 'redirection' => 0, // Drive answers 308 to unfinished uploads: never a redirect to follow. 'headers' => array_merge( array( 'Authorization' => 'Bearer ' . $this->access_token( $try > 0 ) ), $headers ), ); if ( null !== $body ) { $args['body'] = $body; } if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( max( 60, $timeout + 30 ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- Network waits count on some hosts. } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { throw new Remote_Exception( 'drive_network', sprintf( /* translators: %s: error. */ __( 'Could not reach Google Drive: %s', 'accessnow-site-replica' ), $response->get_error_message() ), true, 20 ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 401 === $code && 0 === $try ) { continue; } if ( ( $code >= 200 && $code < 300 ) || in_array( $code, $ok, true ) ) { return $response; } throw self::error( $response ); } throw new Remote_Exception( 'drive_auth', __( 'Google Drive refused the access token. Connect Google Drive again.', 'accessnow-site-replica' ) ); } /** * PUT to an upload session (the session address carries its own authorization). * * @param string $session Session address. * @param string $data Bytes. * @param string $range Content-Range value. * @return array WordPress HTTP response. * @throws Remote_Exception On a network error. */ private function session_request( $session, $data, $range ) { if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged } $response = wp_remote_request( $session, array( 'method' => 'PUT', 'timeout' => 240, 'redirection' => 0, // 308 Resume Incomplete is not a redirect. 'headers' => array( 'Content-Range' => $range, 'Content-Length' => (string) strlen( $data ), ), 'body' => $data, ) ); if ( is_wp_error( $response ) ) { throw new Remote_Exception( 'drive_network', sprintf( /* translators: %s: error. */ __( 'The upload to Google Drive was interrupted: %s', 'accessnow-site-replica' ), $response->get_error_message() ), true, 20 ); } return $response; } /** * Interprets an upload session response. * * @param array $response WordPress HTTP response. * @param int $total Total size. * @return array{next: int, file: array|null} * @throws Remote_Exception When the session is gone or failed. */ private static function session_result( $response, $total ) { $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 === $code || 201 === $code ) { $file = json_decode( (string) wp_remote_retrieve_body( $response ), true ); return array( 'next' => (int) $total, 'file' => is_array( $file ) ? $file : array(), ); } if ( 308 === $code ) { $range = (string) wp_remote_retrieve_header( $response, 'range' ); return array( 'next' => preg_match( '/bytes=0-(\d+)/', $range, $m ) ? (int) $m[1] + 1 : 0, 'file' => null, ); } if ( 404 === $code || 410 === $code ) { throw new Remote_Exception( 'drive_session_gone', __( 'Google Drive forgot an unfinished upload. That part will be uploaded again.', 'accessnow-site-replica' ), true, 3 ); } throw self::error( $response ); } /** * Exception for an error response. * * @param array $response WordPress HTTP response. * @return Remote_Exception */ private static function error( $response ) { $code = (int) wp_remote_retrieve_response_code( $response ); $json = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $reason = (string) ( $json['error']['errors'][0]['reason'] ?? ( $json['error']['status'] ?? '' ) ); $message = (string) ( $json['error']['message'] ?? wp_remote_retrieve_response_message( $response ) ); if ( 429 === $code || $code >= 500 || in_array( $reason, array( 'rateLimitExceeded', 'userRateLimitExceeded', 'backendError' ), true ) ) { return new Remote_Exception( 'drive_busy', __( 'Google Drive is busy. Trying again shortly.', 'accessnow-site-replica' ), true, 30 ); } if ( 'storageQuotaExceeded' === $reason || false !== stripos( $message, 'quota' ) ) { return new Remote_Exception( 'drive_full', __( 'Your Google Drive is full. Delete older backups or files, or keep fewer backups.', 'accessnow-site-replica' ) ); } /* translators: 1: HTTP status, 2: Google's message. */ return new Remote_Exception( 'drive_http_' . $code, sprintf( __( 'Google Drive answered %1$d: %2$s', 'accessnow-site-replica' ), $code, $message ) ); } /** * Escapes a value for a Drive query string literal. * * @param string $value Value. */ private static function quote( $value ) { return str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), (string) $value ); } }