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

ExternalScrobbler::getApiSession()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 1
dl 0
loc 7
rs 10
c 0
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 Matthew Wells
10
 * @copyright Matthew Wells 2025
11
 */
12
13
namespace OCA\Music\Service;
14
15
use DateTime;
16
use OCA\Music\AppFramework\Core\Logger;
17
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
18
use OCA\Music\BusinessLayer\TrackBusinessLayer;
19
use OCA\Music\Db\Track;
20
use OCP\IConfig;
21
use OCP\IURLGenerator;
22
use OCP\Security\ICrypto;
23
24
class ExternalScrobbler implements Scrobbler
25
{
26
	private IConfig $config;
27
	private Logger $logger;
28
	private IURLGenerator $urlGenerator;
29
	private TrackBusinessLayer $trackBusinessLayer;
30
	private AlbumBusinessLayer $albumBusinessLayer;
31
	private ICrypto $crypto;
32
	private string $name;
33
	private string $identifier;
34
	private string $endpoint;
35
	private string $tokenRequestUrl;
36
	private ?string $appName;
37
38
	public function __construct(
39
		IConfig $config,
40
		Logger $logger,
41
		IURLGenerator $urlGenerator,
42
		TrackBusinessLayer $trackBusinessLayer,
43
		AlbumBusinessLayer $albumBusinessLayer,
44
		ICrypto $crypto,
45
		string $name,
46
		string $identifier,
47
		string $endpoint,
48
		string $tokenRequestUrl,
49
		?string $appName = 'music'
50
	) {
51
		$this->config = $config;
52
		$this->logger = $logger;
53
		$this->urlGenerator = $urlGenerator;
54
		$this->trackBusinessLayer = $trackBusinessLayer;
55
		$this->albumBusinessLayer = $albumBusinessLayer;
56
		$this->crypto = $crypto;
57
		$this->name = $name;
58
		$this->identifier = $identifier;
59
		$this->endpoint = $endpoint;
60
		$this->tokenRequestUrl = $tokenRequestUrl;
61
		$this->appName = $appName;
62
	}
63
64
	/**
65
	 * @throws \Exception when curl initialization or session key save fails
66
	 * @throws ScrobbleServiceException when auth.getSession call fails
67
	 */
68
	public function generateSession(string $token, string $userId) : void {
69
		$ch = $this->makeCurlHandle();
70
		\curl_setopt($ch, \CURLOPT_POSTFIELDS, \http_build_query(
71
			$this->generateMethodParams('auth.getSession', ['token' => $token])
72
		));
73
		$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

73
		$xml = \simplexml_load_string(/** @scrutinizer ignore-type */ \curl_exec($ch));
Loading history...
74
75
		$status = (string)$xml['status'];
76
		if ($status !== 'ok') {
77
			throw new ScrobbleServiceException((string)$xml->error, (int)$xml->error['code']);
78
		}
79
		$sessionValue = (string)$xml->session->key;
80
81
		$this->saveApiSession($userId, $sessionValue);
82
	}
83
84
	/**
85
	 * @throws \InvalidArgumentException
86
	 */
87
	public function clearSession(?string $userId) : void {
88
		try {
89
			$this->config->deleteUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey');
90
		} catch (\InvalidArgumentException $e) {
91
			$this->logger->error(
92
				'Could not delete user config "' . $this->identifier . '.scrobbleSessionKey". ' . $e->getMessage()
93
			);
94
			throw $e;
95
		}
96
	}
97
98
	public function getApiSession(string $userId) : ?string {
99
		$encryptedKey = $this->config->getUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey');
100
		if (!$encryptedKey) {
101
			return null;
102
		}
103
		$key = $this->crypto->decrypt($encryptedKey, $userId . $this->config->getSystemValue('secret'));
104
		return $key;
105
	}
106
107
	public function getName() : string {
108
		return $this->name;
109
	}
110
111
	public function getIdentifier() : string {
112
		return $this->identifier;
113
	}
114
115
	public function getApiKey() : ?string {
116
		return $this->config->getSystemValue('music.' . $this->identifier . '_api_key', null);
117
	}
118
119
	public function getApiSecret() : ?string {
120
		return $this->config->getSystemValue('music.' . $this->identifier . '_api_secret', null);
121
	}
122
123
	public function recordTrackPlayed(int $trackId, string $userId, ?\DateTime $timeOfPlay = null) : void {
124
		$timeOfPlay = $timeOfPlay ?? new \DateTime();
125
		$sessionKey = $this->getApiSession($userId);
126
		if (!$sessionKey) {
127
			return;
128
		}
129
130
		$timestamp = $timeOfPlay->getTimestamp();
131
		$scrobbleData = [
132
			'sk' => $sessionKey
133
		];
134
135
		/** @var array<Track> $tracks */
136
		$tracks = $this->trackBusinessLayer->findById([$trackId], $userId);
137
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
138
		foreach ($tracks as $i => $track) {
139
			if ($track->getAlbum()) {
140
				$albumArtist = $track->getAlbum()->getAlbumArtistName();
141
				if ($albumArtist !== $track->getArtistName()) {
142
					$scrobbleData["albumArtist[{$i}]"] = $albumArtist;
143
				}
144
			}
145
			$scrobbleData["artist[{$i}]"] = $track->getArtistName();
146
			$scrobbleData["track[{$i}]"] = $track->getTitle();
147
			$scrobbleData["timestamp[{$i}]"] = $timestamp;
148
			$scrobbleData["album[{$i}]"] = $track->getAlbumName();
149
			$scrobbleData["trackNumber[{$i}]"] = $track->getNumber();
150
		}
151
		$scrobbleData = $this->generateMethodParams('track.scrobble', $scrobbleData);
152
153
		$ch = $this->makeCurlHandle();
154
		\curl_setopt($ch, \CURLOPT_POSTFIELDS, \http_build_query($scrobbleData));
155
		$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

155
		$xml = \simplexml_load_string(/** @scrutinizer ignore-type */ \curl_exec($ch));
Loading history...
156
157
		if ((string)$xml['status'] !== 'ok') {
158
			$this->logger->warning('Failed to scrobble to ' . $this->name);
159
		}
160
	}
161
162
	public function getTokenRequestUrl(): ?string {
163
		$apiKey = $this->getApiKey();
164
		if (!$apiKey) {
165
			return null;
166
		}
167
168
		$tokenHandleUrl = $this->urlGenerator->linkToRouteAbsolute('music.scrobbler.handleToken', [
169
			'serviceIdentifier' => $this->identifier
170
		]);
171
		return "{$this->tokenRequestUrl}?api_key={$apiKey}&cb={$tokenHandleUrl}";
172
	}
173
174
	private function saveApiSession(string $userId, string $sessionValue) : void {
175
		try {
176
			$encryptedKey = $this->crypto->encrypt(
177
				$sessionValue,
178
				$userId . $this->config->getSystemValue('secret')
179
			);
180
			$this->config->setUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey', $encryptedKey);
181
		} catch (\Exception $e) {
182
			$this->logger->error('Encryption of scrobble session key failed');
183
			throw $e;
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
	 * @param array<string, mixed> $moreParams
210
	 * @param bool $sign
211
	 * @return array<string, mixed>
212
	 */
213
	private function generateMethodParams(string $method, array $moreParams = [], bool $sign = true) : array {
214
		$params = \array_merge($moreParams, [
215
			'method' => $method,
216
			'api_key' => $this->getApiKey()
217
		]);
218
219
		if ($sign) {
220
			$params['api_sig'] = $this->generateSignature($params);
221
		}
222
223
		return $params;
224
	}
225
226
	/**
227
	 * @return resource (in PHP8+ return \CurlHandle)
228
	 * @throws \RuntimeException when unable to initialize a cURL handle
229
	 */
230
	private function makeCurlHandle() {
231
		$ch = \curl_init();
232
		if (!$ch) {
233
			$this->logger->error('Failed to initialize a curl handle, is the php curl extension installed?');
234
			throw new \RuntimeException('Unable to initialize a curl handle');
235
		}
236
		\curl_setopt($ch, \CURLOPT_URL, $this->endpoint);
237
		\curl_setopt($ch, \CURLOPT_CONNECTTIMEOUT, 10);
238
		\curl_setopt($ch, \CURLOPT_POST, true);
239
		\curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
240
		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...
241
	}
242
}
243