···189189# ES_CLIENT_CONNECT_TIMEOUT=0.5
190190# ES_SEARCH_TIMEOUT=5s
191191192192-# TOURNAMENT_BANNER_CURRENT_ID=
193193-# Image path/url for tournament supporter banner.
194194-# Will be appended with country abbreviation and `.jpg`. `@2x` version is also required (prepended before `.jpg`).
195195-# Example:
196196-# prefix: https://assets.ppy.sh/tournament-banners/official/twc2018_
197197-# image path: https://assets.ppy.sh/tournament-banners/official/twc2018_JP.jpg
198198-# image path @2x: https://assets.ppy.sh/tournament-banners/official/twc2018_JP@2x.jpg
199199-# TOURNAMENT_BANNER_CURRENT_PREFIX=
200200-201201-# TOURNAMENT_BANNER_PREVIOUS_ID=
202202-# Same as `_CURRENT_PREFIX`.
203203-# TOURNAMENT_BANNER_PREVIOUS_PREFIX=
204204-# Winning country code
205205-# TOURNAMENT_BANNER_PREVIOUS_WINNER_ID=
206206-207192# {prefix}{filename}.png with {filename} the achievement slug as stored in database.
208193# USER_ACHIEVEMENT_ICON_PREFIX=https://assets.ppy.sh/user-achievements/
209194···244229# USER_PROFILE_SCORES_NOTICE=
245230246231# MULTIPLAYER_MAX_ATTEMPTS_LIMIT=128
232232+# MULTIPLAYER_ROOM_CLOSE_GRACE_PERIOD_MINUTES=5
247233248234# NOTIFICATION_QUEUE=notification
249235# NOTIFICATION_REDIS_HOST=127.0.0.1
···186186Docker images need to be occasionally updated to make sure they're running latest version of the packages.
187187188188```
189189+docker compose pull
189190docker compose build --no-cache
190191```
191192
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+namespace App\Models;
99+1010+use Illuminate\Database\Eloquent\Relations\BelongsTo;
1111+1212+class TournamentBanner extends Model
1313+{
1414+ public $incrementing = false;
1515+1616+ protected $casts = [
1717+ 'is_active' => 'boolean',
1818+ ];
1919+ protected $primaryKey = 'tournament_id';
2020+2121+ public function tournament(): BelongsTo
2222+ {
2323+ return $this->belongsTo(Tournament::class, 'tournament_id');
2424+ }
2525+}
+1-1
app/Models/Traits/Scoreable.php
···74747575 public function recalculateRank(): void
7676 {
7777- if (!$this->passed) {
7777+ if (!$this->pass) {
7878 $this->rank = 'F';
79798080 return;
+19-20
app/Models/User.php
···707707708708 // FIXME: this can probably be removed after old site is deactivated
709709 // as there's same check in getter function.
710710- if (present($value) && !starts_with($value, ['http://', 'https://'])) {
710710+ if (present($value) && !is_http($value)) {
711711 $value = "https://{$value}";
712712 }
713713···914914 'orders',
915915 'pivot', // laravel built-in relation when using belongsToMany
916916 'profileBanners',
917917+ 'profileBannersActive',
917918 'profileBeatmapsetsGraveyard',
918919 'profileBeatmapsetsLoved',
919920 'profileBeatmapsetsPending',
···13081309 public function profileBanners()
13091310 {
13101311 return $this->hasMany(ProfileBanner::class);
13121312+ }
13131313+13141314+ public function profileBannersActive(): HasMany
13151315+ {
13161316+ return $this->profileBanners()->active()->with('tournamentBanner')->orderBy('banner_id');
13111317 }
1312131813131319 public function storeAddresses()
···23122318 $this->isValidEmail();
23132319 }
2314232023152315- if ($this->isDirty('country_acronym')) {
23162316- if (present($this->country_acronym)) {
23172317- if (($country = Country::find($this->country_acronym)) !== null) {
23182318- // ensure matching case
23192319- $this->country_acronym = $country->getKey();
23202320- } else {
23212321- $this->validationErrors()->add('country', '.invalid_country');
23222322- }
23232323- } else {
23242324- $this->country_acronym = Country::UNKNOWN;
23212321+ $countryAcronym = $this->country_acronym;
23222322+ if ($countryAcronym === null) {
23232323+ $this->country_acronym = Country::UNKNOWN;
23242324+ } elseif ($this->isDirty('country_acronym') && $countryAcronym !== Country::UNKNOWN) {
23252325+ if (app('countries')->byCode($countryAcronym) === null) {
23262326+ $this->validationErrors()->add('country', '.invalid_country');
23252327 }
23262328 }
23272329···24662468 {
24672469 $value = presence(trim($this->getRawAttribute('user_website')));
2468247024692469- if ($value === null) {
24702470- return null;
24712471- }
24722472-24732473- if (starts_with($value, ['http://', 'https://'])) {
24742474- return $value;
24752475- }
24762476-24772477- return "https://{$value}";
24712471+ return $value === null
24722472+ ? null
24732473+ : (is_http($value)
24742474+ ? $value
24752475+ : "https://{$value}"
24762476+ );
24782477 }
24792478}
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+use Illuminate\Database\Migrations\Migration;
99+use Illuminate\Database\Schema\Blueprint;
1010+use Illuminate\Support\Facades\Schema;
1111+1212+return new class extends Migration
1313+{
1414+ public function up(): void
1515+ {
1616+ Schema::create('tournament_banners', function (Blueprint $table) {
1717+ $table->unsignedMediumInteger('tournament_id')->primary();
1818+ $table->boolean('is_active')->default(false);
1919+ $table->char('winner_country_acronym', 2)->nullable(true);
2020+ $table->string('banner_url_prefix');
2121+ $table->timestampTz('created_at')->useCurrent();
2222+ $table->timestampTz('updated_at')->useCurrent();
2323+ });
2424+ }
2525+2626+ public function down(): void
2727+ {
2828+ Schema::dropIfExists('tournament_banners');
2929+ }
3030+};
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+use Illuminate\Database\Migrations\Migration;
99+use Illuminate\Database\Schema\Blueprint;
1010+use Illuminate\Support\Facades\Schema;
1111+1212+return new class extends Migration
1313+{
1414+ /**
1515+ * Run the migrations.
1616+ */
1717+ public function up(): void
1818+ {
1919+ Schema::table('multiplayer_rooms', function (Blueprint $table) {
2020+ $table->enum('status', ['idle', 'playing'])->default('idle');
2121+ });
2222+ }
2323+2424+ /**
2525+ * Reverse the migrations.
2626+ */
2727+ public function down(): void
2828+ {
2929+ Schema::table('multiplayer_rooms', function (Blueprint $table) {
3030+ $table->dropColumn('status');
3131+ });
3232+ }
3333+};
+37-3
database/mods.json
···287287 }
288288 ],
289289 "IncompatibleMods": [
290290- "BL"
290290+ "BL",
291291+ "BM"
291292 ],
292293 "RequiresConfiguration": false,
293294 "UserPlayable": true,
···10161017 "Description": "The combo count at which the cursor becomes completely hidden"
10171018 }
10181019 ],
10191019- "IncompatibleMods": [],
10201020+ "IncompatibleMods": [
10211021+ "BM"
10221022+ ],
10201023 "RequiresConfiguration": false,
10211024 "UserPlayable": true,
10221025 "ValidForMultiplayer": true,
···12041207 "AlwaysValidForSubmission": false
12051208 },
12061209 {
12101210+ "Acronym": "BM",
12111211+ "Name": "Bloom",
12121212+ "Description": "The cursor blooms into.. a larger cursor!",
12131213+ "Type": "Fun",
12141214+ "Settings": [
12151215+ {
12161216+ "Name": "max_size_combo_count",
12171217+ "Type": "number",
12181218+ "Label": "Max size at combo",
12191219+ "Description": "The combo count at which the cursor reaches its maximum size"
12201220+ },
12211221+ {
12221222+ "Name": "max_cursor_size",
12231223+ "Type": "number",
12241224+ "Label": "Final size multiplier",
12251225+ "Description": "The multiplier applied to cursor size when combo reaches maximum"
12261226+ }
12271227+ ],
12281228+ "IncompatibleMods": [
12291229+ "FL",
12301230+ "NS",
12311231+ "TD"
12321232+ ],
12331233+ "RequiresConfiguration": false,
12341234+ "UserPlayable": true,
12351235+ "ValidForMultiplayer": true,
12361236+ "ValidForMultiplayerAsFreeMod": true,
12371237+ "AlwaysValidForSubmission": false
12381238+ },
12391239+ {
12071240 "Acronym": "TD",
12081241 "Name": "Touch Device",
12091242 "Description": "Automatically applied to plays on devices with a touchscreen.",
···12121245 "IncompatibleMods": [
12131246 "AT",
12141247 "CN",
12151215- "AP"
12481248+ "AP",
12491249+ "BM"
12161250 ],
12171251 "RequiresConfiguration": false,
12181252 "UserPlayable": true,
+2-3
docker-compose.yml
···11x-env: &x-env
22 APP_KEY: "${APP_KEY}"
33- BEATMAPS_DIFFICULTY_CACHE_SERVER_URL: http://beatmap-difficulty-lookup-cache
33+ BEATMAPS_DIFFICULTY_CACHE_SERVER_URL: http://beatmap-difficulty-lookup-cache:8080
44 BROADCAST_DRIVER: redis
55 DB_CONNECTION_STRING: Server=db;Database=osu;Uid=osuweb;
66 DB_HOST: db
···6969 beatmap-difficulty-lookup-cache:
7070 image: pppy/osu-beatmap-difficulty-lookup-cache
7171 ports:
7272- - "${BEATMAPS_DIFFICULTY_CACHE_EXTERNAL_PORT:-127.0.0.1:5001}:80"
7272+ - "${BEATMAPS_DIFFICULTY_CACHE_EXTERNAL_PORT:-127.0.0.1:5001}:8080"
73737474 notification-server:
7575 image: pppy/osu-notification-server
···110110 MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
111111 ports:
112112 - "${MYSQL_EXTERNAL_PORT:-127.0.0.1:3306}:3306"
113113- command: --default-authentication-plugin=mysql_native_password
114113 healthcheck:
115114 # important to use 127.0.0.1 instead of localhost as mysql starts twice.
116115 # the first time it listens on sockets but isn't actually ready
···11// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22// See the LICENCE file in the repository root for full licence text.
3344-.turbolinks-progress-bar {
44+.turbo-progress-bar {
55 background-color: @yellow;
66}
+4-15
resources/js/app-deps.ts
···3344import CurrentUserJson from 'interfaces/current-user-json';
5566+import 'setup-turbo';
77+68// import jquery + plugins
77-import * as $ from 'jquery';
99+import 'setup-jquery';
1010+// imported separately as it requires window jquery (setup by the import above)
811import 'jquery-ujs';
99-import 'bootstrap';
1010-import 'timeago/jquery.timeago.js';
1111-import 'qtip2/dist/jquery.qtip.js';
1212-import 'jquery.scrollto/jquery.scrollTo.js';
1313-import 'jquery-ui/ui/data.js';
1414-import 'jquery-ui/ui/widgets/slider.js';
1515-import 'jquery-ui/ui/widgets/sortable.js';
1616-import 'jquery-ui-touch-punch';
1717-import 'blueimp-file-upload/js/jquery.fileupload.js';
18121913import { patchPluralHandler } from 'lang-overrides';
2014import Lang from 'lang.js';
2115import { configure as mobxConfigure } from 'mobx';
2216import * as moment from 'moment';
2323-import Turbolinks from 'turbolinks';
2417import { popup } from 'utils/popup';
2518import { reloadPage } from 'utils/turbolinks';
2619···6760 moment: any;
6861 popup: typeof popup;
6962 reloadPage: typeof reloadPage;
7070- Turbolinks: Turbolinks;
7163 }
7264}
73657474-window.$ = $;
7575-window.jQuery = $;
7666window.LangMessages ??= {};
7767window.Lang = patchPluralHandler(new Lang({
7868 fallback: window.fallbackLocale,
···8272window.moment = moment;
8373window.popup = popup;
8474window.reloadPage = reloadPage;
8585-window.Turbolinks = Turbolinks;
86758776// refer to variables.less
8877window._styles = {
···3939export default class UserVerification {
4040 // Used as callback on original action (where verification was required)
4141 private callback?: () => void;
4242- // set to true on turbolinks:visit so the box will be rendered on navigation
4242+ // set to true on turbo:visit so the box will be rendered on navigation
4343 private delayShow = false;
4444 // actual function to "store" the parameter original used for delayed show call
4545 private delayShowCallback?: () => void;
···7070 constructor() {
7171 $(document)
7272 .on('ajax:error', this.onError)
7373- .on('turbolinks:load', this.setModal)
7474- .on('turbolinks:load', this.showOnLoad)
7575- .on('turbolinks:visit', this.setDelayShow)
7373+ .on('turbo:load', this.setModal)
7474+ .on('turbo:load', this.showOnLoad)
7575+ .on('turbo:visit', this.setDelayShow)
7676 .on('input', '.js-user-verification--input', this.autoSubmit)
7777 .on('click', '.js-user-verification--reissue', this.reissue);
7878 $.subscribe('user-verification:success', this.success);
···4747declare const process: Process;
4848// #endregion
49495050-// TODO: Turbolinks 5.3 is Typescript, so this should be updated then...or it could be never released.
5151-declare const Turbolinks: import('turbolinks').default;
5252-5350// our helpers
5451declare const tooltipDefault: import('legacy-modules').TooltipDefault;
5552
+1-13
resources/js/main.coffee
···2828import Search from 'core-legacy/search'
2929import { StoreCheckout } from 'core-legacy/store-checkout'
3030import TooltipDefault from 'core-legacy/tooltip-default'
3131-import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'
3231import { navigate } from 'utils/turbolinks'
3333-3434-Turbolinks.start()
3535-Turbolinks.setProgressBarDelay(0)
36323733moment.relativeTimeThreshold('ss', 44)
3834moment.relativeTimeThreshold('s', 120)
···4339jQuery.timeago.inWords = (distanceMillis) ->
4440 moment.duration(-1 * distanceMillis).humanize(true)
45414646-# loading animation overlay
4747-# fired from turbolinks
4848-$(document).on 'turbolinks:request-start', showLoadingOverlay
4949-$(document).on 'turbolinks:request-end', hideLoadingOverlay
5050-# form submission is not covered by turbolinks
5151-$(document).on 'submit', 'form', (e) ->
5252- showLoadingOverlay() if e.currentTarget.dataset.loadingOverlay != '0'
5353-5454-$(document).on 'turbolinks:load', ->
4242+$(document).on 'turbo:load', ->
5543 BeatmapPack.initialize()
5644 StoreCheckout.initialize()
5745
+2-1
resources/js/models/chat/create-announcement.ts
···44import UserJson from 'interfaces/user-json';
55import { action, autorun, computed, makeObservable, observable } from 'mobx';
66import { present } from 'utils/string';
77+import { v4 as uuidv4 } from 'uuid';
78import { maxMessageLength } from './channel';
89910interface LocalStorageProps extends Record<InputKey, string> {
···3334 @observable validUsers = new Map<number, UserJson>();
34353536 private initialized = false;
3636- private readonly uuid = crypto.randomUUID();
3737+ private readonly uuid = uuidv4();
37383839 @computed
3940 get errors() {
+2-1
resources/js/models/chat/message.ts
···66import User from 'models/user';
77import * as moment from 'moment';
88import core from 'osu-core-singleton';
99+import { v4 as uuidv4 } from 'uuid';
9101011export default class Message {
1112 @observable channelId = -1;
1213 @observable content = '';
1314 @observable errored = false;
1415 @observable isAction = false;
1515- @observable messageId: number | string = crypto.randomUUID();
1616+ @observable messageId: number | string = uuidv4();
1617 @observable persisted = false;
1718 @observable senderId = -1;
1819 @observable timestamp: string = moment().toISOString();
···66import { NotificationContextData } from 'notifications-context';
77import NotificationStackStore from 'stores/notification-stack-store';
88import NotificationStore from 'stores/notification-store';
99-import { currentUrl, currentUrlParams } from 'utils/turbolinks';
99+import { updateHistory, currentUrl, currentUrlParams } from 'utils/turbolinks';
1010import { updateQueryString } from 'utils/url';
11111212export default class NotificationController {
···8686 href = updateQueryString(null, { type });
8787 }
88888989- Turbolinks.controller.advanceHistory(href);
8989+ updateHistory(href, 'push');
9090 }
9191 }
9292
+2-9
resources/js/osu-core.ts
···5959 readonly currentUserObserver;
6060 readonly dataStore;
6161 readonly enchant;
6262+ firstCurrentUserSet = false;
6263 readonly forumPoll;
6364 readonly forumPostEdit;
6465 readonly forumPostInput;
···9798 constructor() {
9899 // Set current user on first page load. Further updates are done in
99100 // reactTurbolinks before the new page is rendered.
100100- // This needs to be fired before everything else (turbolinks:load etc).
101101- const isLoading = document.readyState === 'loading';
102102- if (isLoading) {
103103- document.addEventListener('DOMContentLoaded', this.updateCurrentUser);
104104- }
101101+ // This needs to be fired before everything else (turbo:load etc).
105102 $.subscribe('user:update', this.onCurrentUserUpdate);
106103107104 this.animateNav = new AnimateNav();
···150147 this.notificationsWorker = new NotificationsWorker(this.socketWorker);
151148152149 makeObservable(this);
153153-154154- if (!isLoading) {
155155- this.updateCurrentUser();
156156- }
157150 }
158151159152 @action
···11+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22+// See the LICENCE file in the repository root for full licence text.
33+44+import jQuery from 'jquery';
55+import 'bootstrap';
66+import 'timeago/jquery.timeago.js';
77+import 'qtip2/dist/jquery.qtip.js';
88+import 'jquery.scrollto/jquery.scrollTo.js';
99+import 'jquery-ui/ui/data.js';
1010+import 'jquery-ui/ui/widgets/slider.js';
1111+import 'jquery-ui/ui/widgets/sortable.js';
1212+import 'jquery-ui-touch-punch';
1313+import 'blueimp-file-upload/js/jquery.fileupload.js';
1414+1515+window.$ = window.jQuery = jQuery;
+57
resources/js/setup-turbo.ts
···11+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22+// See the LICENCE file in the repository root for full licence text.
33+44+import '@hotwired/turbo';
55+import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay';
66+import { reloadPage } from 'utils/turbolinks';
77+88+Turbo.config.drive.progressBarDelay = 0;
99+1010+// loading animation overlay
1111+document.addEventListener('turbo:visit', showLoadingOverlay);
1212+document.addEventListener('turbo:before-cache', hideLoadingOverlay);
1313+document.addEventListener('turbo:load', hideLoadingOverlay);
1414+// only for forms handled by turbo. jquery-ujs forms are handled separately
1515+document.addEventListener('turbo:submit-start', (e) => {
1616+ if (e.detail.formSubmission.formElement.dataset.loadingOverlay !== '0') {
1717+ showLoadingOverlay();
1818+ }
1919+});
2020+document.addEventListener('turbo:submit-end', hideLoadingOverlay);
2121+document.addEventListener('turbo:submit-end', (e) => {
2222+ if (e.detail.success && e.detail.formSubmission.formElement.dataset.reloadOnSuccess === '1') {
2323+ reloadPage();
2424+ }
2525+});
2626+2727+document.addEventListener('turbo:before-fetch-response', (e) => {
2828+ if (!e.detail.fetchResponse.contentType?.match(/^text\/osu-turbo-redirect[ ;]*/)) {
2929+ return;
3030+ }
3131+3232+ e.preventDefault();
3333+ e.detail.fetchResponse.responseText.then((url) => {
3434+ const [currentUrlBase, urlBase] = [document.location.href, url].map((u) => u.replace(/#.*/, ''));
3535+3636+ if (currentUrlBase === urlBase) {
3737+ // a normal/advance visit to same url won't reload the page
3838+ Turbo.visit(url, { action: 'replace' });
3939+ } else {
4040+ Turbo.visit(url);
4141+ }
4242+ });
4343+});
4444+4545+// disable turbo navigation for old webs
4646+document.addEventListener('turbo:click', (event) => {
4747+ const url = new URL(event.detail.url);
4848+4949+ if (
5050+ url.origin === Turbo.session.navigator.rootLocation.origin
5151+ && url.pathname.match(/^\/(?:(?:api|osu|p|ss|web)\/|(?:beatmapsets|scores(?:\/[^\d]+)?)\/\d+\/download(?:\?|$))/) === null
5252+ ) {
5353+ return;
5454+ }
5555+5656+ event.preventDefault();
5757+});
···33 See the LICENCE file in the repository root for full licence text.
44--}}
55@php
66- $user = auth()->user();
66+ $currentUser ??= Auth::user();
7788- $userJson = $user === null
88+ $currentUserJson = $currentUser === null
99 ? '{}'
1010- : json_encode(json_item($user, new App\Transformers\CurrentUserTransformer()));
1010+ : json_encode(json_item($currentUser, new App\Transformers\CurrentUserTransformer()));
1111@endphp
1212<script id="json-current-user" type="application/json">
1313- {!! $userJson !!}
1313+ {!! $currentUserJson !!}
1414+</script>
1515+<script>
1616+ {{--
1717+ Set current user on first page load. Further updates are done in
1818+ reactTurbolinks before the new page is rendered.
1919+ This needs to be fired before everything else (turbo:load etc).
2020+ --}}
2121+ if (!osuCore.firstCurrentUserSet) {
2222+ osuCore.firstCurrentUserSet = true;
2323+ osuCore.updateCurrentUser();
2424+ }
1425</script>
···22 Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
33 See the LICENCE file in the repository root for full licence text.
44--}}
55-<script data-turbolinks-eval="always">
55+<script data-turbo-eval="always">
66 var csrf = "{{ csrf_token() }}";
77 var canonicalUrl = "{{ $canonicalUrl ?? '' }}";
88</script>
···22 Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
33 See the LICENCE file in the repository root for full licence text.
44--}}
55-;(function() {
66- $(document).off(".ujsHideLoadingOverlay")
77- Turbolinks.visit({!! json_encode($url) !!})
88-}).call(this);
55+$(document).off('.ujsHideLoadingOverlay');
66+Turbo.cache.clear();
77+Turbo.visit({!! json_encode($url) !!});