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}