1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Http\Controllers;
7
8use App\Exceptions\Handler as ExceptionsHandler;
9use App\Jobs\BeatmapsetDelete;
10use App\Libraries\BeatmapsetDiscussion\Review;
11use App\Libraries\CommentBundle;
12use App\Libraries\Search\BeatmapsetSearchCached;
13use App\Libraries\Search\BeatmapsetSearchRequestParams;
14use App\Models\Beatmap;
15use App\Models\BeatmapDownload;
16use App\Models\BeatmapMirror;
17use App\Models\Beatmapset;
18use App\Models\BeatmapsetEvent;
19use App\Models\BeatmapsetWatch;
20use App\Models\Genre;
21use App\Models\Language;
22use App\Transformers\BeatmapsetTransformer;
23use Auth;
24use Carbon\Carbon;
25use DB;
26use Request;
27
28/**
29 * @group Beatmapsets
30 */
31class BeatmapsetsController extends Controller
32{
33 public function __construct()
34 {
35 parent::__construct();
36
37 $this->middleware('require-scopes:public', ['only' => ['lookup', 'search', 'show']]);
38 }
39
40 public function destroy($id)
41 {
42 $beatmapset = Beatmapset::findOrFail($id);
43
44 priv_check('BeatmapsetDelete', $beatmapset)->ensureCan();
45
46 (new BeatmapsetDelete($beatmapset, Auth::user()))->handle();
47 }
48
49 public function index()
50 {
51 $canAdvancedSearch = priv_check('BeatmapsetAdvancedSearch')->can();
52 // only cache if guest user and guest advanced search is disabled
53 $beatmapsetsJsonString = !Auth::check() && !$canAdvancedSearch
54 ? cache_remember_mutexed(
55 'beatmapsets_guest_str',
56 600,
57 '{}',
58 fn () => json_encode($this->getSearchResponse([])['content'])
59 ) : json_encode($this->getSearchResponse()['content']);
60
61 return ext_view('beatmapsets.index', [
62 'beatmapsetsJsonString' => $beatmapsetsJsonString,
63 'canAdvancedSearch' => $canAdvancedSearch,
64 ]);
65 }
66
67 public function lookup()
68 {
69 $beatmapId = get_int(request('beatmap_id'));
70
71 if ($beatmapId === null) {
72 abort(404);
73 }
74
75 $beatmap = Beatmap::findOrFail($beatmapId);
76
77 return $this->show($beatmap->beatmapset_id);
78 }
79
80 public function show($id)
81 {
82 $beatmapset = (
83 priv_check('BeatmapsetShowDeleted')->can()
84 ? Beatmapset::withTrashed()->whereHas('allBeatmaps')
85 : Beatmapset::whereHas('beatmaps')
86 )->findOrFail($id);
87
88 $set = $this->showJson($beatmapset);
89
90 if (is_api_request()) {
91 return $set;
92 } else {
93 $commentBundle = CommentBundle::forEmbed($beatmapset);
94
95 if (priv_check('BeatmapsetMetadataEdit', $beatmapset)->can()) {
96 $genres = Genre::listing();
97 $languages = Language::listing();
98 } else {
99 $genres = [];
100 $languages = [];
101 }
102
103 $noindex = !$beatmapset->esShouldIndex();
104
105 set_opengraph($beatmapset);
106
107 return ext_view('beatmapsets.show', compact(
108 'beatmapset',
109 'commentBundle',
110 'genres',
111 'languages',
112 'noindex',
113 'set'
114 ));
115 }
116 }
117
118 /**
119 * TODO: documentation
120 *
121 * @usesCursor
122 */
123 public function search()
124 {
125 $response = $this->getSearchResponse();
126
127 return response($response['content'], $response['status']);
128 }
129
130 public function discussion($id)
131 {
132 $beatmapset = Beatmapset::findOrFail($id);
133
134 $initialData = [
135 'beatmapset' => $beatmapset->defaultDiscussionJson(),
136 'reviews_config' => Review::config(),
137 ];
138
139 BeatmapsetWatch::markRead($beatmapset, Auth::user());
140
141 if (is_json_request()) {
142 return $initialData;
143 } else {
144 return ext_view('beatmapsets.discussion', compact('beatmapset', 'initialData'));
145 }
146 }
147
148 public function discussionLastUpdate($id)
149 {
150 return response(['last_update' => Beatmapset::findOrFail($id)->lastDiscussionTime()]);
151 }
152
153 public function discussionUnlock($id)
154 {
155 priv_check('BeatmapsetDiscussionLock')->ensureCan();
156
157 $beatmapset = Beatmapset::findOrFail($id);
158 $beatmapset->discussionUnlock(Auth::user());
159
160 return $beatmapset->defaultDiscussionJson();
161 }
162
163 public function discussionLock($id)
164 {
165 priv_check('BeatmapsetDiscussionLock')->ensureCan();
166
167 $beatmapset = Beatmapset::findOrFail($id);
168 $beatmapset->discussionLock(Auth::user(), request('reason'));
169
170 return $beatmapset->defaultDiscussionJson();
171 }
172
173 public function download($id)
174 {
175 if (!is_api_request() && !from_app_url()) {
176 return ujs_redirect(route('beatmapsets.show', ['beatmapset' => rawurlencode($id)]));
177 }
178
179 $beatmapset = Beatmapset::findOrFail($id);
180
181 if ($beatmapset->download_disabled) {
182 abort(404);
183 }
184
185 priv_check('BeatmapsetDownload', $beatmapset)->ensureCan();
186
187 $user = Auth::user();
188 $userId = $user->getKey();
189 $recentlyDownloaded = BeatmapDownload::where('user_id', $userId)
190 ->where('timestamp', '>', Carbon::now()->subHours()->getTimestamp())
191 ->count();
192
193 if ($recentlyDownloaded > $user->beatmapsetDownloadAllowance()) {
194 abort(429, osu_trans('beatmapsets.download.limit_exceeded'));
195 }
196
197 $noVideo = get_bool(Request::input('noVideo', false));
198 $mirror = BeatmapMirror::getRandomForRegion(request_country())
199 ?? BeatmapMirror::getDefault()
200 ?? abort(503, osu_trans('beatmapsets.download.no_mirrors'));
201
202 BeatmapDownload::create([
203 'user_id' => $userId,
204 'timestamp' => time(),
205 'beatmapset_id' => $beatmapset->beatmapset_id,
206 'fulfilled' => 1,
207 'mirror_id' => $mirror->mirror_id,
208 ]);
209
210 return redirect($mirror->generateURL($beatmapset, $noVideo));
211 }
212
213 public function nominate($id)
214 {
215 $beatmapset = Beatmapset::findOrFail($id);
216 $params = get_params(request()->all(), null, ['playmodes:string[]']);
217
218 priv_check('BeatmapsetNominate', $beatmapset)->ensureCan();
219
220 $beatmapset->nominate(Auth::user(), $params['playmodes'] ?? []);
221
222 BeatmapsetWatch::markRead($beatmapset, Auth::user());
223
224 return $beatmapset->defaultDiscussionJson();
225 }
226
227 public function love($id)
228 {
229 $beatmapset = Beatmapset::findOrFail($id);
230
231 $params = get_params(request()->all(), null, ['beatmap_ids:int[]'], ['null_missing' => true]);
232
233 priv_check('BeatmapsetLove')->ensureCan();
234
235 $nomination = $beatmapset->love(Auth::user(), $params['beatmap_ids']);
236 if (!$nomination['result']) {
237 return error_popup($nomination['message']);
238 }
239
240 BeatmapsetWatch::markRead($beatmapset, Auth::user());
241
242 return $beatmapset->defaultDiscussionJson();
243 }
244
245 public function removeFromLoved($id)
246 {
247 $beatmapset = Beatmapset::findOrFail($id);
248
249 priv_check('BeatmapsetRemoveFromLoved')->ensureCan();
250
251 $result = $beatmapset->removeFromLoved(Auth::user(), request('reason'));
252 if (!$result['result']) {
253 return error_popup($result['message']);
254 }
255
256 BeatmapsetWatch::markRead($beatmapset, Auth::user());
257
258 return $beatmapset->defaultDiscussionJson();
259 }
260
261 public function update($id)
262 {
263 $beatmapset = Beatmapset::findOrFail($id);
264 $params = request()->all();
265
266 if (isset($params['description']) && is_string($params['description'])) {
267 priv_check('BeatmapsetDescriptionEdit', $beatmapset)->ensureCan();
268
269 $description = $params['description'];
270
271 if (!$beatmapset->updateDescription($description, Auth::user())) {
272 abort(422, 'failed updating description');
273 }
274
275 $beatmapset->refresh();
276 }
277
278 $metadataParams = get_params($params, 'beatmapset', [
279 'genre_id:int',
280 'language_id:int',
281 'nsfw:bool',
282 ]);
283
284 if (count($metadataParams) > 0) {
285 priv_check('BeatmapsetMetadataEdit', $beatmapset)->ensureCan();
286 }
287
288 $updateParams = [
289 ...$metadataParams,
290 ...get_params($params, 'beatmapset', [
291 'offset:int',
292 'tags:string',
293 ]),
294 ];
295
296 if (array_key_exists('offset', $updateParams)) {
297 priv_check('BeatmapsetOffsetEdit')->ensureCan();
298 }
299
300 if (array_key_exists('tags', $updateParams)) {
301 priv_check('BeatmapsetTagsEdit')->ensureCan();
302 }
303
304 if (count($updateParams) > 0) {
305 DB::transaction(function () use ($beatmapset, $updateParams) {
306 $oldGenreId = $beatmapset->genre_id;
307 $oldLanguageId = $beatmapset->language_id;
308 $oldNsfw = $beatmapset->nsfw;
309 $oldOffset = $beatmapset->offset;
310 $oldTags = $beatmapset->tags;
311 $user = auth()->user();
312
313 $beatmapset->fill($updateParams)->saveOrExplode();
314
315 if ($oldGenreId !== $beatmapset->genre_id) {
316 BeatmapsetEvent::log(BeatmapsetEvent::GENRE_EDIT, $user, $beatmapset, [
317 'old' => Genre::find($oldGenreId)->name,
318 'new' => $beatmapset->genre->name,
319 ])->saveOrExplode();
320 }
321
322 if ($oldLanguageId !== $beatmapset->language_id) {
323 BeatmapsetEvent::log(BeatmapsetEvent::LANGUAGE_EDIT, $user, $beatmapset, [
324 'old' => Language::find($oldLanguageId)->name,
325 'new' => $beatmapset->language->name,
326 ])->saveOrExplode();
327 }
328
329 if ($oldNsfw !== $beatmapset->nsfw) {
330 BeatmapsetEvent::log(BeatmapsetEvent::NSFW_TOGGLE, $user, $beatmapset, [
331 'old' => $oldNsfw,
332 'new' => $beatmapset->nsfw,
333 ])->saveOrExplode();
334 }
335
336 if ($oldOffset !== $beatmapset->offset) {
337 BeatmapsetEvent::log(BeatmapsetEvent::OFFSET_EDIT, $user, $beatmapset, [
338 'old' => $oldOffset,
339 'new' => $beatmapset->offset,
340 ])->saveOrExplode();
341 }
342
343 if ($oldTags !== $beatmapset->tags) {
344 BeatmapsetEvent::log(BeatmapsetEvent::TAGS_EDIT, $user, $beatmapset, [
345 'old' => $oldTags,
346 'new' => $beatmapset->tags,
347 ])->saveOrExplode();
348 }
349 });
350 }
351
352 return $this->showJson($beatmapset);
353 }
354
355 private function getSearchResponse(?array $params = null)
356 {
357 $params = new BeatmapsetSearchRequestParams($params ?? request()->all(), auth()->user());
358 $search = (new BeatmapsetSearchCached($params));
359
360 $records = datadog_timing(function () use ($search) {
361 return $search->records();
362 }, $GLOBALS['cfg']['datadog-helper']['prefix_web'].'.search', ['type' => 'beatmapset']);
363
364 $error = $search->getError();
365
366 return [
367 'content' => array_merge([
368 'beatmapsets' => json_collection(
369 $records,
370 new BeatmapsetTransformer(),
371 ['beatmaps.max_combo', 'pack_tags']
372 ),
373 'search' => [
374 'sort' => $search->getParams()->getSort(),
375 ],
376 'recommended_difficulty' => $params->getRecommendedDifficulty(),
377 'error' => search_error_message($error),
378 'total' => $search->count(),
379 ], cursor_for_response($search->getSortCursor())),
380 'status' => $error === null ? 200 : ExceptionsHandler::statusCode($error),
381 ];
382 }
383
384 private function showJson($beatmapset)
385 {
386 $beatmapRelation = $beatmapset->trashed()
387 ? 'allBeatmaps'
388 : 'beatmaps';
389 $beatmapset->load([
390 "{$beatmapRelation}.baseDifficultyRatings",
391 "{$beatmapRelation}.baseMaxCombo",
392 "{$beatmapRelation}.failtimes",
393 "{$beatmapRelation}.beatmapOwners.user",
394 'genre',
395 'language',
396 'user',
397 ]);
398
399 $transformer = new BeatmapsetTransformer();
400 $transformer->relatedUsersType = 'show';
401
402 return json_item($beatmapset, $transformer, [
403 'beatmaps',
404 'beatmaps.failtimes',
405 'beatmaps.max_combo',
406 'beatmaps.owners',
407 'beatmaps.top_tag_ids',
408 'converts',
409 'converts.failtimes',
410 'converts.owners',
411 'current_nominations',
412 'current_user_attributes',
413 'description',
414 'genre',
415 'language',
416 'pack_tags',
417 'ratings',
418 'recent_favourites',
419 'related_tags',
420 'related_users',
421 'user',
422 ]);
423 }
424}