diff --git a/.env.example b/.env.example index be18dae..fb7ce75 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ APP_URL=http://localhost APP_DOMAIN=flightsgoneby.test API_DOMAIN=api.flightsgoneby.test +TRUSTED_FRONTEND_ORIGINS=https://app.example.com + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php index 6cbffbe..4f7dfa4 100644 --- a/app/Http/Controllers/Api/UserApiController.php +++ b/app/Http/Controllers/Api/UserApiController.php @@ -3,22 +3,27 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\ApiController; +use App\Http\Controllers\UserFlightController; use App\Models\User; use App\Models\UserFlight; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; class UserApiController extends ApiController { - public function nextFlight(string $username): JsonResponse + public function nextFlight(User $user): JsonResponse { - $user = User::where('name', 'ilike', $username)->first(); - if (!$user) { + if (!$user->id) { return response()->json(['message' => 'User not found'], 404); } + if (Gate::denies('viewProfileData', $user)) { + return response()->json(['message' => 'Cannot access private user.'], 403); + } + $flight = UserFlight::with(['departureAirport', 'arrivalAirport', 'airline', 'aircraft']) ->where('user_id', $user->id) ->where('departure_date', '>', now()->utc()) @@ -50,4 +55,20 @@ class UserApiController extends ApiController ]); } + public function viewableFlights(User $user) + { + if (Gate::denies('viewProfileData', $user)) { + return collect([]); + } + return $user->flightsWithRelationshipsLoaded(); + } + + public function viewableDepartedFlights(User $user) + { + if (Gate::denies('viewProfileData', $user)) { + return collect([]); + } + return $user->flightsWithRelationshipsLoaded('departed'); + } + } diff --git a/app/Http/Controllers/UserFlightController.php b/app/Http/Controllers/UserFlightController.php index b4ecb02..a63c546 100644 --- a/app/Http/Controllers/UserFlightController.php +++ b/app/Http/Controllers/UserFlightController.php @@ -11,48 +11,5 @@ use Illuminate\Support\Facades\Gate; class UserFlightController extends Controller { - public function viewableFlights(User $user, ?Request $request = null) - { - if (Gate::denies('viewProfileData', $user)) { - return response()->json([]); - } - return $this->flights($user, $request); - } - public function flights(User $user, ?Request $request = null) - { - $key = "user_flights_{$user->id}"; - - $json = Cache::remember($key, now()->addDays(30), function () use ($user) { - return UserFlight::where('user_id', $user->id) - ->with([ - 'departureAirport.region.country', - 'departureAirport.region.continent', - 'arrivalAirport.region.country', - 'arrivalAirport.region.continent', - 'airline.country', - 'airline.alliance', - 'aircraft', - 'seatType', - 'flightReason', - 'flightClass', - 'crewType' - ]) - ->orderBy('departure_date', 'desc') - ->get() - ->values() - ->toJson(); - }); - - if ($request?->boolean('departed_only')) { - $filtered = collect(json_decode($json)) - ->filter(fn($f) => $f->departure_date <= now('UTC')->toDateString()) - ->values() - ->toJson(); - - return response($filtered, 200)->header('Content-Type', 'application/json'); - } - - return response($json, 200)->header('Content-Type', 'application/json'); - } } diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index f5d8489..47e9ec9 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -36,7 +36,8 @@ class UserProfileController extends Controller } public static function getUserFlightApiURL(User $user){ - return '/data/user/'.$user->name.'/flights'; + return config('app.logo_api_url').'/user/'.$user->name.'/flights'; + //return '/data/user/'.$user->name.'/flights'; } public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array { diff --git a/app/Http/Middleware/SanctumOrTrustedOrigin.php b/app/Http/Middleware/SanctumOrTrustedOrigin.php new file mode 100644 index 0000000..0125990 --- /dev/null +++ b/app/Http/Middleware/SanctumOrTrustedOrigin.php @@ -0,0 +1,30 @@ +user() is set. + if ($request->user('sanctum')) { + return $next($request); + } + + // Unauthenticated, but coming from our own frontend — let it through too. + $origin = $request->headers->get('Origin') ?? $request->headers->get('Referer'); + $trusted = config('app.trusted_frontend_origins', []); + + foreach ($trusted as $trustedOrigin) { + if ($origin && str_starts_with($origin, $trustedOrigin)) { + return $next($request); + } + } + + abort(403, 'Forbidden.'); + } +} diff --git a/app/Models/Guest.php b/app/Models/Guest.php new file mode 100644 index 0000000..00afd48 --- /dev/null +++ b/app/Models/Guest.php @@ -0,0 +1,11 @@ +flights()->where('departure_date', '>=', now('UTC')); } + public function flightsWithRelationshipsLoaded(?string $filter = null): Collection + { + $key = "user_flights_{$this->id}"; + + $json = Cache::remember($key, now()->addDays(30), function () { + return $this->flights() + ->with([ + 'departureAirport.region.country', + 'departureAirport.region.continent', + 'arrivalAirport.region.country', + 'arrivalAirport.region.continent', + 'airline.country', + 'airline.alliance', + 'aircraft', + 'seatType', + 'flightReason', + 'flightClass', + 'crewType' + ]) + ->orderBy('departure_date', 'desc') + ->get() + ->values() + ->toJson(); + }); + + $collection = collect(json_decode($json)); + $today = now('UTC')->toDateString(); + + return match ($filter) { + 'departed' => $collection->filter(fn($f) => $f->departure_date <= $today)->values(), + 'upcoming' => $collection->filter(fn($f) => $f->departure_date > $today)->values(), + default => $collection, + }; + } + public function ImportedFlights(): HasMany { return $this->hasMany(ImportedFlight::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2e8ffec..2f79f57 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,9 @@ use App\Observers\AirlineObserver; use App\Observers\FlightObserver; use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Http\Request; +use Illuminate\Cache\RateLimiting\Limit; class AppServiceProvider extends ServiceProvider { @@ -27,5 +30,11 @@ class AppServiceProvider extends ServiceProvider Vite::prefetch(concurrency: 3); UserFlight::observe(FlightObserver::class); Airline::observe(AirlineObserver::class); + RateLimiter::for('api', function (Request $request) { + return $request->user() + ? Limit::perMinute(60)->by($request->user()->id) + : Limit::perMinute(10)->by($request->ip()); + }); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c91c737..335b908 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ @@ -33,9 +36,26 @@ return Application::configure(basePath: dirname(__DIR__)) // }) ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->render(function (NotFoundHttpException $e, Request $request) { + if ($request->getHost() !== config('app.api_domain')) { + return null; + } + + if ($e->getPrevious() instanceof ModelNotFoundException) { + return response()->json(['message' => 'Resource not found.'], 404); + } + + return response()->json(['message' => 'Not found.'], 404); + }); + $exceptions->respond(function ($response, Throwable $e, Request $request) { $status = $response->getStatusCode(); + // API domain: never touch the response, let Laravel's own JSON rendering stand. + if ($request->getHost() === config('app.api_domain')) { + return $response; + } + $errors = [ 403 => [ 'title' => "The Cockpit is Off Limits", @@ -64,10 +84,8 @@ return Application::configure(basePath: dirname(__DIR__)) ]; $isLocal = app()->environment(['local', 'testing']); - $handled = array_keys($errors); $friendlyErrorsOnLocal = [404, 403]; - // In local/testing, only handle 404. In production, handle all. $shouldHandle = isset($errors[$status]) && ( !$isLocal || in_array($status, $friendlyErrorsOnLocal) ); @@ -88,5 +106,13 @@ return Application::configure(basePath: dirname(__DIR__)) ]) ->toResponse($request) ->setStatusCode($status); - }); - })->create(); + }) + ->shouldRenderJsonWhen(function ($request, Throwable $e) { + if ($request->getHost() === config('app.api_domain')) { + return true; + } + + return $request->expectsJson(); + }); + }) + ->create(); diff --git a/config/app.php b/config/app.php index 418cf89..0f79282 100644 --- a/config/app.php +++ b/config/app.php @@ -58,6 +58,7 @@ return [ 'api_domain' => env('API_DOMAIN', 'api.flightsgoneby.com'), 'logo_api_url' => env('LOGO_API_URL', 'https://api.flightsgoneby.com'), 'timezone_api_key' => env('TIMEZONE_API_KEY', '1234567890'), + 'trusted_frontend_origins' => array_filter(explode(',', env('TRUSTED_FRONTEND_ORIGINS', ''))), /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..e6906f1 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,38 @@ + ['*'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [ + 'https://flightsgoneby.com', + 'https://www.flightsgoneby.com', + 'http://flightsgoneby.test:8000', + ], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; diff --git a/resources/js/Components/FlightsGoneBy/AllianceChallenge.vue b/resources/js/Components/FlightsGoneBy/AllianceChallenge.vue index 35f1b3f..6e2ff2a 100644 --- a/resources/js/Components/FlightsGoneBy/AllianceChallenge.vue +++ b/resources/js/Components/FlightsGoneBy/AllianceChallenge.vue @@ -19,7 +19,7 @@ interface AirlineEntry { const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string alliance: Alliance airlines: Airline[] flights: Flight[] diff --git a/resources/js/Composables/useFlights.ts b/resources/js/Composables/useFlights.ts index 3cdd465..6a2cca5 100644 --- a/resources/js/Composables/useFlights.ts +++ b/resources/js/Composables/useFlights.ts @@ -9,9 +9,8 @@ export function useFlights(url: string, departedOnly: boolean = false) { onMounted(async () => { try { - const response = await axios.get(url, { - params: departedOnly ? { departed_only: true } : {} - }) + const requestUrl = departedOnly ? `${url}/departed` : url + const response = await axios.get(requestUrl) flights.value = response.data } finally { flightsLoading.value = false diff --git a/resources/js/Pages/Profile/Achievements/aircraft.all_airbus_a3xx.vue b/resources/js/Pages/Profile/Achievements/aircraft.all_airbus_a3xx.vue index 3658daf..6593893 100644 --- a/resources/js/Pages/Profile/Achievements/aircraft.all_airbus_a3xx.vue +++ b/resources/js/Pages/Profile/Achievements/aircraft.all_airbus_a3xx.vue @@ -12,7 +12,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] families: Record }>() diff --git a/resources/js/Pages/Profile/Achievements/aircraft.all_boeing_7x7.vue b/resources/js/Pages/Profile/Achievements/aircraft.all_boeing_7x7.vue index c144a8b..a7088ad 100644 --- a/resources/js/Pages/Profile/Achievements/aircraft.all_boeing_7x7.vue +++ b/resources/js/Pages/Profile/Achievements/aircraft.all_boeing_7x7.vue @@ -11,7 +11,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] families: Record }>() diff --git a/resources/js/Pages/Profile/Achievements/airlines_alliances.all_oneworld.vue b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_oneworld.vue index 7ab54db..f33bd83 100644 --- a/resources/js/Pages/Profile/Achievements/airlines_alliances.all_oneworld.vue +++ b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_oneworld.vue @@ -5,7 +5,7 @@ defineOptions({ inheritAttrs: false }) defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string alliance: Alliance airlines: Airline[] flights: Flight[] @@ -16,7 +16,7 @@ defineProps<{ () diff --git a/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_one_way.vue b/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_one_way.vue index 3171394..fa86ff8 100644 --- a/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_one_way.vue +++ b/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_one_way.vue @@ -10,7 +10,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] continents: Continent[] }>() diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.airline_alphabet.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.airline_alphabet.vue index f88b97d..dd0f7c1 100644 --- a/resources/js/Pages/Profile/Achievements/fun_challenges.airline_alphabet.vue +++ b/resources/js/Pages/Profile/Achievements/fun_challenges.airline_alphabet.vue @@ -13,7 +13,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] }>() diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.airport_alphabet.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.airport_alphabet.vue index 7b33a4b..69f1b69 100644 --- a/resources/js/Pages/Profile/Achievements/fun_challenges.airport_alphabet.vue +++ b/resources/js/Pages/Profile/Achievements/fun_challenges.airport_alphabet.vue @@ -13,7 +13,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] }>() diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.australian_states.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.australian_states.vue index a27e1da..9af2d8b 100644 --- a/resources/js/Pages/Profile/Achievements/fun_challenges.australian_states.vue +++ b/resources/js/Pages/Profile/Achievements/fun_challenges.australian_states.vue @@ -16,7 +16,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] regions: Region[] }>() diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.brazilian_states.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.brazilian_states.vue index a42550d..29c96f7 100644 --- a/resources/js/Pages/Profile/Achievements/fun_challenges.brazilian_states.vue +++ b/resources/js/Pages/Profile/Achievements/fun_challenges.brazilian_states.vue @@ -16,7 +16,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] regions: Region[] }>() diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.canadian_provinces.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.canadian_provinces.vue index ae70522..0151a7e 100644 --- a/resources/js/Pages/Profile/Achievements/fun_challenges.canadian_provinces.vue +++ b/resources/js/Pages/Profile/Achievements/fun_challenges.canadian_provinces.vue @@ -17,7 +17,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] regions: Region[] }>() diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.chinese_provinces.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.chinese_provinces.vue index 7153274..14a06b6 100644 --- a/resources/js/Pages/Profile/Achievements/fun_challenges.chinese_provinces.vue +++ b/resources/js/Pages/Profile/Achievements/fun_challenges.chinese_provinces.vue @@ -16,7 +16,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] regions: Region[] }>() diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.us_states.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.us_states.vue index 30c1b4f..d0b5600 100644 --- a/resources/js/Pages/Profile/Achievements/fun_challenges.us_states.vue +++ b/resources/js/Pages/Profile/Achievements/fun_challenges.us_states.vue @@ -16,7 +16,7 @@ defineOptions({ inheritAttrs: false }) const props = defineProps<{ achievement: Achievement user: User - isFollowing: boolean + followStatus: string flights: Flight[] regions: Region[] }>() diff --git a/resources/js/Pages/Profile/UserAchievement.vue b/resources/js/Pages/Profile/UserAchievement.vue index cbc887b..65e55f4 100644 --- a/resources/js/Pages/Profile/UserAchievement.vue +++ b/resources/js/Pages/Profile/UserAchievement.vue @@ -20,7 +20,7 @@ const props = defineProps<{ userAchievement: UserAchievement | null user: User loggedInUser: User | null - isFollowing: boolean + followStatus: string flight_api_url: string regions: Region[] alliance: string | null @@ -57,7 +57,7 @@ const unlocked = computed(() => {