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\Exceptions\ImageProcessorException;
9use App\Exceptions\ModelNotSavedException;
10use App\Libraries\Session\Store as SessionStore;
11use App\Libraries\SessionVerification;
12use App\Libraries\User\AvatarHelper;
13use App\Libraries\User\CountryChange;
14use App\Libraries\User\CountryChangeTarget;
15use App\Mail\UserEmailUpdated;
16use App\Mail\UserPasswordUpdated;
17use App\Models\Beatmap;
18use App\Models\GithubUser;
19use App\Models\OAuth\Client;
20use App\Models\UserAccountHistory;
21use App\Models\UserNotificationOption;
22use App\Transformers\CurrentUserTransformer;
23use App\Transformers\LegacyApiKeyTransformer;
24use App\Transformers\LegacyIrcKeyTransformer;
25use Auth;
26use DB;
27use Mail;
28use Request;
29
30class AccountController extends Controller
31{
32 public function __construct()
33 {
34 $this->middleware('auth', ['except' => [
35 'verifyLink',
36 ]]);
37
38 $this->middleware(function ($request, $next) {
39 if (Auth::check() && Auth::user()->isSilenced()) {
40 return abort(403, osu_trans('authorization.silenced'));
41 }
42
43 return $next($request);
44 }, [
45 'except' => [
46 'edit',
47 'reissueCode',
48 'updateCountry',
49 'updateEmail',
50 'updateNotificationOptions',
51 'updateOptions',
52 'updatePassword',
53 'verify',
54 'verifyLink',
55 ],
56 ]);
57
58 $this->middleware('verify-user', ['except' => [
59 'updateOptions',
60 ]]);
61
62 $this->middleware('throttle:3,5', ['only' => [
63 'reissueCode',
64 ]]);
65
66 $this->middleware('throttle:60,10', ['only' => [
67 'updateEmail',
68 'updatePassword',
69 'verify',
70 'verifyLink',
71 ]]);
72
73 parent::__construct();
74 }
75
76 public function avatar()
77 {
78 $user = auth()->user();
79
80 try {
81 AvatarHelper::set($user, Request::file('avatar_file'));
82 } catch (ImageProcessorException $e) {
83 return error_popup($e->getMessage());
84 }
85
86 return json_item($user, new CurrentUserTransformer());
87 }
88
89 public function cover()
90 {
91 $user = \Auth::user();
92 $params = get_params(\Request::all(), null, [
93 'cover_file:file',
94 'cover_id:int',
95 ], ['null_missing' => true]);
96
97 if ($params['cover_file'] !== null && !$user->osu_subscriber) {
98 return error_popup(osu_trans('errors.supporter_only'));
99 }
100
101 try {
102 $user->cover()->set($params['cover_id'], $params['cover_file']);
103 $user->save();
104 } catch (ImageProcessorException $e) {
105 return error_popup($e->getMessage());
106 }
107
108 return json_item($user, new CurrentUserTransformer());
109 }
110
111 public function edit()
112 {
113 $user = auth()->user();
114
115 $blocks = $user->blocks()
116 ->orderBy('username')
117 ->get();
118
119 $sessions = SessionStore::sessions($user->getKey());
120 $currentSessionId = \Session::getId();
121
122 $authorizedClients = json_collection(Client::forUser($user), 'OAuth\Client', 'user');
123 $ownClients = json_collection($user->oauthClients()->where('revoked', false)->get(), 'OAuth\Client', ['redirect', 'secret']);
124
125 $legacyApiKey = $user->apiKeys()->available()->first();
126 $legacyApiKeyJson = $legacyApiKey === null ? null : json_item($legacyApiKey, new LegacyApiKeyTransformer());
127
128 $legacyIrcKey = $user->legacyIrcKey;
129 $legacyIrcKeyJson = $legacyIrcKey === null ? null : json_item($legacyIrcKey, new LegacyIrcKeyTransformer());
130
131 $notificationOptions = $user->notificationOptions->keyBy('name');
132
133 $githubUser = GithubUser::canAuthenticate() && $user->githubUser !== null
134 ? json_item($user->githubUser, 'GithubUser')
135 : null;
136
137 return ext_view('accounts.edit', compact(
138 'authorizedClients',
139 'blocks',
140 'currentSessionId',
141 'githubUser',
142 'legacyApiKeyJson',
143 'legacyIrcKeyJson',
144 'notificationOptions',
145 'ownClients',
146 'sessions'
147 ));
148 }
149
150 public function update()
151 {
152 $user = Auth::user();
153
154 $params = get_params(request()->all(), 'user', [
155 'user_from:string',
156 'user_interests:string',
157 'user_occ:string',
158 'user_sig:string',
159 'user_twitter:string',
160 'user_website:string',
161 'user_discord:string',
162 'user_style:int',
163 ]);
164
165 // setting it to null (default) is always allowed
166 if (isset($params['user_style']) && !$user->osu_subscriber) {
167 return error_popup(osu_trans('errors.supporter_only'));
168 }
169
170 try {
171 $user->fill($params)->saveOrExplode();
172 } catch (ModelNotSavedException $e) {
173 return ModelNotSavedException::makeResponse($e, compact('user'));
174 }
175
176 return json_item($user, new CurrentUserTransformer());
177 }
178
179 public function updateCountry()
180 {
181 $newCountry = get_string(Request::input('country_acronym'));
182 $user = Auth::user();
183
184 if ($newCountry === null || CountryChangeTarget::get($user) !== $newCountry) {
185 abort(403, 'specified country_acronym is not allowed');
186 }
187
188 CountryChange::handle($user, $newCountry, 'account settings');
189 \Session::flash('popup', osu_trans('common.saved'));
190
191 return ext_view('layout.ujs-reload', [], 'js');
192 }
193
194 public function updateEmail()
195 {
196 priv_check('UserUpdateEmail')->ensureCan();
197
198 $params = get_params(request()->all(), 'user', ['current_password', 'user_email', 'user_email_confirmation']);
199 $user = Auth::user()->validateCurrentPassword()->validateEmailConfirmation();
200 $previousEmail = $user->user_email;
201
202 if ($user->update($params) === true) {
203 foreach ([$previousEmail, $user->user_email] as $address) {
204 if (is_valid_email_format($address)) {
205 Mail::to($address)->locale($user->preferredLocale())->send(new UserEmailUpdated($user));
206 }
207 }
208
209 UserAccountHistory::logUserUpdateEmail($user, $previousEmail);
210
211 return response([], 204);
212 } else {
213 return ModelNotSavedException::makeResponse(null, compact('user'));
214 }
215 }
216
217 public function updateNotificationOptions()
218 {
219 $requestParams = request()->all()['user_notification_option'] ?? [];
220 if (!is_array($requestParams)) {
221 abort(422);
222 }
223
224 DB::transaction(function () use ($requestParams) {
225 $user = auth()->user();
226 $user
227 ->notificationOptions()
228 ->whereIn('name', array_keys($requestParams))
229 ->select('user_id')
230 ->lockForUpdate()
231 ->get();
232 foreach ($requestParams as $key => $value) {
233 if (!UserNotificationOption::supportsNotifications($key)) {
234 continue;
235 }
236
237 $params = get_params($value, null, ['details:any']);
238
239 $option = $user->notificationOptions()->firstOrNew(['name' => $key]);
240 // TODO: show correct field error.
241 $option->fill($params)->saveOrExplode();
242 }
243 });
244
245 return response(null, 204);
246 }
247
248 public function updateOptions()
249 {
250 $user = Auth::user();
251 $params = request()->all();
252
253 $userParams = get_params($params, 'user', [
254 'hide_presence:bool',
255 'osu_playstyle:string[]',
256 'playmode:string',
257 'pm_friends_only:bool',
258 'user_notify:bool',
259 ]);
260
261 if (isset($userParams['playmode']) && !Beatmap::isModeValid($userParams['playmode'])) {
262 abort(422, 'invalid value specified for user[playmode]');
263 }
264
265 $profileParams = get_params($params, 'user_profile_customization', [
266 'audio_autoplay:bool',
267 'audio_muted:bool',
268 'audio_volume:float',
269 'beatmapset_card_size:string',
270 'beatmapset_download:string',
271 'beatmapset_show_nsfw:bool',
272 'beatmapset_title_show_original:bool',
273 'comments_show_deleted:bool',
274 'comments_sort:string',
275 'extras_order:string[]',
276 'forum_posts_show_deleted:bool',
277 'legacy_score_only:bool',
278 'profile_cover_expanded:bool',
279 'scoring_mode:string',
280 'user_list_filter:string',
281 'user_list_sort:string',
282 'user_list_view:string',
283 ]);
284
285 $profileCustomization = $user->userProfileCustomization()->createOrFirst();
286 $user->setRelation('userProfileCustomization', $profileCustomization);
287
288 try {
289 if (!empty($userParams)) {
290 $user->fill($userParams)->saveOrExplode();
291 }
292
293 if (!empty($profileParams)) {
294 $profileCustomization->fill($profileParams)->saveOrExplode();
295 }
296 } catch (ModelNotSavedException $e) {
297 return ModelNotSavedException::makeResponse($e, [
298 'user' => $user,
299 'user_profile_customization' => $profileCustomization,
300 ]);
301 }
302
303 return json_item($user, new CurrentUserTransformer());
304 }
305
306 public function updatePassword()
307 {
308 $params = get_params(request()->all(), 'user', ['current_password', 'password', 'password_confirmation']);
309 $user = Auth::user()->validateCurrentPassword()->validatePasswordConfirmation();
310
311 if ($user->update($params) === true) {
312 if (is_valid_email_format($user->user_email)) {
313 Mail::to($user)->send(new UserPasswordUpdated($user));
314 }
315
316 $user->resetSessions(\Session::getId());
317
318 return response([], 204);
319 } else {
320 return ModelNotSavedException::makeResponse(null, compact('user'));
321 }
322 }
323
324 public function verify()
325 {
326 return SessionVerification\Controller::verify();
327 }
328
329 public function verifyLink()
330 {
331 return SessionVerification\Controller::verifyLink();
332 }
333
334 public function reissueCode()
335 {
336 return SessionVerification\Controller::reissue();
337 }
338}