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;
9use App\Libraries\CurrentStats;
10use App\Libraries\MenuContent;
11use App\Libraries\Search\AllSearch;
12use App\Libraries\Search\QuickSearch;
13use App\Models\BeatmapDownload;
14use App\Models\Beatmapset;
15use App\Models\Forum\Post;
16use App\Models\NewsPost;
17use App\Models\UserDonation;
18use App\Transformers\MenuImageTransformer;
19use App\Transformers\UserCompactTransformer;
20use Auth;
21use Jenssegers\Agent\Agent;
22use Request;
23
24/**
25 * @group Home
26 */
27class HomeController extends Controller
28{
29 public function __construct()
30 {
31 $this->middleware('auth', [
32 'only' => [
33 'downloadQuotaCheck',
34 'quickSearch',
35 ],
36 ]);
37
38 $this->middleware('require-scopes:public', ['only' => 'search']);
39
40 parent::__construct();
41 }
42
43 public function bbcodePreview()
44 {
45 $post = new Post(['post_text' => Request::input('text')]);
46
47 return $post->bodyHTML();
48 }
49
50 /**
51 * @group Undocumented
52 */
53 public function downloadQuotaCheck()
54 {
55 return [
56 'quota_used' => BeatmapDownload::where('user_id', Auth::user()->user_id)->count(),
57 ];
58 }
59
60 public function getDownload()
61 {
62 $lazerPlatformNames = [
63 'android' => osu_trans('home.download.os_version_or_later', ['os_version' => 'Android 5']),
64 'ios' => osu_trans('home.download.os_version_or_later', ['os_version' => 'iOS 13.4']),
65 'linux_x64' => 'Linux (x64)',
66 'macos_as' => osu_trans('home.download.os_version_or_later', ['os_version' => 'macOS 10.15']).' (Apple Silicon)',
67 'windows_x64' => osu_trans('home.download.os_version_or_later', ['os_version' => 'Windows 8.1']).' (x64)',
68 ];
69
70 $agent = new Agent(Request::server());
71
72 $platform = match (true) {
73 // Try matching most likely platform first
74 $agent->is('Windows') => 'windows_x64',
75 // iPadOS detection apparently doesn't work on newer version
76 // and detected as macOS instead.
77 ($agent->isiOS() || $agent->isiPadOS()) => $platform = 'ios',
78 // FIXME: Figure out a way to differentiate Intel and Apple Silicon.
79 $agent->is('OS X') => 'macos_as',
80 $agent->isAndroidOS() => 'android',
81 $agent->is('Linux') => 'linux_x64',
82 default => 'windows_x64',
83 };
84
85 return ext_view('home.download', [
86 'lazerUrl' => osu_url("lazer_dl.{$platform}"),
87 'lazerPlatformName' => $lazerPlatformNames[$platform],
88 ]);
89 }
90
91 public function index()
92 {
93 $host = Request::getHttpHost();
94 $subdomain = substr($host, 0, strpos($host, '.'));
95
96 if ($subdomain === 'store') {
97 return ujs_redirect(route('store.products.index'));
98 }
99
100 $newsLimit = Auth::check() ? NewsPost::DASHBOARD_LIMIT + 1 : NewsPost::LANDING_LIMIT;
101 $news = NewsPost::default()->limit($newsLimit)->get();
102
103 if (Auth::check()) {
104 $menuImages = json_collection(MenuContent::activeImages(), new MenuImageTransformer());
105 $newBeatmapsets = Beatmapset::latestRanked();
106 $popularBeatmapsets = Beatmapset::popular()->get();
107
108 return ext_view('home.user', compact(
109 'menuImages',
110 'newBeatmapsets',
111 'news',
112 'popularBeatmapsets'
113 ));
114 } else {
115 $news = json_collection($news, 'NewsPost');
116
117 return ext_view('home.landing', ['stats' => new CurrentStats(), 'news' => $news]);
118 }
119 }
120
121 public function messageUser($user)
122 {
123 return ujs_redirect(route('chat.index', ['sendto' => $user]));
124 }
125
126 public function opensearch()
127 {
128 return ext_view('home.opensearch', null, 'opensearch')->header('Cache-Control', 'max-age=86400');
129 }
130
131 public function quickSearch()
132 {
133 $quickSearch = new QuickSearch(Request::all(), ['user' => auth()->user()]);
134 $searches = $quickSearch->searches();
135
136 $result = [];
137
138 if ($quickSearch->hasQuery()) {
139 foreach ($searches as $mode => $search) {
140 if ($search === null) {
141 continue;
142 }
143 $result[$mode]['total'] = $search->count();
144 }
145
146 $result['user']['users'] = json_collection(
147 $searches['user']->data(),
148 new UserCompactTransformer(),
149 [...UserCompactTransformer::CARD_INCLUDES, 'support_level'],
150 );
151 $result['beatmapset']['beatmapsets'] = json_collection($searches['beatmapset']->data(), 'Beatmapset', ['beatmaps']);
152 }
153
154 return $result;
155 }
156
157 /**
158 * Search
159 *
160 * Searches users and wiki pages.
161 *
162 * ---
163 *
164 * ### Response Format
165 *
166 * Field | Type | Description
167 * --------- | -------------------------- | -----------
168 * user | SearchResult<User>? | For `all` or `user` mode. Only first 100 results are accessible
169 * wiki_page | SearchResult<WikiPage>? | For `all` or `wiki_page` mode
170 *
171 * #### SearchResult<T>
172 *
173 * Field | Type | Description
174 * ----- | ------- | -----------
175 * data | T[] | |
176 * total | integer | |
177 *
178 * @queryParam mode string Either `all`, `user`, or `wiki_page`. Default is `all`. Example: all
179 * @queryParam query Search keyword. Example: hello
180 * @queryParam page Search result page. Ignored for mode `all`. Example: 1
181 */
182 public function search()
183 {
184 $currentUser = Auth::user();
185 $allSearch = new AllSearch(Request::all(), ['user' => $currentUser]);
186
187 if ($allSearch->getMode() === 'beatmapset') {
188 return ujs_redirect(route('beatmapsets.index', ['q' => $allSearch->getRawQuery()]));
189 }
190
191 $isSearchPage = true;
192
193 if (is_api_request()) {
194 return response()->json($allSearch->toJson());
195 }
196
197 $fields = $currentUser?->isModerator() ?? false ? [] : ['includeDeleted' => null];
198
199 return ext_view('home.search', compact('allSearch', 'fields', 'isSearchPage'));
200 }
201
202 public function setLocale()
203 {
204 $newLocale = get_valid_locale(Request::input('locale')) ?? $GLOBALS['cfg']['app']['fallback_locale'];
205 App::setLocale($newLocale);
206
207 if (Auth::check()) {
208 Auth::user()->update([
209 'user_lang' => $newLocale,
210 ]);
211 }
212
213 return ext_view('layout.ujs_full_reload', [], 'js')
214 ->withCookie(cookie()->forever('locale', $newLocale));
215 }
216
217 public function supportTheGame()
218 {
219 $user = auth()->user();
220
221 if ($user !== null) {
222 // current status
223 $expiration = $user->osu_subscriptionexpiry?->addDays(1);
224 $current = $expiration?->isFuture() ?? false;
225
226 // purchased
227 $tagPurchases = $user->supporterTagPurchases;
228 $dollars = $tagPurchases->sum('amount');
229 $cancelledTags = $tagPurchases->where('cancel', true)->count() * 2; // 1 for purchase transaction and 1 for cancel transaction
230 $tags = $tagPurchases->count() - $cancelledTags;
231
232 // gifted
233 $gifted = $tagPurchases->where('target_user_id', '<>', $user->user_id);
234 $giftedDollars = $gifted->sum('amount');
235 $canceledGifts = $gifted->where('cancel', true)->count() * 2; // 1 for purchase transaction and 1 for cancel transaction
236 $giftedTags = $gifted->count() - $canceledGifts;
237
238 $supporterStatus = [
239 // current status
240 'current' => $current,
241 'expiration' => $expiration,
242 // purchased
243 'dollars' => currency($dollars, 2, false),
244 'tags' => i18n_number_format($tags),
245 // gifted
246 'giftedDollars' => currency($giftedDollars, 2, false),
247 'giftedTags' => i18n_number_format($giftedTags),
248 ];
249
250 if ($current) {
251 $lastTagPurchaseDate = UserDonation::where('target_user_id', $user->user_id)
252 ->orderBy('timestamp', 'desc')
253 ->pluck('timestamp')
254 ->first();
255
256 $lastTagPurchaseDate ??= $expiration->copy()->subMonths(1);
257
258 $total = max(1, $expiration->diffInDays($lastTagPurchaseDate));
259 $used = $lastTagPurchaseDate->diffInDays();
260
261 $supporterStatus['remainingPercent'] = 100 - round($used / $total * 100, 2);
262 }
263 }
264
265 $pageLayout = [
266 // why support
267 'support-reasons' => [
268 'type' => 'group',
269 'section' => 'why-support',
270 'items' => [
271 'team' => [
272 'icons' => ['fas fa-users'],
273 ],
274 'infra' => [
275 'icons' => ['fas fa-server'],
276 ],
277 'featured-artists' => [
278 'icons' => ['fas fa-user-astronaut'],
279 'link' => route('artists.index'),
280 ],
281 'ads' => [
282 'icons' => ['fas fa-ad', 'fas fa-slash'],
283 ],
284 'tournaments' => [
285 'icons' => ['fas fa-trophy'],
286 'link' => route('tournaments.index'),
287 ],
288 'bounty-program' => [
289 'icons' => ['fas fa-child'],
290 'link' => osu_url('bounty-form'),
291 ],
292 ],
293 ],
294
295 // supporter perks
296
297 // There are 5 perk rendering types: image, image-flipped, hero, group and image-group.
298 // image, image-flipped, hero each show an individual perk (with image) while group and image-group show groups of perks (the latter with images)
299 'perks' => [
300 [
301 'type' => 'image',
302 'name' => 'osu_direct',
303 'icons' => ['fas fa-search'],
304 ],
305 [
306 'type' => 'image_group',
307 'items' => [
308 'friend_ranking' => [
309 'icons' => ['fas fa-list-alt'],
310 ],
311 'country_ranking' => [
312 'icons' => ['fas fa-globe-asia'],
313 ],
314 'mod_filtering' => [
315 'icons' => ['fas fa-tasks'],
316 ],
317 ],
318 ],
319 [
320 'type' => 'image',
321 'variant' => 'flipped',
322 'name' => 'beatmap_filters',
323 'icons' => ['fas fa-filter'],
324 ],
325 [
326 'type' => 'group',
327 'items' => [
328 'auto_downloads' => [
329 'icons' => ['fas fa-download'],
330 ],
331 'more_beatmaps' => [
332 'icons' => ['fas fa-file-upload'],
333 'translation_options' => [
334 'base' => $GLOBALS['cfg']['osu']['beatmapset']['upload_allowed'],
335 'bonus' => $GLOBALS['cfg']['osu']['beatmapset']['upload_bonus_per_ranked'],
336 'bonus_max' => $GLOBALS['cfg']['osu']['beatmapset']['upload_bonus_per_ranked_max'],
337 'supporter_base' => $GLOBALS['cfg']['osu']['beatmapset']['upload_allowed_supporter'],
338 'supporter_bonus' => $GLOBALS['cfg']['osu']['beatmapset']['upload_bonus_per_ranked_supporter'],
339 'supporter_bonus_max' => $GLOBALS['cfg']['osu']['beatmapset']['upload_bonus_per_ranked_max_supporter'],
340 ],
341 ],
342 'early_access' => [
343 'icons' => ['fas fa-flask'],
344 ],
345 ],
346 ],
347 [
348 'type' => 'hero',
349 'name' => 'customisation',
350 'icons' => ['fas fa-image'],
351 ],
352 [
353 'type' => 'group',
354 'items' => [
355 'more_favourites' => [
356 'icons' => ['fas fa-star'],
357 'translation_options' => [
358 'normally' => $GLOBALS['cfg']['osu']['beatmapset']['favourite_limit'],
359 'supporter' => $GLOBALS['cfg']['osu']['beatmapset']['favourite_limit_supporter'],
360 ],
361 ],
362 'more_friends' => [
363 'icons' => ['fas fa-user-friends'],
364 'translation_options' => [
365 'normally' => $GLOBALS['cfg']['osu']['user']['max_friends'],
366 'supporter' => $GLOBALS['cfg']['osu']['user']['max_friends_supporter'],
367 ],
368 ],
369 'friend_filtering' => [
370 'icons' => ['fas fa-medal'],
371 ],
372 ],
373 ],
374 [
375 'type' => 'image_group',
376 'items' => [
377 'yellow_fellow' => [
378 'icons' => ['fas fa-fire'],
379 ],
380 'speedy_downloads' => [
381 'icons' => ['fas fa-tachometer-alt'],
382 ],
383 'change_username' => [
384 'icons' => ['fas fa-magic'],
385 ],
386 'skinnables' => [
387 'icons' => ['fas fa-paint-brush'],
388 ],
389 ],
390 ],
391 ],
392 ];
393
394 return ext_view('home.support-the-game', [
395 'supporterStatus' => $supporterStatus ?? [],
396 'data' => $pageLayout,
397 ]);
398 }
399
400 public function testflight()
401 {
402 return ext_view('home.testflight');
403 }
404}