Passed
Pull Request — master (#1266)
by Matthew
04:19
created

ScrobblerService::generateBaseMethodParams()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 7
rs 10
c 1
b 0
f 0
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2020 - 2025
11
 */
12
13
namespace OCA\Music\Service;
14
15
use DateTime;
16
use OCA\Music\AppFramework\Core\Logger;
17
use OCA\Music\BusinessLayer\TrackBusinessLayer;
18
use OCA\Music\Db\Track;
19
use OCP\IConfig;
20
use OCP\IURLGenerator;
21
use OCP\Security\ICrypto;
22
23
class ScrobblerService
24
{
25
	private IConfig $config;
26
27
	private Logger $logger;
28
29
	private IURLGenerator $urlGenerator;
30
31
	private TrackBusinessLayer $trackBusinessLayer;
32
33
	private ?string $appName;
34
35
	private ICrypto $crypto;
36
37
	public const SCROBBLE_SERVICES = [
38
		'lastfm' => [
39
			'endpoint' => 'http://ws.audioscrobbler.com/2.0/',
40
			'name' => 'Last.fm'
41
		]
42
	];
43
44
	public function __construct(
45
		IConfig $config,
46
		Logger $logger,
47
		IURLGenerator $urlGenerator,
48
		TrackBusinessLayer $trackBusinessLayer,
49
		ICrypto $crypto,
50
		?string $appName = 'music'
51
	) {
52
		$this->config = $config;
53
		$this->logger = $logger;
54
		$this->urlGenerator = $urlGenerator;
55
		$this->trackBusinessLayer = $trackBusinessLayer;
56
		$this->crypto = $crypto;
57
		$this->appName = $appName;
58
	}
59
60
	/**
61
	 * @throws \Throwable when unable to generate or save a session
62
	 */
63
	public function generateSession(string $token, string $userId) : void {
64
		$scrobbleService = $this->getApiService();
65
		$ch = $this->makeCurlHandle($scrobbleService);
0 ignored issues
show
Bug introduced by
It seems like $scrobbleService can also be of type null; however, parameter $scrobblerServiceIdentifier of OCA\Music\Service\Scrobb...rvice::makeCurlHandle() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

65
		$ch = $this->makeCurlHandle(/** @scrutinizer ignore-type */ $scrobbleService);
Loading history...
66
		$params = $this->generateBaseMethodParams('auth.getSession');
67
		$params['token'] = $token;
68
		$params['api_sig'] = $this->generateSignature($params);
69
		\curl_setopt($ch, \CURLOPT_POSTFIELDS, \http_build_query($params));
70
		$sessionText = \curl_exec($ch);
71
		$xml = \simplexml_load_string($sessionText);
0 ignored issues
show
Bug introduced by
It seems like $sessionText can also be of type true; however, parameter $data of simplexml_load_string() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

71
		$xml = \simplexml_load_string(/** @scrutinizer ignore-type */ $sessionText);
Loading history...
72
73
		$status = (string)$xml['status'];
74
		if ($status !== 'ok') {
75
			throw new \Exception((string)$xml->error, (int)$xml->error['code']);
76
		}
77
78
		try {
79
			$encryptedKey = $this->crypto->encrypt(
80
				(string)$xml->session->key,
81
				$userId . $this->config->getSystemValue('secret')
82
			);
83
			$this->config->setUserValue($userId, $this->appName, 'scrobbleSessionKey', $encryptedKey);
84
		} catch (\Throwable $e) {
85
			$this->logger->error('Unable to save session key ' . $e->getMessage());
86
			throw $e;
87
		}
88
	}
89
90
	public function getApiKey() : ?string {
91
		return $this->config->getSystemValue('music.scrobble_api_key', null);
92
	}
93
94
	public function getApiSecret() : ?string {
95
		return $this->config->getSystemValue('music.scrobble_api_secret', null);
96
	}
97
98
	public function getApiService() : ?string {
99
		return $this->config->getSystemValue('music.scrobble_api_service', null);
100
	}
101
102
	public function getApiSession(string $userId): ?string
103
	{
104
		$encryptedKey = $this->config->getUserValue($userId, $this->appName, 'scrobbleSessionKey');
105
		if (!$encryptedKey) {
106
			return null;
107
		}
108
		$key = $this->crypto->decrypt($encryptedKey, $userId . $this->config->getSystemValue('secret'));
109
		return $key;
110
	}
111
112
	public function getTokenRequestUrl(): ?string {
113
		$apiKey = $this->getApiKey();
114
		$apiService = $this->getApiService();
115
		if (!$apiKey || !$apiService) {
116
			return null;
117
		}
118
119
		$tokenHandleUrl = $this->urlGenerator->linkToRouteAbsolute('music.scrobbler.handleToken');
120
		switch ($apiService) {
121
			case 'lastfm':
122
				return "http://www.last.fm/api/auth/?api_key={$apiKey}&cb={$tokenHandleUrl}";
123
			default:
124
				throw new \Exception('Invalid service');
125
		}
126
	}
127
128
	/**
129
	 * @param array<int,mixed> $trackIds
130
	 */
131
	public function scrobbleTrack(array $trackIds, string $userId, \DateTime $timeOfPlay) : bool {
132
		$sessionKey = $this->getApiSession($userId);
133
		$scrobbleService = $this->getApiService();
134
		if (!$sessionKey || !$scrobbleService) {
135
			return false;
136
		}
137
138
		$timestamp = $timeOfPlay->getTimestamp();
139
		$scrobbleData = \array_merge($this->generateBaseMethodParams('track.scrobble'), [
140
			'sk' => $sessionKey,
141
		]);
142
143
		/** @var array<Track> $tracks */
144
		$tracks = $this->trackBusinessLayer->findById($trackIds);
145
		foreach ($tracks as $i => $track) {
146
			$scrobbleData["artist[{$i}]"] = $track->getArtistName();
147
			$scrobbleData["track[{$i}]"] = $track->getTitle();
148
			$scrobbleData["timestamp[{$i}]"] = $timestamp;
149
			$scrobbleData["album[{$i}]"] = $track->getAlbumName();
150
			$scrobbleData["trackNumber[{$i}]"] = $track->getNumber();
151
		}
152
		$scrobbleData['api_sig'] = $this->generateSignature($scrobbleData);
153
154
		try {
155
			$ch = $this->makeCurlHandle($scrobbleService);
156
			$postFields = \http_build_query($scrobbleData);
157
			\curl_setopt($ch, \CURLOPT_POSTFIELDS, $postFields);
158
			$xml = \simplexml_load_string(\curl_exec($ch));
0 ignored issues
show
Bug introduced by
It seems like curl_exec($ch) can also be of type true; however, parameter $data of simplexml_load_string() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

158
			$xml = \simplexml_load_string(/** @scrutinizer ignore-type */ \curl_exec($ch));
Loading history...
159
			$status = (string)$xml['status'] === 'ok';
160
		} catch (\Throwable $t) {
161
			$status = false;
162
			$this->logger->error($t->getMessage());
163
		} finally {
164
			return $status;
165
		}
166
	}
167
168
	public function clearSession(?string $userId) : void {
169
		$this->config->deleteUserValue($userId, $this->appName, 'scrobbleSessionKey');
170
	}
171
172
	public function getName() : string
173
	{
174
		$apiService = $this->getApiService();
175
		if (!$apiService) {
176
			return '';
177
		}
178
179
		switch ($apiService) {
180
			case 'lastfm':
181
				return "Last.fm";
182
			default:
183
				throw new \Exception('Invalid service');
184
		}
185
	}
186
187
	/**
188
	 * @param array<string, string|array> $params
189
	 */
190
	private function generateSignature(array $params) : string {
191
		\ksort($params);
192
		$paramString = '';
193
		foreach ($params as $key => $value) {
194
			if (\is_array($value)) {
195
				foreach ($value as $valIdx => $valVal) {
196
					$paramString .= "{$key}[{$valIdx}]{$valVal}";
197
				}
198
			} else {
199
				$paramString .= $key . $value;
200
			}
201
		}
202
203
		$paramString .= $apiSecret = $this->getApiSecret();
0 ignored issues
show
Unused Code introduced by
The assignment to $apiSecret is dead and can be removed.
Loading history...
204
		return \md5($paramString);
205
	}
206
207
	/**
208
	 * @return array<string, string>
209
	 */
210
	private function generateBaseMethodParams(string $method) : array {
211
		$params = [
212
			'method' => $method,
213
			'api_key' => $this->getApiKey()
214
		];
215
216
		return $params;
217
	}
218
219
	/**
220
	 * @return resource in PHP8+ \CurlHandle
221
	 * @throws \RuntimeException when unable to initialize a cURL handle
222
	 */
223
	private function makeCurlHandle(string $scrobblerServiceIdentifier) {
224
		$endpoint = self::SCROBBLE_SERVICES[$scrobblerServiceIdentifier]['endpoint'];
225
		$ch = \curl_init($endpoint);
226
		if (!$ch) {
227
			throw new \RuntimeException('Unable to initialize a cURL handle');
228
		}
229
		\curl_setopt($ch, \CURLOPT_CONNECTTIMEOUT, 10);
230
		\curl_setopt($ch, \CURLOPT_POST, true);
231
		\curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
232
		return $ch;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $ch also could return the type CurlHandle which is incompatible with the documented return type resource.
Loading history...
233
	}
234
}
235