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\Enums\Ruleset;
9use App\Models\Beatmap;
10use App\Models\Score\Best\Model as ScoreBest;
11use App\Models\ScoreReplayStats;
12use App\Models\Solo\Score as SoloScore;
13use App\Transformers\ScoreTransformer;
14use App\Transformers\UserCompactTransformer;
15use Illuminate\Auth\AuthenticationException;
16
17class ScoresController extends Controller
18{
19 const REPLAY_DOWNLOAD_COUNT_INTERVAL = 86400; // 1 day
20
21 public function __construct()
22 {
23 parent::__construct();
24
25 $this->middleware('auth', ['except' => [
26 'download',
27 'index',
28 'show',
29 ]]);
30
31 $this->middleware('require-scopes:public');
32 }
33
34 private static function parseIdOrFail(string $id): int
35 {
36 if (ctype_digit($id)) {
37 $ret = (int) $id;
38
39 if ($ret > 0) {
40 return $ret;
41 }
42 }
43
44 abort(404, osu_trans('errors.scores.invalid_id'));
45 }
46
47 public function download($rulesetOrSoloId, $id = null)
48 {
49 $currentUser = \Auth::user();
50 if (!is_api_request() && $currentUser === null) {
51 throw new AuthenticationException('User is not logged in.');
52 }
53
54 $shouldRedirect = !is_api_request() && !from_app_url();
55 if ($id === null) {
56 if ($shouldRedirect) {
57 return ujs_redirect(route('scores.show', ['rulesetOrScore' => $rulesetOrSoloId]));
58 }
59 $soloScore = SoloScore::where('has_replay', true)->findOrFail($rulesetOrSoloId);
60
61 $score = $soloScore->legacyScore() ?? $soloScore;
62 } else {
63 if ($shouldRedirect) {
64 return ujs_redirect(route('scores.show', ['rulesetOrScore' => $rulesetOrSoloId, 'score' => $id]));
65 }
66 // don't limit downloading replays of restricted users for review purpose
67 $score = ScoreBest::getClass($rulesetOrSoloId)
68 ::where('score_id', $id)
69 ->where('replay', true)
70 ->firstOrFail();
71
72 $soloScore = SoloScore::firstWhere(['legacy_score_id' => $score->getKey(), 'ruleset_id' => $score->ruleset_id]);
73 }
74
75 $file = $score->getReplayFile();
76 if ($file === null) {
77 abort(404);
78 }
79
80 if (
81 $currentUser !== null
82 && !$currentUser->isRestricted()
83 && $currentUser->getKey() !== $score->user_id
84 && ($currentUser->token()?->client->password_client ?? false)
85 ) {
86 $countLock = \Cache::lock(
87 "view:score_replay:{$score->getKey()}:{$currentUser->getKey()}",
88 static::REPLAY_DOWNLOAD_COUNT_INTERVAL,
89 );
90
91 if ($countLock->get()) {
92 $score->user->statistics($score->getMode(), true)->increment('replay_popularity');
93
94 $currentMonth = format_month_column(new \DateTime());
95 $score->user->replaysWatchedCounts()
96 ->firstOrCreate(['year_month' => $currentMonth], ['count' => 0])
97 ->incrementInstance('count');
98
99 if ($score instanceof ScoreBest) {
100 $score->replayViewCount()
101 ->firstOrCreate([], ['play_count' => 0])
102 ->incrementInstance('play_count');
103 }
104
105 if ($soloScore !== null) {
106 ScoreReplayStats
107 ::createOrFirst(['score_id' => $soloScore->getKey()], ['user_id' => $soloScore->user_id])
108 ->incrementInstance('watch_count');
109 }
110 }
111 }
112
113 static $responseHeaders = [
114 'Content-Type' => 'application/x-osu-replay',
115 ];
116
117 return response()->streamDownload(function () use ($file) {
118 echo $file;
119 }, $this->makeReplayFilename($score), $responseHeaders);
120 }
121
122 /**
123 * Get Scores
124 *
125 * Returns all passed scores. Up to 1000 scores will be returned in order of oldest to latest.
126 * Most recent scores will be returned if `cursor_string` parameter is not specified.
127 *
128 * Obtaining new scores that arrived after the last request can be done by passing `cursor_string`
129 * parameter from the previous request.
130 *
131 * ---
132 *
133 * ### Response Format
134 *
135 * Field | Type | Notes
136 * ------------- | ----------------------------- | -----
137 * scores | [Score](#score)[] | |
138 * cursor_string | [CursorString](#cursorstring) | Same value as the request will be returned if there's no new scores
139 *
140 * @group Scores
141 *
142 * @queryParam ruleset The [Ruleset](#ruleset) to get scores for.
143 * @queryParam cursor_string Next set of scores
144 */
145 public function index()
146 {
147 $params = \Request::all();
148 $cursor = cursor_from_params($params);
149 $isOldScores = false;
150 if (isset($cursor['id']) && ($idFromCursor = get_int($cursor['id'])) !== null) {
151 $currentMaxId = SoloScore::max('id');
152 $idDistance = $currentMaxId - $idFromCursor;
153 if ($idDistance > $GLOBALS['cfg']['osu']['scores']['index_max_id_distance']) {
154 abort(422, 'cursor is too old');
155 }
156 $isOldScores = $idDistance > 10_000;
157 }
158
159 $rulesetId = null;
160 if (isset($params['ruleset'])) {
161 $rulesetId = Beatmap::modeInt(get_string($params['ruleset']));
162
163 if ($rulesetId === null) {
164 abort(422, 'invalid ruleset parameter');
165 }
166 }
167
168 return \Cache::remember(
169 'score_index:'.($rulesetId ?? '').':'.json_encode($cursor),
170 $isOldScores ? 600 : 5,
171 function () use ($cursor, $isOldScores, $rulesetId) {
172 $cursorHelper = SoloScore::makeDbCursorHelper('old');
173 $scoresQuery = SoloScore::forListing()->limit(1_000);
174 if ($rulesetId !== null) {
175 $scoresQuery->where('ruleset_id', $rulesetId);
176 }
177 if ($cursor === null || $cursorHelper->prepare($cursor) === null) {
178 // fetch the latest scores when no or invalid cursor is specified
179 // and reverse result to match the other query (latest score last)
180 $scores = array_reverse($scoresQuery->orderByDesc('id')->get()->all());
181 } else {
182 $scores = $scoresQuery->cursorSort($cursorHelper, $cursor)->get()->all();
183 }
184
185 if ($isOldScores) {
186 $filteredScores = $scores;
187 } else {
188 $filteredScores = [];
189 foreach ($scores as $score) {
190 // only return up to but not including the earliest unprocessed scores
191 if ($score->isProcessed()) {
192 $filteredScores[] = $score;
193 } else {
194 break;
195 }
196 }
197 }
198
199 return [
200 'scores' => json_collection($filteredScores, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)),
201 // return previous cursor if no result, assuming there's no new scores yet
202 ...cursor_for_response($cursorHelper->next($filteredScores) ?? $cursor),
203 ];
204 },
205 );
206 }
207
208 public function show($rulesetOrSoloId, $legacyId = null)
209 {
210 if ($legacyId === null) {
211 $scoreQuery = SoloScore::whereKey(static::parseIdOrFail($rulesetOrSoloId));
212 } else {
213 $scoreQuery = SoloScore::where([
214 'ruleset_id' => Ruleset::tryFromName($rulesetOrSoloId) ?? abort(404, 'unknown ruleset name'),
215 'legacy_score_id' => static::parseIdOrFail($legacyId),
216 ]);
217 }
218 $score = $scoreQuery->whereHas('beatmap.beatmapset')->visibleUsers()->firstOrFail();
219
220 $userIncludes = array_map(function ($include) {
221 return "user.{$include}";
222 }, UserCompactTransformer::CARD_INCLUDES);
223
224 $scoreJson = json_item($score, new ScoreTransformer(), array_merge([
225 'beatmap.max_combo',
226 'beatmap.user',
227 'beatmap.owners',
228 'beatmapset',
229 'rank_global',
230 ], $userIncludes));
231
232 if (is_json_request()) {
233 return $scoreJson;
234 }
235
236 return ext_view('scores.show', compact('score', 'scoreJson'));
237 }
238
239 private function makeReplayFilename(ScoreBest|SoloScore $score): string
240 {
241 $prefix = $score instanceof SoloScore
242 ? 'solo-replay'
243 : 'replay';
244
245 return "{$prefix}-{$score->getMode()}_{$score->beatmap_id}_{$score->getKey()}.osr";
246 }
247}