folder . '/' . $name; $commit = ['path' => $path, 'mode' => 'overwrite', 'autorename' => false, 'mute' => true]; $metadata = $size <= $this->singleLimit ? $this->content('/2/files/upload', $commit, (string) file_get_contents($localPath)) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local file, at most one chunk. : $this->session($localPath, $size, $commit); $local = ContentHash::ofFile($localPath); $remote = is_array($metadata) ? (string) ($metadata['content_hash'] ?? '') : ''; if (!hash_equals($local, $remote)) { throw new DestinationError(sprintf( /* translators: %s: file name */ __('Dropbox\'s copy of %s does not match the file on this server, so it does not count as a backup. The local copy is kept and sent again on the next run.', 'freshet-backup'), $name )); } } public function prune(int $keep, string $pattern): array { $names = []; $page = $this->api('/2/files/list_folder', ['path' => $this->folder, 'recursive' => false], true); while (true) { foreach ((array) ($page['entries'] ?? []) as $entry) { if (($entry['.tag'] ?? '') === 'file' && preg_match($pattern, (string) ($entry['name'] ?? ''))) { $names[] = (string) $entry['name']; } } if (empty($page['has_more']) || empty($page['cursor'])) { break; } $page = $this->api('/2/files/list_folder/continue', ['cursor' => (string) $page['cursor']]); } sort($names, SORT_STRING); $gone = array_slice($names, 0, max(0, count($names) - $keep)); foreach ($gone as $old) { $this->api('/2/files/delete_v2', ['path' => $this->folder . '/' . $old]); } return $gone; } /** * Withdraw this site's access at Dropbox. Revoking the access token revokes * the refresh token it came from, so the token in wp-config.php is dead too. */ public function revoke(): void { $this->call(self::API . '/2/auth/token/revoke', ['Content-Type' => 'application/json'], 'null', 30); } /** @param array $commit */ private function session(string $localPath, int $size, array $commit): mixed { $handle = fopen($localPath, 'rb'); if ($handle === false) { throw new DestinationError(__('The backup file could not be opened to send it to Dropbox. It stays on this server and is sent again on the next run.', 'freshet-backup')); } try { $first = (string) fread($handle, $this->chunk); $started = $this->content('/2/files/upload_session/start', ['close' => false], $first); $id = is_array($started) ? (string) ($started['session_id'] ?? '') : ''; if ($id === '') { throw new DestinationError(__('Dropbox did not start the upload, so the backup was not sent. It stays on this server and is sent again on the next run.', 'freshet-backup')); } $offset = strlen($first); while ($size - $offset > $this->chunk) { $piece = (string) fread($handle, $this->chunk); if ($piece === '') { throw new DestinationError(__('The backup file ended sooner than expected while it was being sent to Dropbox. It is sent again on the next run.', 'freshet-backup')); } $this->content('/2/files/upload_session/append_v2', ['cursor' => ['session_id' => $id, 'offset' => $offset], 'close' => false], $piece); $offset += strlen($piece); } $last = (string) stream_get_contents($handle); return $this->content('/2/files/upload_session/finish', [ 'cursor' => ['session_id' => $id, 'offset' => $offset], 'commit' => $commit, ], $last); } finally { fclose($handle); } } /** A call to content.dropboxapi.com: arguments in the Dropbox-API-Arg header, the bytes as the body. */ private function content(string $endpoint, array $arg, string $bytes): mixed { return $this->call(self::CONTENT . $endpoint, [ 'Content-Type' => 'application/octet-stream', 'Dropbox-API-Arg' => (string) wp_json_encode($arg), ], $bytes, 300); } /** A call to api.dropboxapi.com: JSON in, JSON out. A missing folder lists as empty when $missingOk. */ private function api(string $endpoint, array $arg, bool $missingOk = false): mixed { try { return $this->call(self::API . $endpoint, ['Content-Type' => 'application/json'], (string) wp_json_encode($arg), 60); } catch (DestinationError $e) { if ($missingOk && str_contains($e->getMessage(), 'not_found')) { return ['entries' => [], 'has_more' => false]; } throw $e; } } /** @param array $headers */ private function call(string $url, array $headers, string $body, int $timeout): mixed { for ($attempt = 0; $attempt < 2; $attempt++) { $response = $this->http->send('POST', $url, ['Authorization' => 'Bearer ' . $this->token()] + $headers, $body, $timeout); if ($response['status'] === 401 && $attempt === 0) { // Access tokens last a few hours; a long run can outlive one. $this->token = null; continue; } break; } if ($response['status'] === 200) { return json_decode($response['body'], true); } throw new DestinationError($this->explain($response, $url)); } private function token(): string { if ($this->token !== null) { return $this->token; } $response = $this->http->send('POST', self::API . '/oauth2/token', [ 'Content-Type' => 'application/x-www-form-urlencoded', ], http_build_query(array_filter([ 'grant_type' => 'refresh_token', 'refresh_token' => $this->refreshToken, 'client_id' => $this->appKey, // A token from the screen's Connect button was issued with PKCE and refreshes without a secret. 'client_secret' => $this->appSecret, ], 'strlen')), 30); $data = $response['status'] === 200 ? json_decode($response['body'], true) : null; if (!is_array($data) || empty($data['access_token'])) { $reason = is_array($error = json_decode($response['body'], true)) ? (string) ($error['error'] ?? '') : ''; $reason = $reason !== '' ? $reason : ($response['error'] ?? 'HTTP ' . $response['status']); throw new DestinationError($this->appSecret === '' ? sprintf( /* translators: %s: Dropbox's error code, or the connection error */ __('Dropbox refused this site\'s connection (%s), so nothing was sent; the backup stays on this server. If Freshet Backup was removed from your Dropbox account, disconnect and connect again under Settings → Freshet Backup.', 'freshet-backup'), $reason ) : sprintf( /* translators: %s: Dropbox's error code, or the connection error */ __('Dropbox refused this site\'s connection (%s), so nothing was sent; the backup stays on this server. Check FRESHET_BACKUP_DROPBOX_APP_KEY, FRESHET_BACKUP_DROPBOX_APP_SECRET and FRESHET_BACKUP_DROPBOX_REFRESH_TOKEN in wp-config.php. A refresh token stops working if the app\'s access was removed.', 'freshet-backup'), $reason )); } return $this->token = (string) $data['access_token']; } /** @param array{status: int, body: string, error: ?string} $response */ private function explain(array $response, string $url): string { if ($response['status'] === 0) { return sprintf( /* translators: %s: the connection error */ __('Dropbox could not be reached (%s). The backup stays on this server and is sent on the next run.', 'freshet-backup'), (string) $response['error'] ); } $data = json_decode($response['body'], true); $summary = is_array($data) ? (string) ($data['error_summary'] ?? '') : trim(substr(wp_strip_all_tags($response['body']), 0, 200)); if (str_contains($summary, 'insufficient_space')) { return __('Your Dropbox is full, so the backup could not be saved there. It stays on this server; free up space in Dropbox and it is sent on the next run.', 'freshet-backup'); } return sprintf( /* translators: 1: Dropbox endpoint, 2: HTTP status, 3: Dropbox's own error summary */ __('Dropbox turned down the request (%1$s, HTTP %2$d: %3$s). The backup stays on this server and is sent on the next run.', 'freshet-backup'), (string) wp_parse_url($url, PHP_URL_PATH), $response['status'], $summary !== '' ? $summary : __('no reason given', 'freshet-backup') ); } }