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

ScrobblerService::saveApiSession()   A

Complexity

Conditions 2
Paths 3

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 8
c 0
b 0
f 0
nc 3
nop 2
dl 0
loc 10
rs 10
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 ScrobblerService
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
	/**
124
	 * @param array<int,mixed> $trackIds
125
	 */
126
	public function scrobbleTrack(array $trackIds, string $userId, \DateTime $timeOfPlay) : void {
127
		$sessionKey = $this->getApiSession($userId);
128
		if (!$sessionKey) {
129
			return;
130
		}
131
132
		$timestamp = $timeOfPlay->getTimestamp();
133
		$scrobbleData = [
134
			'sk' => $sessionKey
135
		];
136
137
		/** @var array<Track> $tracks */
138
		$tracks = $this->trackBusinessLayer->findById($trackIds, $userId);
139
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
140
		foreach ($tracks as $i => $track) {
141
			if ($track->getAlbum()) {
142
				$albumArtist = $track->getAlbum()->getAlbumArtistName();
143
				if ($albumArtist !== $track->getArtistName()) {
144
					$scrobbleData["albumArtist[{$i}]"] = $albumArtist;
145
				}
146
			}
147
			$scrobbleData["artist[{$i}]"] = $track->getArtistName();
148
			$scrobbleData["track[{$i}]"] = $track->getTitle();
149
			$scrobbleData["timestamp[{$i}]"] = $timestamp;
150
			$scrobbleData["album[{$i}]"] = $track->getAlbumName();
151
			$scrobbleData["trackNumber[{$i}]"] = $track->getNumber();
152
		}
153
		$scrobbleData = $this->generateMethodParams('track.scrobble', $scrobbleData);
154
155
		$ch = $this->makeCurlHandle();
156
		\curl_setopt($ch, \CURLOPT_POSTFIELDS, \http_build_query($scrobbleData));
157
		$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

157
		$xml = \simplexml_load_string(/** @scrutinizer ignore-type */ \curl_exec($ch));
Loading history...
158
159
		if ((string)$xml['status'] !== 'ok') {
160
			$this->logger->warning('Failed to scrobble to ' . $this->name);
161
		}
162
	}
163
164
	public function getTokenRequestUrl(): ?string {
165
		$apiKey = $this->getApiKey();
166
		if (!$apiKey) {
167
			return null;
168
		}
169
170
		$tokenHandleUrl = $this->urlGenerator->linkToRouteAbsolute('music.scrobbler.handleToken', [
171
			'serviceIdentifier' => $this->identifier
172
		]);
173
		return "{$this->tokenRequestUrl}?api_key={$apiKey}&cb={$tokenHandleUrl}";
174
	}
175
176
	private function saveApiSession(string $userId, string $sessionValue) : void {
177
		try {
178
			$encryptedKey = $this->crypto->encrypt(
179
				$sessionValue,
180
				$userId . $this->config->getSystemValue('secret')
181
			);
182
			$this->config->setUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey', $encryptedKey);
183
		} catch (\Exception $e) {
184
			$this->logger->error('Encryption of scrobble session key failed');
185
			throw $e;
186
		}
187
	}
188
189
	/**
190
	 * @param array<string, string|array> $params
191
	 */
192
	private function generateSignature(array $params) : string {
193
		\ksort($params);
194
		$paramString = '';
195
		foreach ($params as $key => $value) {
196
			if (\is_array($value)) {
197
				foreach ($value as $valIdx => $valVal) {
198
					$paramString .= "{$key}[{$valIdx}]{$valVal}";
199
				}
200
			} else {
201
				$paramString .= $key . $value;
202
			}
203
		}
204
205
		$paramString .= $apiSecret = $this->getApiSecret();
0 ignored issues
show
Unused Code introduced by
The assignment to $apiSecret is dead and can be removed.
Loading history...
206
		return \md5($paramString);
207
	}
208
209
	/**
210
	 * @return array<string, string>
211
	 * @param array<string, mixed> $moreParams
212
	 * @param bool $sign
213
	 * @return array<string, mixed>
214
	 */
215
	private function generateMethodParams(string $method, array $moreParams = [], bool $sign = true) : array {
216
		$params = \array_merge($moreParams, [
217
			'method' => $method,
218
			'api_key' => $this->getApiKey()
219
		]);
220
221
		if ($sign) {
222
			$params['api_sig'] = $this->generateSignature($params);
223
		}
224
225
		return $params;
226
	}
227
228
	/**
229
	 * @return resource (in PHP8+ return \CurlHandle)
230
	 * @throws \RuntimeException when unable to initialize a cURL handle
231
	 */
232
	private function makeCurlHandle() {
233
		$ch = \curl_init();
234
		if (!$ch) {
235
			$this->logger->error('Failed to initialize a curl handle, is the php curl extension installed?');
236
			throw new \RuntimeException('Unable to initialize a curl handle');
237
		}
238
		\curl_setopt($ch, \CURLOPT_URL, $this->endpoint);
239
		\curl_setopt($ch, \CURLOPT_CONNECTTIMEOUT, 10);
240
		\curl_setopt($ch, \CURLOPT_POST, true);
241
		\curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
242
		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...
243
	}
244
}
245