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
6declare(strict_types=1);
7
8namespace App\Libraries\Session;
9
10use App\Events\UserSessionEvent;
11use App\Interfaces\SessionVerificationInterface;
12use Illuminate\Redis\Connections\PhpRedisConnection;
13use Illuminate\Session\Store as BaseStore;
14use Illuminate\Support\Arr;
15use Jenssegers\Agent\Agent;
16
17class Store extends BaseStore implements SessionVerificationInterface
18{
19 private const PREFIX = 'sessions:';
20
21 public static function batchDelete(?int $userId, ?array $ids = null): void
22 {
23 $ids ??= static::ids($userId);
24
25 if (empty($ids)) {
26 return;
27 }
28
29 $currentSession = \Session::instance();
30 if (in_array($currentSession->getId(), $ids, true)) {
31 $currentSession->flush();
32 }
33
34 $redis = self::redis();
35 $redis->del($ids);
36 if ($userId !== null) {
37 $idsForEvent = self::keysForEvent($ids);
38 // Also delete ids that were previously stored with prefix which is
39 // the full redis key just like for event.
40 $redis->srem(self::listKey($userId), ...$ids, ...$idsForEvent);
41 UserSessionEvent::newLogout($userId, $idsForEvent)->broadcast();
42 }
43 }
44
45 public static function findForVerification(string $id): static
46 {
47 return static::findOrNew($id);
48 }
49
50 public static function findOrNew(?string $id = null): static
51 {
52 if ($id !== null) {
53 $currentSession = \Session::instance();
54
55 if ($currentSession->getId() === $id) {
56 return $currentSession;
57 }
58 }
59
60 $ret = (new SessionManager(\App::getInstance()))->instance();
61 if ($id !== null) {
62 $ret->setId($id);
63 }
64 $ret->start();
65
66 return $ret;
67 }
68
69 public static function ids(?int $userId): array
70 {
71 return $userId === null
72 ? []
73 : array_map(
74 // The ids were previously stored with prefix.
75 fn ($id) => str_starts_with($id, 'osu-next:') ? substr($id, 9) : $id,
76 self::redis()->smembers(self::listKey($userId)),
77 );
78 }
79
80 public static function keyForEvent(string $id): string
81 {
82 // TODO: use config's database.redis.session.prefix (also in notification-server)
83 return "osu-next:{$id}";
84 }
85
86 public static function keysForEvent(array $ids): array
87 {
88 return array_map(static::keyForEvent(...), $ids);
89 }
90
91 public static function sessions(int $userId): array
92 {
93 $ids = static::ids($userId);
94 if (empty($ids)) {
95 return [];
96 }
97
98 $sessions = array_combine(
99 $ids,
100 // Sessions are stored double-serialized in redis (session serialization + cache backend serialization)
101 array_map(
102 fn ($s) => $s === null ? null : unserialize(unserialize($s)),
103 self::redis()->mget($ids),
104 ),
105 );
106
107 // Current session data in redis may be stale
108 $currentSession = \Session::instance();
109 if ($currentSession->userId() === $userId) {
110 $sessions[$currentSession->getId()] = $currentSession->attributes;
111 }
112
113 $sessionMeta = [];
114 $agent = new Agent();
115 $expiredIds = [];
116 foreach ($sessions as $id => $session) {
117 if ($session === null) {
118 $expiredIds[] = $id;
119 continue;
120 }
121
122 if (!isset($session['meta'])) {
123 continue;
124 }
125
126 $meta = $session['meta'];
127 $agent->setUserAgent($meta['agent']);
128
129 $sessionMeta[$id] = [
130 ...$meta,
131 'mobile' => $agent->isMobile() || $agent->isTablet(),
132 'device' => $agent->device(),
133 'platform' => $agent->platform(),
134 'browser' => $agent->browser(),
135 'verified' => (bool) ($session['verified'] ?? false),
136 ];
137 }
138
139 // cleanup expired sessions
140 static::batchDelete($userId, $expiredIds);
141
142 // returns sessions sorted from most to least recently active
143 return Arr::sortDesc(
144 $sessionMeta,
145 fn ($value) => $value['last_visit'],
146 );
147 }
148
149 /**
150 * Get the redis key containing the session list for the given user.
151 */
152 private static function listKey(int $userId): string
153 {
154 return static::PREFIX.$userId;
155 }
156
157 private static function redis(): PhpRedisConnection
158 {
159 return \LaravelRedis::connection($GLOBALS['cfg']['session']['connection']);
160 }
161
162 public function delete(): void
163 {
164 static::batchDelete($this->userId(), [$this->getId()]);
165 }
166
167 public function getKey(): string
168 {
169 return $this->getId();
170 }
171
172 public function getKeyForEvent(): string
173 {
174 return self::keyForEvent($this->getId());
175 }
176
177 /**
178 * Used to obtain the instance from Session facade or SessionManager instance
179 */
180 public function instance(): static
181 {
182 return $this;
183 }
184
185 public function isValidId($id)
186 {
187 // Overridden to allow prefixed id
188 return is_string($id);
189 }
190
191 public function isVerified(): bool
192 {
193 return $this->attributes['verified'] ?? false;
194 }
195
196 public function markVerified(): void
197 {
198 $this->attributes['verified'] = true;
199 $this->save();
200 }
201
202 /**
203 * Generate a new session id.
204 *
205 * Overridden to delete session from redis - both entry and list.
206 */
207 public function migrate($destroy = false)
208 {
209 if ($destroy) {
210 $userId = $this->userId();
211 if ($userId !== null) {
212 // Keep existing attributes
213 $attributes = $this->attributes;
214 static::batchDelete($userId, [$this->getId()]);
215 $this->attributes = $attributes;
216 }
217 }
218
219 return parent::migrate($destroy);
220 }
221
222 /**
223 * Save the session data to storage.
224 *
225 * Overriden to track user sessions in Redis and shorten lifetime for guest sessions.
226 */
227 public function save()
228 {
229 $userId = $this->userId();
230
231 if ($this->handler instanceof CacheBasedSessionHandler) {
232 $this->handler->setMinutes($userId === null ? 120 : $GLOBALS['cfg']['session']['lifetime']);
233 }
234
235 parent::save();
236
237 // TODO: move this to migrate and validate session id in readFromHandler
238 if ($userId !== null) {
239 self::redis()->sadd(self::listKey($userId), $this->getId());
240 }
241 }
242
243 public function userId(): ?int
244 {
245 // From `Auth::getName()`.
246 // Hardcoded because Auth depends on this class instance which then
247 // calls this functions and would otherwise cause circular dependency.
248 // Note that osu-notification-server also checks this key. Make sure
249 // to also update it if the value changes.
250 return $this->attributes['login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d'] ?? null;
251 }
252
253 protected function generateSessionId()
254 {
255 $userId = $this->userId();
256
257 return self::PREFIX
258 .($userId ?? 'guest')
259 .':'
260 .parent::generateSessionId();
261 }
262
263 /**
264 * Read the session data from the handler.
265 *
266 * @return array
267 */
268 protected function readFromHandler()
269 {
270 // Overridden to force session ids to be regenerated when trying to load a session that doesn't exist anymore
271 if ($data = $this->handler->read($this->getId())) {
272 $data = @unserialize($this->prepareForUnserialize($data));
273
274 if ($data !== false && !is_null($data) && is_array($data)) {
275 return $data;
276 }
277 }
278
279 $this->regenerate(true);
280
281 return [];
282 }
283}