the browser-facing portion of osu!
at master 9.1 kB view raw
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}