From fcba639bae8d47fca9f0c46d43a2e2efc302ee98 Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 23 Jun 2026 11:20:01 +1000 Subject: [PATCH] Session api token validation --- app/Http/Controllers/FeedController.php | 47 +++++ app/Http/Controllers/FollowerController.php | 51 +++++ app/Http/Controllers/HomePageController.php | 5 +- app/Http/Controllers/UserController.php | 64 +----- app/Http/Middleware/RefreshApiToken.php | 29 +++ app/Listeners/IssueApiToken.php | 21 +- app/Listeners/RevokeApiToken.php | 7 +- app/Models/User.php | 6 +- app/Observers/FlightObserver.php | 5 + bootstrap/app.php | 2 + .../Components/FlightsGoneBy/Feed/Avatar.vue | 33 +++ .../FlightsGoneBy/Feed/FeedItem.vue | 22 +- .../js/Components/FlightsGoneBy/FlightMap.vue | 51 ++++- .../Components/FlightsGoneBy/FollowButton.vue | 4 +- .../FlightsGoneBy/ProfileHeader.vue | 3 +- .../FlightsGoneBy/ProfileLayout.vue | 3 +- resources/js/Pages/Feed.vue | 194 ++++++++++++++++-- .../js/Pages/Profile/UserAchievement.vue | 2 +- resources/js/Pages/ProfileBoardingPasses.vue | 30 --- resources/js/Pages/UserAchievements.vue | 1 + resources/js/Pages/UserFlight.vue | 1 + resources/js/Pages/UserProfile.vue | 2 +- resources/js/Types/types.d.ts | 1 + routes/api.php | 7 +- routes/web.php | 135 +++++------- 25 files changed, 491 insertions(+), 235 deletions(-) create mode 100644 app/Http/Middleware/RefreshApiToken.php create mode 100644 resources/js/Components/FlightsGoneBy/Feed/Avatar.vue delete mode 100644 resources/js/Pages/ProfileBoardingPasses.vue diff --git a/app/Http/Controllers/FeedController.php b/app/Http/Controllers/FeedController.php index 989615b..c5681ee 100644 --- a/app/Http/Controllers/FeedController.php +++ b/app/Http/Controllers/FeedController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers; use App\Models\UserAction; +use App\Models\UserFlight; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class FeedController extends Controller @@ -28,4 +30,49 @@ class FeedController extends Controller 'feed' => $feed, ]); } + + public function following() + { + $user = auth()->user(); + + return Cache::remember( + "user:{$user->id}:following", + now()->addMinutes(15), + fn () => $user->following() + ->with('followee') + ->get() + ->pluck('followee') + ->filter() + ->values() + ->toArray() + ); + } + + public function followingFlights() + { + $user = auth()->user(); + + return Cache::remember( + "user_following_flights_{$user->id}", + now()->addDays(30), + function () use ($user) { + $followingIds = $user->following() + ->with('followee') + ->get() + ->pluck('followee') + ->filter() + ->reject(fn ($followee) => ($followee->settings['profile_privacy'] ?? 'public') === 'private') + ->pluck('id'); + + return UserFlight::query() + ->with(['departureAirport', 'arrivalAirport', 'user']) + ->whereIn('user_id', $followingIds) + ->orderByDesc('departure_date') + ->limit(100) + ->get() + ->values() + ->toArray(); + } + ); + } } diff --git a/app/Http/Controllers/FollowerController.php b/app/Http/Controllers/FollowerController.php index 0e59ccf..46200fd 100644 --- a/app/Http/Controllers/FollowerController.php +++ b/app/Http/Controllers/FollowerController.php @@ -7,6 +7,8 @@ use App\Models\Notification; use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Gate; class FollowerController extends Controller { @@ -24,6 +26,43 @@ class FollowerController extends Controller return response()->json($followers); } + public function follow(User $user): JsonResponse + { + abort_if($user->id === auth()->id(), 403); + + $existing = Followee::where('user_id', auth()->id()) + ->where('followee_id', $user->id) + ->first(); + + if ($existing) { + $existing->delete(); + $this->clearFollowingCache(auth()->user()); + return response()->json(['status' => 'none']); + } + + $canView = Gate::allows('viewProfileData', $user); + + Followee::create([ + 'user_id' => auth()->id(), + 'followee_id' => $user->id, + 'verified' => $canView, + ]); + + $this->clearFollowingCache(auth()->user()); + + Notification::create([ + 'user_id' => $user->id, + 'title' => $canView ? 'New follower' : 'Follow request', + 'body' => $canView + ? auth()->user()->name . ' is now following you.' + : auth()->user()->name . ' wants to follow you.', + 'is_achievement' => false, + 'url' => $canView ? '/u/' . auth()->user()->name : '/follow-requests', + ]); + + return response()->json(['status' => $canView ? 'following' : 'requested']); + } + public function approve(User $follower): JsonResponse { $followee = Followee::where('user_id', $follower->id) @@ -33,6 +72,8 @@ class FollowerController extends Controller $followee->update(['verified' => true]); + $this->clearFollowingCache($follower); + Notification::create([ 'user_id' => $follower->id, 'title' => 'Follow request accepted', @@ -51,6 +92,8 @@ class FollowerController extends Controller ->pending() ->delete(); + $this->clearFollowingCache($follower); + return response()->json(['status' => 'denied']); } @@ -61,6 +104,14 @@ class FollowerController extends Controller ->verified() ->delete(); + $this->clearFollowingCache($follower); + return response()->json(['status' => 'removed']); } + + protected function clearFollowingCache(User $user): void + { + Cache::forget("user:{$user->id}:following"); + Cache::forget("user_following_flights_{$user->id}"); + } } diff --git a/app/Http/Controllers/HomePageController.php b/app/Http/Controllers/HomePageController.php index d969d71..493ca37 100644 --- a/app/Http/Controllers/HomePageController.php +++ b/app/Http/Controllers/HomePageController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\UserFlight; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; @@ -14,9 +15,9 @@ class HomePageController extends Controller { return Cache::remember('splash_flights', now()->addHours(12), function () { return UserFlight::query() - ->with(['departureAirport', 'arrivalAirport']) + ->with(['departureAirport', 'arrivalAirport', 'user']) ->whereHas('user', function ($query) { - $query->whereRaw("settings->>'profile_privacy' is distinct from 'private'"); + $query->whereRaw(DB::raw("settings->>'profile_privacy' is distinct from 'private'")); }) ->orderByDesc('departure_date') ->limit(50) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 351568c..cf47789 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -9,74 +9,12 @@ use App\Settings\SettingsRegistry; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Gate; use Inertia\Inertia; class UserController extends Controller { - public function follow(User $user): JsonResponse - { - abort_if($user->id === auth()->id(), 403); - - $existing = Followee::where('user_id', auth()->id()) - ->where('followee_id', $user->id) - ->first(); - - if ($existing) { - $existing->delete(); - return response()->json(['status' => 'none']); - } - - $canView = Gate::allows('viewProfileData', $user); - - Followee::create([ - 'user_id' => auth()->id(), - 'followee_id' => $user->id, - 'verified' => $canView, - ]); - - Notification::create([ - 'user_id' => $user->id, - 'title' => $canView ? 'New follower' : 'Follow request', - 'body' => $canView - ? auth()->user()->name . ' is now following you.' - : auth()->user()->name . ' wants to follow you.', - 'is_achievement' => false, - 'url' => $canView ? '/u/' . auth()->user()->name : '/follow-requests', - ]); - - return response()->json(['status' => $canView ? 'following' : 'requested']); - } - - public function approveRequest(User $follower): JsonResponse - { - $followee = Followee::where('user_id', $follower->id) - ->where('followee_id', auth()->id()) - ->pending() - ->firstOrFail(); - - $followee->update(['verified' => true]); - - Notification::create([ - 'user_id' => $follower->id, - 'title' => 'Follow request accepted', - 'body' => auth()->user()->name . ' accepted your follow request.', - 'is_achievement' => false, - 'url' => '/u/' . auth()->user()->name, - ]); - - return response()->json(['approved' => true]); - } - - public function denyRequest(User $follower): JsonResponse - { - Followee::where('user_id', $follower->id) - ->where('followee_id', auth()->id()) - ->pending() - ->delete(); - - return response()->json(['denied' => true]); - } public function settings(?string $category = null){ $allowedTabs = ['general', 'followers']; diff --git a/app/Http/Middleware/RefreshApiToken.php b/app/Http/Middleware/RefreshApiToken.php new file mode 100644 index 0000000..e3e402c --- /dev/null +++ b/app/Http/Middleware/RefreshApiToken.php @@ -0,0 +1,29 @@ +user() || !session('api_token_id')) { + return $next($request); + } + + $token = $request->user() + ->tokens() + ->find(session('api_token_id')); + + // Refresh if expired or within 30 minutes of expiry + if (!$token || $token->expires_at->subMinutes(30)->isPast()) { + app(IssueApiToken::class)->createToken($request->user()); + } + + return $next($request); + } +} diff --git a/app/Listeners/IssueApiToken.php b/app/Listeners/IssueApiToken.php index 8303795..150c754 100644 --- a/app/Listeners/IssueApiToken.php +++ b/app/Listeners/IssueApiToken.php @@ -11,13 +11,26 @@ class IssueApiToken { /** @var User $user */ $user = $event->user; - $user->tokens()->where('name', 'frontend')->delete(); - $token = $user->createToken( + + $this->createToken($user); + } + + public function createToken(User $user): void + { + // Delete only this session's existing token (if any) + if ($tokenId = session('api_token_id')) { + $user->tokens()->where('id', $tokenId)->delete(); + } + + $newToken = $user->createToken( 'frontend', ['*'], now()->addHours(4) - )->plainTextToken; + ); - session(['api_token' => $token]); + session([ + 'api_token' => $newToken->plainTextToken, + 'api_token_id' => $newToken->accessToken->id, + ]); } } diff --git a/app/Listeners/RevokeApiToken.php b/app/Listeners/RevokeApiToken.php index 144dff7..7f5c462 100644 --- a/app/Listeners/RevokeApiToken.php +++ b/app/Listeners/RevokeApiToken.php @@ -17,7 +17,10 @@ class RevokeApiToken /** @var User $user */ $user = $event->user; - $user?->tokens()->where('name', 'frontend')->delete(); - session()->forget('api_token'); + if ($tokenId = session('api_token_id')) { + $user?->tokens()->where('id', $tokenId)->delete(); + } + + session()->forget(['api_token', 'api_token_id']); } } diff --git a/app/Models/User.php b/app/Models/User.php index e05643b..6fd402f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -140,19 +140,19 @@ class User extends Authenticatable public function following(): HasMany { - return $this->hasMany(Followee::class, 'user_id'); + return $this->hasMany(Followee::class, 'user_id')->verified(); } public function followers(): HasMany { - return $this->hasMany(Followee::class, 'followee_id'); + return $this->hasMany(Followee::class, 'followee_id')->verified(); } + public function isFollowing(User $user): bool { return $this->following() ->where('followee_id', $user->id) - ->verified() ->exists(); } diff --git a/app/Observers/FlightObserver.php b/app/Observers/FlightObserver.php index fa373e0..80a58a1 100644 --- a/app/Observers/FlightObserver.php +++ b/app/Observers/FlightObserver.php @@ -10,6 +10,11 @@ class FlightObserver protected function clearCache(UserFlight $flight): void { Cache::forget("user_flights_{$flight->user->id}"); + + //Make queued task if the site gets big + $flight->user->followers() + ->get() + ->each(fn ($follower) => Cache::forget("user_following_flights_{$follower->id}")); } diff --git a/bootstrap/app.php b/bootstrap/app.php index 4bcb295..b77cf43 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ web(append: [ HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, + RefreshApiToken::class ]); $middleware->alias([ 'role' => RoleMiddleware::class, diff --git a/resources/js/Components/FlightsGoneBy/Feed/Avatar.vue b/resources/js/Components/FlightsGoneBy/Feed/Avatar.vue new file mode 100644 index 0000000..139af19 --- /dev/null +++ b/resources/js/Components/FlightsGoneBy/Feed/Avatar.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue b/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue index 9284d5a..ad5f6e1 100644 --- a/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue +++ b/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue @@ -14,6 +14,7 @@ import FlightCancelledFeedItem from "@/Components/FlightsGoneBy/Feed/FlightCance import FlightImportedFeedItem from "@/Components/FlightsGoneBy/Feed/FlightImportedFeedItem.vue"; import FlightMovedFeedItem from "@/Components/FlightsGoneBy/Feed/FlightMovedFeedItem.vue"; import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue"; +import Avatar from "@/Components/FlightsGoneBy/Feed/Avatar.vue"; const props = defineProps<{ action: UserAction @@ -56,11 +57,7 @@ function timeAgo(dateStr: string): string {