<?php
// @codingStandardsIgnoreFile

if (!defined('UPDRAFTPLUS_DIR')) die('No direct access allowed');

class UpdraftPlus_Backblaze_CurlClient {

	protected $accountId;
	protected $applicationKey;
	protected $credentials;

	protected $authToken;
	protected $apiUrl;
	protected $downloadUrl;
	
	// Whether to verify the server certificate or not
	protected $sslVerify = true;
	
	// If set to a string, then it indicates a path for a CA store to use
	protected $useCACerts = false;

	protected $singleBucketKeyId;

	/**
	 * Constructor
	 *
	 * @param String $accountId
	 * @param String $applicationKey
	 * @param String $singleBucketKeyId
	 * @param Array	 $options
	 */
	public function __construct($accountId, $applicationKey, $singleBucketKeyId = '', array $options = array()) {
		$this->accountId = $accountId;
		$this->applicationKey = $applicationKey;
		$this->singleBucketKeyId = $singleBucketKeyId;
		if (isset($options['ssl_verify'])) $this->sslVerify = $options['ssl_verify'];
		if (isset($options['ssl_ca_certs'])) $this->useCACerts = $options['ssl_ca_certs'];
		$this->authorizeAccount();
	}
	
	protected function request($method = 'GET', $uri = '', array $options = array(), $as_json = true) {
		$session = curl_init($uri);

		$headers = array();

		if (isset($options['auth'])) {
			$account_id = empty($this->singleBucketKeyId) ? $this->accountId : $this->singleBucketKeyId;
			$headers[] = 'Authorization: Basic ' . base64_encode($account_id . ':' . $this->applicationKey);
		}

		if (isset($options['headers'])) {
			foreach ($options['headers'] as $key => $header) {
				$headers[] = $key . ': ' . $header;
			}
		}
		
		if ($this->sslVerify) {
			curl_setopt($session, CURLOPT_SSL_VERIFYPEER, true);
			curl_setopt($session, CURLOPT_SSL_VERIFYHOST, 2);
		} else {
			curl_setopt($session, CURLOPT_SSL_VERIFYPEER, false);
			curl_setopt($session, CURLOPT_SSL_VERIFYHOST, 0);
		}
		
		if ($this->useCACerts) {
			curl_setopt($session, CURLOPT_CAINFO, $this->useCACerts);
		}
		
		if ('GET' == $method) {

			curl_setopt($session, CURLOPT_HTTPGET, true);

		} else {

			$data = array();

			if (isset($options['json'])) {

				$headers[] = "Accept: application/json";
				$data	  = json_encode($options['json']);

			}

			if (isset($options['body'])) {

				$data = $options['body'];

			}

			curl_setopt($session, CURLOPT_POSTFIELDS, $data);
			curl_setopt($session, CURLOPT_POST, true);
		}

		curl_setopt($session, CURLOPT_HTTPHEADER, $headers);
		curl_setopt($session, CURLOPT_RETURNTRANSFER, true);

		if (isset($options['sink'])) {
			$sink = fopen($options['sink'], 'w+');
			curl_setopt($session, CURLOPT_FILE, $sink);
			curl_setopt($session, CURLOPT_FOLLOWLOCATION, true);
		}

		if (isset($options['session'])) {
			return $session;
		}

		$response = curl_exec($session);
		
		if (0 != ($curl_error = curl_errno($session))) {
			throw new Exception("Curl error ($curl_error): ".curl_error($session), $curl_error);
		}

		$decode_response = json_decode($response, true);

		if (isset($decode_response['status']) && 200 !== $decode_response['status']) {

			throw new Exception($decode_response['message'], $decode_response['status']);

		}

		if (version_compare(PHP_VERSION, '8.0', '<')) {
			curl_close($session);
		} else {
			unset($session); // On PHP 8+, curl_close() is a no-op (deprecated in 8.5); unset the handle instead.
		}

		if (!empty($sink)) @fclose($sink);

		if ($as_json) return $decode_response;

		return $response;
	}

	public function uploadLargeStart($options) {
		// Request body parameters
		if ('/' === substr($options['FileName'], 0, 1)) {
			$options['FileName'] = ltrim($options['FileName'], '/');
		}

		if (!isset($options['BucketId']) && isset($options['BucketName'])) {
			$options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
		}

		if (!isset($options['FileContentType'])) {
			$options['FileContentType'] = 'b2/x-auto';
		}

		// Request start large file upload
		$response = $this->request('POST', $this->apiUrl . '/b2_start_large_file', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json'	=> array(
				'bucketId'	=> $options['BucketId'],
				'fileName'	=> $options['FileName'],
				'contentType' => $options['FileContentType'],
			),
		));

		/*
		 * fileId
		 * fileName
		 * accountId
		 * bucketId
		 * contentType
		 * fileInfo
		 * uploadTimestamp
		 */
		return $response;
	}

	public function uploadLargeUrl($options) {
		if (!isset($options['FileId'])) {
			throw new Exception('FileId required');
		}

		$response = $this->request('POST', $this->apiUrl . '/b2_get_upload_part_url', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json'	=> array(
				'fileId' => $options['FileId'],
			),
		));

		/*
		 * authorizationToken
		 * fileId
		 * uploadUrl
		 */
		return $response;
	}

	public function uploadLargePart($options) {

		if (!isset($options['AuthorizationToken'])) {
			throw new Exception('AuthorizationToken required');
		}

		if (!isset($options['FilePartNo'])) {
			throw new Exception('FilePartNo required');
		}

		if (!isset($options['UploadUrl'])) {
			throw new Exception('UploadUrl required');
		}

		if (!isset($options['Body'])) {
			throw new Exception('Body required');
		}

		if (is_resource($options['Body'])) {
			// We need to calculate the file's hash incrementally from the stream.
			$context = hash_init('sha1');
			hash_update_stream($context, $options['Body']);
			$hash = hash_final($context);

			// Similarly, we have to use fstat to get the size of the stream.
			$fstat = fstat($options['Body']);
			$size  = $fstat['size'];

			// Rewind the stream before passing it to the HTTP client.
			rewind($options['Body']);
		} else {
			// We've been given a simple string body, it's super simple to calculate the hash and size.
			$hash = sha1($options['Body']);
			$size = mb_strlen($options['Body'], '8bit');
		}

		$response = $this->request('POST', $options['UploadUrl'], array(
			'headers' => array(
				'Authorization'	 => $options['AuthorizationToken'],
				'X-Bz-Part-Number'  => $options['FilePartNo'],
				'Content-Length'	=> $size,
				'X-Bz-Content-Sha1' => $hash,
			),
			'body'	=> $options['Body'],
		));

		/*
		 * fileId
		 * partNumber
		 * contentLength
		 * contentSha1
		 */
		return $response;
	}

	public function uploadLargeFinish($options) {

		if (!isset($options['FileId'])) {
			throw new Exception('FileId required');
		}

		if (!isset($options['FilePartSha1Array'])) {
			throw new Exception('FilePartSha1Array required');
		}

		if (!is_array($options['FilePartSha1Array'])) {
			throw new Exception("FilePartSha1Array must be an array");

		}

		$response = $this->request('POST', $this->apiUrl . '/b2_finish_large_file', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json'	=> array(
				'fileId'		=> (string) $options['FileId'],
				'partSha1Array' => $options['FilePartSha1Array'],
			),
		));

		if (empty($response['contentLength'])) {
			throw new Exception('B2: uploadLargeFinish error: contentLength returned was empty ('.serialize($response).')');
		}

		return new UpdraftPlus_Backblaze_File(
			$response['fileId'],
			$response['fileName'],
			$response['contentSha1'],
			$response['contentLength'],
			$response['contentType'],
			$response['fileInfo']
		);
	}

	protected function authorizeAccount() {
		$response = $this->request("GET", 'https://api.backblazeb2.com/b2api/v1/b2_authorize_account', array(
			'auth' => array($this->accountId, $this->applicationKey),
		));

		$this->authToken   = $response['authorizationToken'];
		$this->apiUrl	  = $response['apiUrl'] . '/b2api/v1';
		$this->downloadUrl = $response['downloadUrl'];
	}

	public function listBuckets() {
		$buckets = array();

		$response = $this->request('POST', $this->apiUrl . '/b2_list_buckets', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json'	=> array(
				'accountId' => $this->accountId,
			),
		));

		if (!isset($response['buckets'])) throw new Exception('Failed to list buckets: '.serialize($response));
		
		foreach ($response['buckets'] as $bucket) {
			$is_file_lock_enabled = isset($bucket['fileLockConfiguration']['value']['isFileLockEnabled']) ? $bucket['fileLockConfiguration']['value']['isFileLockEnabled'] : false;
			$buckets[] = new UpdraftPlus_Backblaze_Bucket($bucket['bucketId'], $bucket['bucketName'], $bucket['bucketType'], $is_file_lock_enabled);
		}

		
		return $buckets;
	}

	protected function getBucketIdFromName($name) {
		$buckets = $this->listBuckets();
		$name = strtolower($name);
		
		foreach ($buckets as $bucket) {
			if ($bucket->getName() === $name) {
				return $bucket->getId();
			}
		}

		return null;
	}

	protected function getBucketNameFromId($id) {
		$buckets = $this->listBuckets();

		foreach ($buckets as $bucket) {
			if ($bucket->getId() === $id) {
				return $bucket->getName();
			}
		}

		return null;
	}

	protected function getFileIdFromBucketAndFileName($bucketName, $fileName) {
		$files = $this->listFiles(array(
			'BucketName' => $bucketName,
			'FileName'   => $fileName,
		));

		foreach ($files as $file) {
			if ($file->getName() === $fileName) {
				return $file->getId();
			}
		}

		return null;
	}

	public function listFiles($options) {
		// if FileName is set, we only attempt to retrieve information about that single file.
		$fileName = !empty($options['FileName']) ? $options['FileName'] : null;

		$nextFileName = null;
		$maxFileCount = 1000;
		$files		= array();

		if (!isset($options['BucketId']) && isset($options['BucketName'])) {
			$options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
		}

		if ($fileName) {
			$nextFileName = $fileName;
			$maxFileCount = 1;
		}

		$json = array(
			'bucketId'	  => $options['BucketId'],
			'startFileName' => $nextFileName,
			'maxFileCount'  => $maxFileCount,
		);
		
		if (!empty($options['Prefix'])) $json['prefix'] = $options['Prefix'];
		
		// B2 returns, at most, 1000 files per "page". Loop through the pages and compile an array of File objects.
		while (true) {
			$response = $this->request('POST', $this->apiUrl . '/b2_list_file_names', array(
				'headers' => array(
					'Authorization' => $this->authToken,
				),
				'json'	=> $json
			));

			if (!isset($response['files'])) throw new Exception('Failed to list files. '.serialize($files));
			
			foreach ($response['files'] as $file) {
				// if we have a file name set, only retrieve information if the file name matches
				if (!$fileName || ($fileName === $file['fileName'])) {
					$files[] = new UpdraftPlus_Backblaze_File($file['fileId'], $file['fileName'], null, $file['size']);
				}
			}

			if ($fileName || $response['nextFileName'] === null) {
				// We've got all the files - break out of loop.
				break;
			}

			$json['startFileName'] = $response['nextFileName'];
		}

		return $files;
	}

	public function upload($options) {
		// Clean the path if it starts with /.
		if (substr($options['FileName'], 0, 1) === '/') {
			$options['FileName'] = ltrim($options['FileName'], '/');
		}

		if (!isset($options['BucketId']) && isset($options['BucketName'])) {
			$options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
		}

		// Retrieve the URL that we should be uploading to.
		$response = $this->request('POST', $this->apiUrl . '/b2_get_upload_url', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json'	=> array(
				'bucketId' => $options['BucketId'],
			),
		));

		$uploadEndpoint  = $response['uploadUrl'];
		$uploadAuthToken = $response['authorizationToken'];

		if (is_resource($options['Body'])) {
			// We need to calculate the file's hash incrementally from the stream.
			$context = hash_init('sha1');
			hash_update_stream($context, $options['Body']);
			$hash = hash_final($context);

			// Similarly, we have to use fstat to get the size of the stream.
			$fstat = fstat($options['Body']);
			$size  = $fstat['size'];

			// Rewind the stream before passing it to the HTTP client.
			rewind($options['Body']);
		} else {
			// We've been given a simple string body, it's super simple to calculate the hash and size.
			$hash = sha1($options['Body']);
			$size = mb_strlen($options['Body'], '8bit');
		}

		if (!isset($options['FileLastModified'])) {
			$options['FileLastModified'] = round(microtime(true) * 1000);
		}

		if (!isset($options['FileContentType'])) {
			$options['FileContentType'] = 'b2/x-auto';
		}

		$response = $this->request('POST', $uploadEndpoint, array(
			'headers' => array(
				'Authorization'                      => $uploadAuthToken,
				'Content-Type'                       => $options['FileContentType'],
				'Content-Length'                     => $size,
				'X-Bz-File-Name'                     => $options['FileName'],
				'X-Bz-Content-Sha1'                  => $hash,
				'X-Bz-Info-src_last_modified_millis' => $options['FileLastModified'],
			),
			'body'	=> $options['Body'],
		));

		return new UpdraftPlus_Backblaze_File(
			$response['fileId'],
			$response['fileName'],
			$response['contentSha1'],
			$response['contentLength'],
			$response['contentType'],
			$response['fileInfo']
		);
	}

	public function download($options) {
		$requestUrl	 = null;
		$requestOptions = array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'sink'	=> isset($options['SaveAs']) ? $options['SaveAs'] : null,
		);

		if (isset($options['FileId'])) {
			$requestOptions['query'] = array('fileId' => $options['FileId']);
			$requestUrl			  = $this->downloadUrl . '/b2api/v1/b2_download_file_by_id';
		} else {
			if (!isset($options['BucketName']) && isset($options['BucketId'])) {
				$options['BucketName'] = $this->getBucketNameFromId($options['BucketId']);
			}

			$requestUrl = sprintf('%s/file/%s/%s', $this->downloadUrl, $options['BucketName'], $options['FileName']);
		}
		
		if (isset($options['headers'])) {
			$requestOptions['headers'] = array_merge($requestOptions['headers'], $options['headers']);
		}

		$response = $this->request('GET', $requestUrl, $requestOptions, false);

		return isset($options['SaveAs']) ? true : $response;
	}

	public function getFile($options) {
		if (!isset($options['FileId']) && isset($options['BucketName']) && isset($options['FileName'])) {
			$options['FileId'] = $this->getFileIdFromBucketAndFileName($options['BucketName'], $options['FileName']);

			if (!$options['FileId']) {
				throw new UpdraftPlus_Backblaze_NotFoundException();
			}
		}

		$response = $this->request('POST', $this->apiUrl . '/b2_get_file_info', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json'	=> array(
				'fileId' => $options['FileId'],
			),
		));

		return new UpdraftPlus_Backblaze_File(
			$response['fileId'],
			$response['fileName'],
			$response['contentSha1'],
			$response['contentLength'],
			$response['contentType'],
			$response['fileInfo'],
			$response['bucketId'],
			$response['action'],
			$response['uploadTimestamp']
		);
	}

	/**
	 * Delete a file
	 *
	 * @param Array $options - possible keys are FileName, FileId, BucketName
	 *
	 * @return Boolean. Can also throw an exception; including UpdraftPlus_Backblaze_NotFoundException if the file was not found.
	 */
	public function deleteFile($options) {
		if (!isset($options['FileName'])) {
			$file = $this->getFile($options);

			$options['FileName'] = $file->getName();
		}

		if (!isset($options['FileId']) && isset($options['BucketName']) && isset($options['FileName'])) {
			$file = $this->getFile($options);

			$options['FileId'] = $file->getId();
		}

		$delete_result = $this->request('POST', $this->apiUrl . '/b2_delete_file_version', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json'	=> array(
				'fileName' => $options['FileName'],
				'fileId'   => $options['FileId'],
			),
		));

		return (is_array($delete_result) && !empty($delete_result['fileId'])) ? true : false;
	}

	/**
	 * Delete multiple files
	 *
	 * @param Array  $files_to_delete - array of possible files to delete; sub-keys are FileName, FileId, BucketName
	 * @param String $bucket_name	  - the bucket that files are being deleted from
	 * @param String|Null			  - path prefix (to prevent unnecessary scanning of other paths)
	 *
	 * @return Array|Boolean
	 */
	public function deleteMultipleFiles($files_to_delete, $bucket_name, $path_prefix = null) {
		if (count($files_to_delete) == 0) {
			return false;
		}

		$active       = null;
		$sessions     = [];
		$result       = [];
		$bulk_session = curl_multi_init();

		$list_options = array(
			'BucketName' => $bucket_name
		);
		
		if (is_string($path_prefix) && '' !== $path_prefix) $list_options['Prefix'] = $path_prefix;
		
		$files = $this->listFiles($list_options);

		$files_lookup = array();

		foreach ($files as $file_object) {
			$file_name = $file_object->getName();
			$file_id = $file_object->getId();
			$files_lookup[$file_name] = $file_id;
		}

		foreach ($files_to_delete as $file_identification) {
			
			try {
				if (!isset($file_identification['FileName'])) {
					// We should not enter here as we always pass a file name but just in case
					$file = $this->getFile($file_identification);
					$file_identification['FileName'] = $file->getName();
					$file_identification['FileId'] = $file->getId();
				} elseif (!isset($file_identification['FileId'])) {
					if (isset($files_lookup[$file_identification['FileName']])) {
						$file_identification['FileId'] = $files_lookup[$file_identification['FileName']];
					} else {
						// We should not enter here as all the files should be in the same bucket but just in case
						$file = $this->getFile($file_identification);
						$file_identification['FileId'] = $file->getId();
					}
				}
			} catch (UpdraftPlus_Backblaze_NotFoundException $e) {
				array_push($sessions, true);
				continue;
			}

			$session = $this->request('POST', $this->apiUrl . '/b2_delete_file_version', array(
				'headers' => array(
					'Authorization' => $this->authToken,
				),
				'json'	=> array(
					'fileName' => $file_identification['FileName'],
					'fileId'   => $file_identification['FileId'],
				),
				'session' => true
			));
			array_push($sessions, $session);
			curl_multi_add_handle($bulk_session, $session);
		}

		do {
			$status = curl_multi_exec($bulk_session, $active);
			if ($active) {
				curl_multi_select($bulk_session);
			}
		} while ($active && $status == CURLM_OK);

		foreach ($sessions as $session) {
			if (is_bool($session)) {
				array_push($result, $session);
				continue;
			}
			$response = curl_multi_getcontent($session);
			array_push($result, $response);
			curl_multi_remove_handle($bulk_session, $session);
		}
		curl_multi_close($bulk_session);

		return (is_array($result) && !empty($result)) ? $result : false;
	}
	
	/**
	 * Create a private bucket with the given name.
	 *
	 * @param String $bucket_name - valid bucket name
	 * @throws Exception
	 *
	 * @return boolean - If bucket created successfully, it returns true otherwise false.
	 */
    public function createPrivateBucket($bucket_name) {
		try {
			$response = $this->request('POST', $this->apiUrl.'/b2_create_bucket',
				array(
					'headers' => array(
						'Authorization' => $this->authToken,
					),
					'json' => array(
						'accountId' => $this->accountId,
						'bucketName' => $bucket_name,
						'bucketType' => 'allPrivate'
					)
				)
			);
		} catch (Exception $e) {
			if (400 == $e->getCode()) {
				throw new Exception("Bucket can't be created because Bucket name is already in use.", $e->getCode());
			} else {
				throw $e;	
			}
			return false;
		}
		if (isset($response['bucketId']) && isset($response['bucketName']) && isset($response['bucketType'])) {
			return true;
		}
		return false;
    }

	/**
	 * Set object lock for a file in a B2 storage bucket.
	 *
	 * @param string $id                   The ID of the file to set object lock for.
	 * @param string $filename             The name of the file.
	 * @param int    $object_lock_duration The duration (in days) for which to set object lock.
	 *
	 * @return array $response             The array of response.
	 */
	public function setObjectLock($id, $filename, $object_lock_duration) {
		// Calculate the retain until timestamp (milliseconds) based on the $object_lock_duration.
		$retain_until_timestamp = (time() + ($object_lock_duration * 86400)) * 1000;

		// Make a request to the B2 API to update file retention settings.
		$response = $this->request('POST', $this->apiUrl.'/b2_update_file_retention', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json' => array(
				'fileId' => $id,
				'fileName' => $filename,
				'fileRetention' => array(
					'mode' => 'compliance',
					'retainUntilTimestamp' => $retain_until_timestamp
				)
			)
		));

		return $response;
	}

	/**
	 * Set object lock for a B2 storage bucket.
	 *
	 * @param string $id       The ID of the bucket to set object lock for.
	 *
	 * @return array $response The array of response.
	 */
	public function setObjectLockToBucket($id) {
		// Make a request to the B2 API to update file retention settings.
		$response = $this->request('POST', $this->apiUrl.'/b2_update_bucket', array(
			'headers' => array(
				'Authorization' => $this->authToken,
			),
			'json' => array(
				'accountId' => $this->accountId,
				'bucketId' => $id,
				'fileLockEnabled' => true
			)
		));

		return $response;
	}

}

final class UpdraftPlus_Backblaze_Bucket {
	const TYPE_PUBLIC  = 'allPublic';
	const TYPE_PRIVATE = 'allPrivate';

	protected $id;
	protected $name;
	protected $type;
	protected $objectLockEnabled;

	/**
	 * Bucket constructor.
	 *
	 * @param $id
	 * @param $name
	 * @param $type
	 */
	public function __construct($id, $name, $type, $objectLockEnabled) {
		$this->id   = $id;
		$this->name = strtolower($name);
		$this->type = $type;
		$this->objectLockEnabled = $objectLockEnabled;
	}

	public function getId() {
		return $this->id;
	}

	public function getName() {
		return $this->name;
	}

	public function getType() {
		return $this->type;
	}

	public function isObjectLockEnabled() {
		return $this->objectLockEnabled;
	}
}

final class UpdraftPlus_Backblaze_File {
	protected $id;
	protected $name;
	protected $hash;
	protected $size;
	protected $type;
	protected $info;
	protected $bucketId;
	protected $action;
	protected $uploadTimestamp;

	/**
	 * File constructor.
	 *
	 * @param $id
	 * @param $name
	 * @param $hash
	 * @param $size
	 * @param $type
	 * @param $info
	 * @param $bucketId
	 * @param $action
	 * @param $uploadTimestamp
	 */
	public function __construct($id, $name, $hash = null, $size = null, $type = null, $info = null, $bucketId = null, $action = null, $uploadTimestamp = null) {
		$this->id			  = $id;
		$this->name			= $name;
		$this->hash			= $hash;
		$this->size			= $size;
		$this->type			= $type;
		$this->info			= $info;
		$this->bucketId		= $bucketId;
		$this->action		  = $action;
		$this->uploadTimestamp = $uploadTimestamp;
	}

	/**
	 * @return string
	 */
	public function getId() {
		return $this->id;
	}

	/**
	 * @return string
	 */
	public function getName() {
		return $this->name;
	}

	/**
	 * @return string
	 */
	public function getHash() {
		return $this->hash;
	}

	/**
	 * @return int
	 */
	public function getSize() {
		return $this->size;
	}

	/**
	 * @return string
	 */
	public function getType() {
		return $this->type;
	}

	/**
	 * @return array
	 */
	public function getInfo()
	{
		return $this->info;
	}

	/**
	 * @return string
	 */
	public function getBucketId() {
		return $this->bucketId;
	}

	/**
	 * @return string
	 */
	public function getAction() {
		return $this->action;
	}

	/**
	 * @return string
	 */
	public function getUploadTimestamp() {
		return $this->uploadTimestamp;
	}
}

class UpdraftPlus_Backblaze_NotFoundException extends Exception {
}
