Passed
Push — master ( 7b42d0...d80bc5 )
by Pauli
04:15 queued 21s
created

RadioApiController::update()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 12
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 9
c 0
b 0
f 0
nc 6
nop 4
dl 0
loc 12
rs 9.2222
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\Controller;
14
15
use OCP\AppFramework\Controller;
16
use OCP\AppFramework\Http;
17
use OCP\AppFramework\Http\JSONResponse;
18
use OCP\AppFramework\Http\RedirectResponse;
19
use OCP\AppFramework\Http\Response;
20
21
use OCP\Files\Folder;
22
use OCP\IConfig;
23
use OCP\IRequest;
24
use OCP\IURLGenerator;
25
26
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
27
use OCA\Music\AppFramework\Core\Logger;
28
use OCA\Music\AppFramework\Utility\FileExistsException;
29
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
30
use OCA\Music\Http\ErrorResponse;
31
use OCA\Music\Http\FileResponse;
32
use OCA\Music\Http\RelayStreamResponse;
33
use OCA\Music\Service\PlaylistFileService;
34
use OCA\Music\Service\RadioService;
35
use OCA\Music\Service\StreamTokenService;
36
use OCA\Music\Utility\HttpUtil;
37
38
class RadioApiController extends Controller {
39
	private IConfig $config;
40
	private IURLGenerator $urlGenerator;
41
	private RadioStationBusinessLayer $businessLayer;
42
	private RadioService $service;
43
	private StreamTokenService $tokenService;
44
	private PlaylistFileService $playlistFileService;
45
	private string $userId;
46
	private ?Folder $userFolder;
47
	private Logger $logger;
48
49
	public function __construct(string $appname,
50
								IRequest $request,
51
								IConfig $config,
52
								IURLGenerator $urlGenerator,
53
								RadioStationBusinessLayer $businessLayer,
54
								RadioService $service,
55
								StreamTokenService $tokenService,
56
								PlaylistFileService $playlistFileService,
57
								?string $userId,
58
								?Folder $userFolder,
59
								Logger $logger) {
60
		parent::__construct($appname, $request);
61
		$this->config = $config;
62
		$this->urlGenerator = $urlGenerator;
63
		$this->businessLayer = $businessLayer;
64
		$this->service = $service;
65
		$this->tokenService = $tokenService;
66
		$this->playlistFileService = $playlistFileService;
67
		$this->userId = $userId ?? ''; // ensure non-null to satisfy Scrutinizer; may be null when resolveStreamUrl used on public share
68
		$this->userFolder = $userFolder;
69
		$this->logger = $logger;
70
	}
71
72
	/**
73
	 * lists all radio stations
74
	 *
75
	 * @NoAdminRequired
76
	 * @NoCSRFRequired
77
	 */
78
	public function getAll() : JSONResponse {
79
		$stations = $this->businessLayer->findAll($this->userId);
80
		return new JSONResponse(
81
			\array_map(fn($s) => $s->toApi(), $stations)
82
		);
83
	}
84
85
	/**
86
	 * creates a station
87
	 *
88
	 * @NoAdminRequired
89
	 * @NoCSRFRequired
90
	 */
91
	public function create(?string $name, ?string $streamUrl, ?string $homeUrl) : JSONResponse {
92
		if ($streamUrl === null) {
93
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, "Mandatory argument 'streamUrl' not given");
94
		}
95
		
96
		try {
97
			$station = $this->businessLayer->create($this->userId, $name, $streamUrl, $homeUrl);
98
			return new JSONResponse($station->toApi());
99
		} catch (\DomainException $ex) {
100
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, $ex->getMessage());
101
		}
102
	}
103
104
	/**
105
	 * deletes a station
106
	 *
107
	 * @NoAdminRequired
108
	 * @NoCSRFRequired
109
	 */
110
	public function delete(int $id) : JSONResponse {
111
		try {
112
			$this->businessLayer->delete($id, $this->userId);
113
			return new JSONResponse([]);
114
		} catch (BusinessLayerException $ex) {
115
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
116
		}
117
	}
118
119
	/**
120
	 * get a single radio station
121
	 *
122
	 * @NoAdminRequired
123
	 * @NoCSRFRequired
124
	 */
125
	public function get(int $id) : JSONResponse {
126
		try {
127
			$station = $this->businessLayer->find($id, $this->userId);
128
			return new JSONResponse($station->toApi());
129
		} catch (BusinessLayerException $ex) {
130
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
131
		}
132
	}
133
134
	/**
135
	 * update a station
136
	 *
137
	 * @NoAdminRequired
138
	 * @NoCSRFRequired
139
	 */
140
	public function update(int $id, ?string $name = null, ?string $streamUrl = null, ?string $homeUrl = null) : JSONResponse {
141
		if ($name === null && $streamUrl === null && $homeUrl === null) {
142
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, "at least one of the args ['name', 'streamUrl', 'homeUrl'] must be given");
143
		}
144
145
		try {
146
			$station = $this->businessLayer->updateStation($id, $this->userId, $name, $streamUrl, $homeUrl);
147
			return new JSONResponse($station->toApi());
148
		} catch (BusinessLayerException $ex) {
149
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
150
		} catch (\DomainException $ex) {
151
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, $ex->getMessage());
152
		}
153
	}
154
155
	/**
156
	 * export all radio stations to a file
157
	 *
158
	 * @param string $name target file name
159
	 * @param string $path parent folder path
160
	 * @param string $oncollision action to take on file name collision,
161
	 *								supported values:
162
	 *								- 'overwrite' The existing file will be overwritten
163
	 *								- 'keepboth' The new file is named with a suffix to make it unique
164
	 *								- 'abort' (default) The operation will fail
165
	 *
166
	 * @NoAdminRequired
167
	 * @NoCSRFRequired
168
	 */
169
	public function exportAllToFile(string $name, string $path, string $oncollision='abort') : JSONResponse {
170
		if ($this->userFolder === null) {
171
			// This shouldn't get actually run. The folder may be null in case the user has already logged out.
172
			// But in that case, the framework should block the execution before it reaches here.
173
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'no valid user folder got');
174
		}
175
		try {
176
			$exportedFilePath = $this->playlistFileService->exportRadioStationsToFile(
177
					$this->userId, $this->userFolder, $path, $name, $oncollision);
178
			return new JSONResponse(['wrote_to_file' => $exportedFilePath]);
179
		} catch (\OCP\Files\NotFoundException $ex) {
180
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'folder not found');
181
		} catch (FileExistsException $ex) {
182
			return new ErrorResponse(Http::STATUS_CONFLICT, 'file already exists', ['path' => $ex->getPath(), 'suggested_name' => $ex->getAltName()]);
183
		} catch (\OCP\Files\NotPermittedException $ex) {
184
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'user is not allowed to write to the target file');
185
		}
186
	}
187
188
	/**
189
	 * import radio stations from a file
190
	 * @param string $filePath path of the file to import
191
	 *
192
	 * @NoAdminRequired
193
	 * @NoCSRFRequired
194
	 */
195
	public function importFromFile(string $filePath) : JSONResponse {
196
		if ($this->userFolder === null) {
197
			// This shouldn't get actually run. The folder may be null in case the user has already logged out.
198
			// But in that case, the framework should block the execution before it reaches here.
199
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'no valid user folder got');
200
		}
201
		try {
202
			$result = $this->playlistFileService->importRadioStationsFromFile($this->userId, $this->userFolder, $filePath);
203
			$result['stations'] = \array_map(fn($s) => $s->toApi(), $result['stations']);
204
			return new JSONResponse($result);
205
		} catch (\OCP\Files\NotFoundException $ex) {
206
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'playlist file not found');
207
		} catch (\UnexpectedValueException $ex) {
208
			return new ErrorResponse(Http::STATUS_UNSUPPORTED_MEDIA_TYPE, $ex->getMessage());
209
		}
210
	}
211
212
	/**
213
	 * reset all the radio stations of the user
214
	 *
215
	 * @NoAdminRequired
216
	 * @NoCSRFRequired
217
	 */
218
	public function resetAll() : JSONResponse {
219
		$this->businessLayer->deleteAll($this->userId);
220
		return new JSONResponse(['success' => true]);
221
	}
222
223
	/**
224
	* get metadata for a channel
225
	*
226
	* @NoAdminRequired
227
	* @NoCSRFRequired
228
	*/
229
	public function getChannelInfo(int $id, ?string $type=null) : JSONResponse {
230
		try {
231
			$station = $this->businessLayer->find($id, $this->userId);
232
			$streamUrl = $station->getStreamUrl();
233
234
			switch ($type) {
235
				case 'icy':
236
					$metadata = $this->service->readIcyMetadata($streamUrl, 3, 5);
237
					break;
238
				case 'shoutcast-v1':
239
					$metadata = $this->service->readShoutcastV1Metadata($streamUrl);
240
					break;
241
				case 'shoutcast-v2':
242
					$metadata = $this->service->readShoutcastV2Metadata($streamUrl);
243
					break;
244
				case 'icecast':
245
					$metadata = $this->service->readIcecastMetadata($streamUrl);
246
					break;
247
				default:
248
					$metadata = $this->service->readIcyMetadata($streamUrl, 3, 5)
249
							?? $this->service->readShoutcastV2Metadata($streamUrl)
250
							?? $this->service->readIcecastMetadata($streamUrl)
251
							?? $this->service->readShoutcastV1Metadata($streamUrl);
252
					break;
253
			}
254
255
			return new JSONResponse($metadata);
256
		} catch (BusinessLayerException $ex) {
257
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
258
		}
259
	}
260
261
	/**
262
	 * get stream URL for a radio station
263
	 *
264
	 * @NoAdminRequired
265
	 * @NoCSRFRequired
266
	 */
267
	public function stationStreamUrl(int $id) : JSONResponse {
268
		try {
269
			$station = $this->businessLayer->find($id, $this->userId);
270
			$streamUrl = $station->getStreamUrl();
271
			$resolved = $this->service->resolveStreamUrl($streamUrl);
272
			$relayEnabled = $this->streamRelayEnabled();
273
			if ($relayEnabled && !$resolved['hls']) {
274
				$resolved['url'] = $this->urlGenerator->linkToRoute('music.radioApi.stationStream', ['id' => $id]);
275
			}
276
			return new JSONResponse($resolved);
277
		} catch (BusinessLayerException $ex) {
278
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
279
		}
280
	}
281
282
	/**
283
	 * get audio stream for a radio station
284
	 *
285
	 * @NoAdminRequired
286
	 * @NoCSRFRequired
287
	 */
288
	public function stationStream(int $id) : Response {
289
		try {
290
			$station = $this->businessLayer->find($id, $this->userId);
291
			$streamUrl = $station->getStreamUrl();
292
			$resolved = $this->service->resolveStreamUrl($streamUrl);
293
			if ($this->streamRelayEnabled()) {
294
				return new RelayStreamResponse($resolved['url']);
295
			} else {
296
				return new RedirectResponse($resolved['url']);
297
			}
298
		} catch (BusinessLayerException $ex) {
299
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
300
		}
301
	}
302
303
	/**
304
	 * get the actual stream URL from the given public URL
305
	 *
306
	 * Available without login since no user data is handled and this may be used on link-shared folder.
307
	 *
308
	 * @PublicPage
309
	 * @NoCSRFRequired
310
	 */
311
	public function resolveStreamUrl(string $url, ?string $token) : JSONResponse {
312
		$url = \rawurldecode($url);
313
314
		if ($token === null) {
315
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
316
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
317
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
318
		} else {
319
			$resolved = $this->service->resolveStreamUrl($url);
320
			$relayEnabled = $this->streamRelayEnabled();
321
			if ($relayEnabled && !$resolved['hls']) {
322
				$token = $this->tokenService->tokenForUrl($resolved['url']);
323
				$resolved['url'] = $this->urlGenerator->linkToRoute('music.radioApi.streamFromUrl',
324
					['url' => \rawurlencode($resolved['url']), 'token' => \rawurlencode($token)]);
325
			}
326
			return new JSONResponse($resolved);
327
		}
328
	}
329
330
	/**
331
	 * create a relayed stream for the given URL if relaying enabled; otherwise just redirect to the URL
332
	 * 
333
	 * @PublicPage
334
	 * @NoCSRFRequired
335
	 */
336
	public function streamFromUrl(string $url, ?string $token) : Response {
337
		$url = \rawurldecode($url);
338
339
		if ($token === null) {
340
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
341
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
342
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
343
		} elseif ($this->streamRelayEnabled()) {
344
			return new RelayStreamResponse($url);
345
		} else {
346
			return new RedirectResponse($url);
347
		}
348
	}
349
350
	/**
351
	 * get manifest of a HLS stream
352
	 *
353
	 * This fetches the manifest file from the given URL and returns a modified version of it.
354
	 * The front-end can't easily stream directly from the original source because of the Content-Security-Policy.
355
	 *
356
	 * @PublicPage
357
	 * @NoCSRFRequired
358
	 */
359
	public function hlsManifest(string $url, ?string $token) : Response {
360
		$url = \rawurldecode($url);
361
362
		if (!$this->hlsEnabled()) {
363
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'the cloud admin has disabled HLS streaming');
364
		} elseif ($token === null) {
365
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
366
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
367
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
368
		} else {
369
			list('content' => $content, 'status_code' => $status, 'content_type' => $contentType)
370
				= $this->service->getHlsManifest($url);
371
372
			return new FileResponse([
373
				'content' => $content,
374
				'mimetype' => $contentType
375
			], $status);
376
		}
377
	}
378
379
	/**
380
	 * get one segment of a HLS stream
381
	 *
382
	 * The segment is fetched from the given URL and relayed as such to the client.
383
	 *
384
	 * @PublicPage
385
	 * @NoCSRFRequired
386
	 */
387
	public function hlsSegment(string $url, ?string $token) : Response {
388
		$url = \rawurldecode($url);
389
390
		if (!$this->hlsEnabled()) {
391
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'the cloud admin has disabled HLS streaming');
392
		} elseif ($token === null) {
393
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
394
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
395
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
396
		} else {
397
			list('content' => $content, 'status_code' => $status, 'content_type' => $contentType)
398
				= HttpUtil::loadFromUrl($url);
399
400
			return new FileResponse([
401
				'content' => $content,
402
				'mimetype' => $contentType ?? 'application/octet-stream'
403
			], $status);
404
		}
405
	}
406
407
	private function hlsEnabled() : bool {
408
		$enabled = (bool)$this->config->getSystemValue('music.enable_radio_hls', true);
409
		if ($this->userId === '') {
410
			$enabled = (bool)$this->config->getSystemValue('music.enable_radio_hls_on_share', $enabled);
411
		}
412
		return $enabled;
413
	}
414
415
	private function streamRelayEnabled() : bool {
416
		$enabled = (bool)$this->config->getSystemValue('music.relay_radio_stream', true);
417
		if ($this->userId === '') {
418
			$enabled = (bool)$this->config->getSystemValue('music.relay_radio_stream_on_share', $enabled);
419
		}
420
		return $enabled;
421
	}
422
}
423