the browser-facing portion of osu!
at master 15 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\Libraries\CommentBundle; 9use App\Libraries\GithubImporter; 10use App\Models\Build; 11use App\Models\BuildPropagationHistory; 12use App\Models\UpdateStream; 13use Cache; 14 15/** 16 * @group Changelog 17 */ 18class ChangelogController extends Controller 19{ 20 private $updateStreams = null; 21 22 private static function changelogEntryMessageIncludes(?array $formats): array 23 { 24 static $validFormats = [ 25 'html' => 'changelog_entries.message_html', 26 'markdown' => 'changelog_entries.message', 27 ]; 28 29 if (is_api_request()) { 30 $ret = []; 31 foreach ($formats ?? [] as $format) { 32 if (array_key_exists($format, $validFormats)) { 33 $ret[$format] ??= $validFormats[$format]; 34 } 35 } 36 37 return count($ret) === 0 38 ? [$validFormats['html'], $validFormats['markdown']] 39 : array_values($ret); 40 } else { 41 return [$validFormats['html']]; 42 } 43 } 44 45 /** 46 * Get Changelog Listing 47 * 48 * Returns a listing of update streams, builds, and changelog entries. 49 * 50 * --- 51 * 52 * ### Response Format 53 * 54 * Field | Type | Notes 55 * --------------|---------------------------------|------ 56 * builds | [Build](#build)[] | Includes `changelog_entries`, `changelog_entries.github_user`, and changelog entry message in requested formats. 57 * search.from | string? | `from` input. 58 * search.limit | integer | Always `21`. 59 * search.max_id | integer? | `max_id` input. 60 * search.stream | string? | `stream` input. 61 * search.to | string? | `to` input. 62 * streams | [UpdateStream](#updatestream)[] | Always contains all available streams. Includes `latest_build` and `user_count`. 63 * 64 * @queryParam from string Minimum build version. No-example 65 * @queryParam max_id integer Maximum build ID. No-example 66 * @queryParam stream string Stream name to return builds from. No-example 67 * @queryParam to string Maximum build version. No-example 68 * @queryParam message_formats[] string `html`, `markdown`. Default to both. 69 * @response { 70 * "streams": [ 71 * { 72 * "id": 5, 73 * "name": "stable40", 74 * "display_name": "Stable", 75 * "is_featured": true, 76 * "latest_build": { 77 * "id": 5778, 78 * "version": "20210520.2", 79 * "display_version": "20210520.2", 80 * "users": 23683, 81 * "created_at": "2021-05-20T14:28:04+00:00", 82 * "update_stream": { 83 * "id": 5, 84 * "name": "stable40", 85 * "display_name": "Stable", 86 * "is_featured": true 87 * } 88 * }, 89 * "user_count": 23965 90 * }, 91 * // ... 92 * ], 93 * "builds": [ 94 * { 95 * "id": 5823, 96 * "version": "2021.619.1", 97 * "display_version": "2021.619.1", 98 * "users": 0, 99 * "created_at": "2021-06-19T08:30:45+00:00", 100 * "update_stream": { 101 * "id": 7, 102 * "name": "lazer", 103 * "display_name": "Lazer", 104 * "is_featured": false 105 * }, 106 * "changelog_entries": [ 107 * { 108 * "id": 12925, 109 * "repository": "ppy/osu", 110 * "github_pull_request_id": 13572, 111 * "github_url": "https://github.com/ppy/osu/pull/13572", 112 * "url": null, 113 * "type": "fix", 114 * "category": "Reliability", 115 * "title": "Fix game crashes due to attempting localisation load for unsupported locales", 116 * "message_html": null, 117 * "major": true, 118 * "created_at": "2021-06-19T08:09:39+00:00", 119 * "github_user": { 120 * "display_name": "bdach", 121 * "github_url": "https://github.com/bdach", 122 * "github_username": "bdach", 123 * "id": 218, 124 * "osu_username": null, 125 * "user_id": null, 126 * "user_url": null 127 * } 128 * } 129 * ] 130 * }, 131 * // ... 132 * ], 133 * "search": { 134 * "stream": null, 135 * "from": null, 136 * "to": null, 137 * "max_id": null, 138 * "limit": 21 139 * } 140 * } 141 */ 142 public function index() 143 { 144 $updateStreams = $this->getUpdateStreams(); 145 146 $params = get_params(request()->all(), null, [ 147 'message_formats:string[]', 148 'from', 149 'max_id:int', 150 'stream', 151 'to', 152 ], ['null_missing' => true]); 153 154 $search = [ 155 'stream' => $params['stream'], 156 'from' => $params['from'], 157 'to' => $params['to'], 158 'max_id' => $params['max_id'], 159 'limit' => 21, 160 ]; 161 162 $builds = Build::search($search) 163 ->default() 164 ->with([ 165 'updateStream', 166 'defaultChangelogs.user', 167 'defaultChangelogEntries.githubUser.user', 168 'defaultChangelogEntries.repository', 169 ])->orderBy('build_id', 'DESC') 170 ->get(); 171 172 if (!is_json_request() && count($builds) === 1 && request('no_redirect') !== '1') { 173 return ujs_redirect(build_url($builds[0])); 174 } 175 176 $buildJsonIncludes = [ 177 'changelog_entries', 178 'changelog_entries.github_user', 179 ...static::changelogEntryMessageIncludes($params['message_formats']), 180 ]; 181 $buildsJson = json_collection($builds, 'Build', $buildJsonIncludes); 182 183 $indexJson = [ 184 'streams' => $updateStreams, 185 'builds' => $buildsJson, 186 'search' => $search, 187 ]; 188 189 if (is_json_request()) { 190 return $indexJson; 191 } else { 192 $chartConfig = Cache::remember( 193 'chart_config_global', 194 $GLOBALS['cfg']['osu']['changelog']['build_history_interval'], 195 function () { 196 return $this->chartConfig(null); 197 } 198 ); 199 200 return ext_view('changelog.index', compact('chartConfig', 'indexJson', 'updateStreams')); 201 } 202 } 203 204 public function github() 205 { 206 $token = $GLOBALS['cfg']['osu']['changelog']['github_token']; 207 208 $signatureHeader = explode('=', request()->header('X-Hub-Signature') ?? ''); 209 210 if (count($signatureHeader) !== 2) { 211 abort(422, 'invalid signature header'); 212 } 213 214 [$algo, $signature] = $signatureHeader; 215 216 if (!in_array($algo, hash_hmac_algos(), true)) { 217 abort(422, 'unknown signature algorithm'); 218 } 219 220 $hash = hash_hmac($algo, request()->getContent(), $token); 221 222 if (!hash_equals((string) $hash, (string) $signature)) { 223 abort(403); 224 } 225 226 (new GithubImporter([ 227 'eventType' => request()->header('X-GitHub-Event'), 228 'data' => request()->json()->all(), 229 ]))->import(); 230 231 return []; 232 } 233 234 /** 235 * Lookup Changelog Build 236 * 237 * Returns details of the specified build. 238 * 239 * --- 240 * 241 * ### Response Format 242 * 243 * See [Get Changelog Build](#get-changelog-build). 244 * 245 * @urlParam changelog string required Build version, update stream name, or build ID. Example: 20210520.2 246 * @queryParam key string Unset to query by build version or stream name, or `id` to query by build ID. No-example 247 * @queryParam message_formats[] string `html`, `markdown`. Default to both. 248 * @response See "Get Changelog Build" response. 249 */ 250 public function show($version) 251 { 252 if (request('key') === 'id') { 253 $build = Build::default()->findOrFail($version); 254 } else { 255 // Search by exact version first. 256 $build = Build::default()->where('version', '=', $version)->first(); 257 } 258 259 // Failing that, check if $version is actually a stream name. 260 if ($build === null) { 261 $stream = UpdateStream::where('name', '=', $version)->first(); 262 263 if ($stream !== null) { 264 $build = $stream->builds()->default()->orderBy('build_id', 'desc')->first(); 265 } 266 } 267 268 // When there's no build found, strip everything but numbers and dots then search again. 269 // 404 if still nothing found. 270 if ($build === null) { 271 $normalizedVersion = preg_replace('#[^0-9.]#', '', $version); 272 273 $build = Build::default()->where('version', '=', $normalizedVersion)->firstOrFail(); 274 } 275 276 if (is_json_request()) { 277 return $this->buildJson($build); 278 } 279 280 return ujs_redirect(build_url($build)); 281 } 282 283 /** 284 * Get Changelog Build 285 * 286 * Returns details of the specified build. 287 * 288 * --- 289 * 290 * ### Response Format 291 * 292 * A [Build](#build) with `changelog_entries`, `changelog_entries.github_user`, and `versions` included. 293 * 294 * @urlParam stream string required Update stream name. Example: stable40 295 * @urlParam build string required Build version. Example: 20210520.2 296 * @response { 297 * "id": 5778, 298 * "version": "20210520.2", 299 * "display_version": "20210520.2", 300 * "users": 22093, 301 * "created_at": "2021-05-20T14:28:04+00:00", 302 * "update_stream": { 303 * "id": 5, 304 * "name": "stable40", 305 * "display_name": "Stable", 306 * "is_featured": true 307 * }, 308 * "changelog_entries": [ 309 * { 310 * "id": null, 311 * "repository": null, 312 * "github_pull_request_id": null, 313 * "github_url": null, 314 * "url": "https://osu.ppy.sh/home/news/2021-05-20-spring-fanart-contest-results", 315 * "type": "fix", 316 * "category": "Misc", 317 * "title": "Spring is here!", 318 * "message_html": "<div class='changelog-md'><p class=\"changelog-md__paragraph\">New seasonal backgrounds ahoy! Amazing work by the artists.</p>\n</div>", 319 * "major": true, 320 * "created_at": "2021-05-20T10:56:49+00:00", 321 * "github_user": { 322 * "display_name": "peppy", 323 * "github_url": null, 324 * "github_username": null, 325 * "id": null, 326 * "osu_username": "peppy", 327 * "user_id": 2, 328 * "user_url": "https://osu.ppy.sh/users/2" 329 * } 330 * } 331 * ], 332 * "versions": { 333 * "previous": { 334 * "id": 5774, 335 * "version": "20210519.3", 336 * "display_version": "20210519.3", 337 * "users": 10, 338 * "created_at": "2021-05-19T11:51:48+00:00", 339 * "update_stream": { 340 * "id": 5, 341 * "name": "stable40", 342 * "display_name": "Stable", 343 * "is_featured": true 344 * } 345 * } 346 * } 347 * } 348 */ 349 public function build($streamName, $version) 350 { 351 352 $stream = UpdateStream::where('name', '=', $streamName)->firstOrFail(); 353 $build = $stream 354 ->builds() 355 ->default() 356 ->where('version', $version) 357 ->with([ 358 'defaultChangelogs.user', 359 'defaultChangelogEntries.githubUser.user', 360 'defaultChangelogEntries.repository', 361 ])->firstOrFail(); 362 $buildJson = $this->buildJson($build); 363 364 if (is_json_request()) { 365 return $buildJson; 366 } 367 368 $updateStreams = $this->getUpdateStreams(); 369 370 $commentBundle = CommentBundle::forEmbed($build); 371 372 $chartConfig = Cache::remember( 373 "chart_config:v2:{$build->updateStream->getKey()}", 374 $GLOBALS['cfg']['osu']['changelog']['build_history_interval'], 375 function () use ($build) { 376 return $this->chartConfig($build->updateStream); 377 } 378 ); 379 380 return ext_view('changelog.build', compact( 381 'build', 382 'buildJson', 383 'chartConfig', 384 'commentBundle', 385 'updateStreams', 386 )); 387 } 388 389 private function buildJson(Build $build): array 390 { 391 return json_item($build, 'Build', [ 392 'changelog_entries', 393 'changelog_entries.github_user', 394 ...static::changelogEntryMessageIncludes(get_arr(request('message_formats'))), 395 'versions', 396 ]); 397 } 398 399 private function getUpdateStreams() 400 { 401 return $this->updateStreams ??= json_collection( 402 UpdateStream::whereHasBuilds() 403 ->orderByField('stream_id', $GLOBALS['cfg']['osu']['changelog']['update_streams']) 404 ->find($GLOBALS['cfg']['osu']['changelog']['update_streams']) 405 ->sortBy(function ($i) { 406 return $i->isFeatured() ? 0 : 1; 407 }), 408 'UpdateStream', 409 ['latest_build', 'user_count'] 410 ); 411 } 412 413 private function chartConfig($stream) 414 { 415 $history = BuildPropagationHistory::changelog(optional($stream)->getKey(), $GLOBALS['cfg']['osu']['changelog']['chart_days'])->get(); 416 417 if ($stream === null) { 418 $chartOrder = array_map(function ($b) { 419 return $b['display_name']; 420 }, $this->getUpdateStreams()); 421 } else { 422 $chartOrder = $this->buildChartOrder($history); 423 $streamName = kebab_case($stream->pretty_name); 424 } 425 426 return [ 427 'build_history' => json_collection($history, 'BuildHistoryChart'), 428 'order' => $chartOrder, 429 'stream_name' => $streamName ?? null, 430 ]; 431 } 432 433 private function buildChartOrder($history) 434 { 435 return $history 436 ->unique('label') 437 ->pluck('label') 438 ->sortByDesc(function ($label) { 439 $parts = explode('.', $label); 440 441 if (count($parts) >= 1 && strlen($parts[0]) >= 8) { 442 $date = substr($parts[0], 0, 8); 443 } elseif (count($parts) >= 2 && strlen($parts[0]) === 4 && strlen($parts[1]) >= 3 && strlen($parts[1]) <= 4) { 444 $date = $parts[0].str_pad($parts[1], 4, '0', STR_PAD_LEFT); 445 } 446 447 return $date ?? null; 448 })->values(); 449 } 450}