Passed
Push — master ( 54eb8d...5f3e02 )
by Pauli
04:51
created

CoverApiController::externalCover()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 13
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 11
nc 4
nop 1
dl 0
loc 13
rs 9.6111
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 Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2024, 2025
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use OCP\AppFramework\Controller;
16
use OCP\AppFramework\Http;
17
use OCP\AppFramework\Http\RedirectResponse;
18
use OCP\AppFramework\Http\Response;
19
use OCP\Files\IRootFolder;
20
use OCP\IRequest;
21
use OCP\IURLGenerator;
22
23
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
24
use OCA\Music\AppFramework\Core\Logger;
25
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
26
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
27
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
28
use OCA\Music\Db\Album;
29
use OCA\Music\Db\Artist;
30
use OCA\Music\Db\Entity;
31
use OCA\Music\Db\PodcastChannel;
32
use OCA\Music\Http\ErrorResponse;
33
use OCA\Music\Http\FileResponse;
34
use OCA\Music\Service\CoverService;
35
use OCA\Music\Utility\HttpUtil;
36
use OCA\Music\Utility\StringUtil;
37
38
class CoverApiController extends Controller {
39
40
	private IURLGenerator $urlGenerator;
41
	private IRootFolder $rootFolder;
42
	private ArtistBusinessLayer $artistBusinessLayer;
43
	private AlbumBusinessLayer $albumBusinessLayer;
44
	private PodcastChannelBusinessLayer $podcastChannelBusinessLayer;
45
	private CoverService $coverService;
46
	private ?string $userId;
47
	private Logger $logger;
48
49
	public function __construct(
50
			string $appName,
51
			IRequest $request,
52
			IURLGenerator $urlGenerator,
53
			IRootFolder $rootFolder,
54
			ArtistBusinessLayer $artistBusinessLayer,
55
			AlbumBusinessLayer $albumBusinessLayer,
56
			PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
57
			CoverService $coverService,
58
			?string $userId, // null if this gets called after the user has logged out or on a public page
59
			Logger $logger
60
	) {
61
		parent::__construct($appName, $request);
62
		$this->urlGenerator = $urlGenerator;
63
		$this->rootFolder = $rootFolder;
64
		$this->artistBusinessLayer = $artistBusinessLayer;
65
		$this->albumBusinessLayer = $albumBusinessLayer;
66
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
67
		$this->coverService = $coverService;
68
		$this->userId = $userId;
69
		$this->logger = $logger;
70
	}
71
72
	/**
73
	 * @PublicPage
74
	 * @NoCSRFRequired
75
	 */
76
	public function externalCover(?string $url) : Response {
77
		$allowedDomains = ['lastfm.freetls.fastly.net']; // domain used by Last.fm for the album art
78
79
		if (empty($url)) {
80
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, 'Required argument "url" missing');
81
		} elseif (!\in_array(\parse_url($url, PHP_URL_HOST), $allowedDomains)) {
82
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'Only whitelisted host names are allowed');
83
		} else {
84
			['content' => $content, 'content_type' => $contentType] = HttpUtil::loadFromUrl($url);
85
			if ($content === false || !StringUtil::startsWith($contentType, 'image/')) {
86
				return new ErrorResponse(Http::STATUS_NOT_FOUND, 'Failed to fetch image from given URL');
87
			} else {
88
				return new FileResponse(['content' => $content, 'mimetype' => $contentType]);
89
			}
90
		}
91
	}
92
93
	/**
94
	 * @PublicPage
95
	 * @NoCSRFRequired
96
	 */
97
	public function albumCover(int $albumId, ?string $originalSize, ?string $coverToken) : Response {
98
		try {
99
			$userId = $this->userId ?? $this->coverService->getUserForAccessToken($coverToken);
100
			$album = $this->albumBusinessLayer->find($albumId, $userId);
101
			return $this->cover($album, $userId, $originalSize);
102
		} catch (BusinessLayerException | \OutOfBoundsException $ex) {
103
			$this->logger->debug("Failed to get the requested cover: $ex");
104
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
105
		}
106
	}
107
108
	/**
109
	 * @PublicPage
110
	 * @NoCSRFRequired
111
	 */
112
	public function artistCover(int $artistId, ?string $originalSize, ?string $coverToken) : Response {
113
		try {
114
			$userId = $this->userId ?? $this->coverService->getUserForAccessToken($coverToken);
115
			$artist = $this->artistBusinessLayer->find($artistId, $userId);
116
			return $this->cover($artist, $userId, $originalSize);
117
		} catch (BusinessLayerException | \OutOfBoundsException $ex) {
118
			$this->logger->debug("Failed to get the requested cover: $ex");
119
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
120
		}
121
	}
122
123
	/**
124
	 * @PublicPage
125
	 * @NoCSRFRequired
126
	 */
127
	public function podcastCover(int $channelId, ?string $originalSize, ?string $coverToken) : Response {
128
		try {
129
			$userId = $this->userId ?? $this->coverService->getUserForAccessToken($coverToken);
130
			$channel = $this->podcastChannelBusinessLayer->find($channelId, $userId);
131
			return $this->cover($channel, $userId, $originalSize);
132
		} catch (BusinessLayerException | \OutOfBoundsException $ex) {
133
			$this->logger->debug("Failed to get the requested cover: $ex");
134
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
135
		}
136
	}
137
138
	/**
139
	 * @PublicPage
140
	 * @NoCSRFRequired
141
	 */
142
	public function cachedCover(string $hash, ?string $coverToken) : Response {
143
		try {
144
			$userId = $this->userId ?? $this->coverService->getUserForAccessToken($coverToken);
145
			$coverData = $this->coverService->getCoverFromCache($hash, $userId);
146
			if ($coverData === null) {
147
				throw new \OutOfBoundsException("Cover with hash $hash not found");
148
			}
149
			$response =  new FileResponse($coverData);
150
			// instruct also the client-side to cache the result, this is safe
151
			// as the resource URI contains the image hash
152
			HttpUtil::setClientCachingDays($response, 365);
153
			return $response;
154
		} catch (\OutOfBoundsException $ex) {
155
			$this->logger->debug("Failed to get the requested cover: $ex");
156
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
157
		}
158
	}
159
160
	/**
161
	 * @param Artist|Album|PodcastChannel $entity
162
	 * @param string|int|bool|null $originalSize
163
	 */
164
	private function cover(Entity $entity, string $userId, /*mixed*/ $originalSize) : Response {
165
		$originalSize = \filter_var($originalSize, FILTER_VALIDATE_BOOLEAN);
166
		$userFolder = $this->rootFolder->getUserFolder($userId);
167
168
		if ($originalSize) {
169
			// cover requested in original size, without scaling or cropping
170
			$cover = $this->coverService->getCover($entity, $userId, $userFolder, CoverService::DO_NOT_CROP_OR_SCALE);
171
			if ($cover !== null) {
172
				return new FileResponse($cover);
173
			} else {
174
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
175
			}
176
		} else {
177
			$coverAndHash = $this->coverService->getCoverAndHash($entity, $userId, $userFolder);
178
179
			if ($coverAndHash['hash'] !== null && $this->userId !== null) {
180
				// Cover is in cache. Return a redirection response so that the client
181
				// will fetch the content through a cacheable route.
182
				// The redirection is not used in case this is a call from the Firefox mediaSession API with not
183
				// logged in user.
184
				$link = $this->urlGenerator->linkToRoute('music.coverApi.cachedCover', ['hash' => $coverAndHash['hash']]);
185
				return new RedirectResponse($link);
186
			} elseif ($coverAndHash['data'] !== null) {
187
				return new FileResponse($coverAndHash['data']);
188
			} else {
189
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
190
			}
191
		}
192
	}
193
}
194