From 3d4c49644bf8f09d29c6eb9c36b1a5812f6d9eb3 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 16 Jul 2025 11:11:44 -0700 Subject: [PATCH 01/12] refactor(routes): re-organize web and api routes for improved organization and clarity --- routes/api.php | 220 ++++++++------ routes/web.php | 781 +++++++++++++++++-------------------------------- 2 files changed, 397 insertions(+), 604 deletions(-) diff --git a/routes/api.php b/routes/api.php index 1d7f5c64e4..6f324e2850 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,8 +2,8 @@ use App\Http\Controllers\API; use App\Http\Controllers\ApiController; +use App\Http\Controllers\OutboundController; use Illuminate\Support\Facades\Route; -use Illuminate\Http\Request; /* |-------------------------------------------------------------------------- @@ -16,123 +16,151 @@ | */ -Route::get('/homepage_data', function () { // Used from DeviceController, tested. - return App\Http\Controllers\ApiController::homepage_data(); +// ============================================================================= +// PUBLIC API ROUTES (No Authentication Required) +// ============================================================================= + +Route::prefix('')->group(function () { + // Homepage and statistics + Route::get('homepage_data', [ApiController::class, 'homepage_data']); + Route::get('party/{id}/stats', [ApiController::class, 'partyStats']); + Route::get('group/{id}/stats', [ApiController::class, 'groupStats']); + + // Outbound/Share information + Route::get('outbound/info/{type}/{id}/{format?}', [OutboundController::class, 'info']); + + // Device data (paginated) + Route::get('devices/{page}/{size}', [ApiController::class, 'getDevices']); + + // Notifications info (no auth required for count) + Route::get('users/{id}/notifications', [API\UserController::class, 'notifications']); + + // Talk/Discussion topics + Route::get('talk/topics/{tag?}', [API\DiscourseController::class, 'discussionTopics']); + + // Timezones + Route::get('timezones', [ApiController::class, 'timezones'])->withoutMiddleware('customApiAuth'); + Route::get('timezone', [API\TimeZoneController::class, 'lookup']); }); -Route::get('/party/{id}/stats', function ($id) { // Used from TRP.org. - return App\Http\Controllers\ApiController::partyStats($id); -}); - -Route::get('/group/{id}/stats', function ($id) { // Used from TRP.org. - return App\Http\Controllers\ApiController::groupStats($id); -}); - -Route::get('/outbound/info/{type}/{id}/{format?}', function ($type, $id, $format = 'fixometer') { // Used from share plugins, tested. - return App\Http\Controllers\OutboundController::info($type, $id, $format); -}); +// ============================================================================= +// AUTHENTICATED API ROUTES (v1 - Legacy) +// ============================================================================= Route::middleware('auth:api')->group(function () { - Route::get('/users/me', [ApiController::class, 'getUserInfo']); // Not used but worth keeping and tested. - Route::get('/users', [ApiController::class, 'getUserList']); // Not used but worth keeping and tested. - Route::get('/users/changes', [API\UserController::class, 'changes']); // Used by Zapier - - Route::get('/networks/{network}/stats/', [API\NetworkController::class, 'stats']); // Used by RepairTogether. + // User management + Route::prefix('users')->group(function () { + Route::get('me', [ApiController::class, 'getUserInfo']); + Route::get('/', [ApiController::class, 'getUserList']); + Route::get('changes', [API\UserController::class, 'changes']); // Used by Zapier + }); - Route::prefix('/groups')->group(function() { + // Network management + Route::prefix('networks')->group(function () { + Route::get('{network}/stats', [API\NetworkController::class, 'stats']); // Used by RepairTogether + }); - Route::get('/', [API\GroupController::class, 'getGroupList']); // Not used but worth keeping and tested. - Route::get('/changes', [API\GroupController::class, 'getGroupChanges']); // Used by Zapier - Route::get('/network/', [API\GroupController::class, 'getGroupsByUsersNetworks']); // Used by Repair Together. + // Group management + Route::prefix('groups')->group(function () { + Route::get('/', [API\GroupController::class, 'getGroupList']); + Route::get('changes', [API\GroupController::class, 'getGroupChanges']); // Used by Zapier + Route::get('network', [API\GroupController::class, 'getGroupsByUsersNetworks']); // Used by Repair Together }); - Route::prefix('/events')->group(function() { - Route::get('/network/{date_from?}/{date_to?}', [API\EventController::class, 'getEventsByUsersNetworks']); // Used by Repair Together. + // Event management + Route::prefix('events')->group(function () { + Route::get('network/{date_from?}/{date_to?}', [API\EventController::class, 'getEventsByUsersNetworks']); // Used by Repair Together Route::get('{id}/volunteers', [API\EventController::class, 'listVolunteers']); Route::put('{id}/volunteers', [API\EventController::class, 'addVolunteer']); }); - Route::get('/usersgroups/changes', [API\UserGroupsController::class, 'changes']); // Used by Zapier - Route::delete('/usersgroups/{id}', [API\UserGroupsController::class, 'leave']); // Used by Vue client. + // User-Group relationships + Route::prefix('usersgroups')->group(function () { + Route::get('changes', [API\UserGroupsController::class, 'changes']); // Used by Zapier + Route::delete('{id}', [API\UserGroupsController::class, 'leave']); // Used by Vue client + }); }); -Route::get('/devices/{page}/{size}', [App\Http\Controllers\ApiController::class, 'getDevices']); // Used by Vue client. - -// Notifications info. We don't authenticate this, as API keys don't exist for all users. There's no real privacy -// issue with exposing the number of outstanding notifications. -Route::get('/users/{id}/notifications', [API\UserController::class, 'notifications']); - -// Top Talk topics. Doesn't need authentication either. -Route::get('/talk/topics/{tag?}', [API\DiscourseController::class, 'discussionTopics']); - -// Timezones - Publicly accessible endpoint -Route::get('/timezones', [App\Http\Controllers\ApiController::class, 'timezones']) - ->withoutMiddleware(\App\Http\Middleware\CustomApiTokenAuth::class); // Explicitly bypass authentication - -// We are working towards a new and more coherent API. -Route::prefix('v2')->group(function() { - Route::middleware(\App\Http\Middleware\APISetLocale::class)->group(function() { - Route::prefix('/groups')->group(function() { - Route::get('/names', [API\GroupController::class, 'listNamesv2']); - Route::get('/tags', [API\GroupController::class, 'listTagsv2']); - Route::get('{id}/events', [API\GroupController::class, 'getEventsForGroupv2']); - Route::get('{id}', [API\GroupController::class, 'getGroupv2']); - Route::post('', [API\GroupController::class, 'createGroupv2']); - Route::patch('{id}', [API\GroupController::class, 'updateGroupv2']); - - Route::get('{id}/volunteers', [API\GroupController::class, 'getVolunteersForGroupv2']); - Route::middleware('auth:api')->group(function () - { - Route::patch('{id}/volunteers/{iduser}', [API\GroupController::class, 'patchVolunteerForGroupv2']); - Route::delete('{id}/volunteers/{iduser}', [API\GroupController::class, 'deleteVolunteerForGroupv2']); - }); +// ============================================================================= +// API v2 - Modern RESTful API +// ============================================================================= + +Route::prefix('v2')->middleware(\App\Http\Middleware\APISetLocale::class)->group(function () { + + // Groups API + Route::prefix('groups')->group(function () { + // Public group endpoints + Route::get('names', [API\GroupController::class, 'listNamesv2']); + Route::get('tags', [API\GroupController::class, 'listTagsv2']); + Route::get('{id}', [API\GroupController::class, 'getGroupv2']); + Route::get('{id}/events', [API\GroupController::class, 'getEventsForGroupv2']); + Route::get('{id}/volunteers', [API\GroupController::class, 'getVolunteersForGroupv2']); + + Route::post('/', [API\GroupController::class, 'createGroupv2']); + + Route::patch('{id}', [API\GroupController::class, 'updateGroupv2']); + + // Authenticated group endpoints + Route::middleware(['auth:api'])->group(function () { + Route::patch('{id}/volunteers/{iduser}', [API\GroupController::class, 'patchVolunteerForGroupv2']); + Route::delete('{id}/volunteers/{iduser}', [API\GroupController::class, 'deleteVolunteerForGroupv2']); }); + }); - Route::prefix('/events')->group(function() { - Route::get('{id}', [API\EventController::class, 'getEventv2']); - Route::post('', [API\EventController::class, 'createEventv2']); - Route::patch('{id}', [API\EventController::class, 'updateEventv2']); - }); + // Events API + Route::prefix('events')->group(function () { + Route::get('{id}', [API\EventController::class, 'getEventv2']); - Route::prefix('/networks')->group(function() { - Route::get('/', [API\NetworkController::class, 'getNetworksv2']); - Route::get('{id}', [API\NetworkController::class, 'getNetworkv2']); - Route::get('{id}/groups', [API\NetworkController::class, 'getNetworkGroupsv2']); - Route::get('{id}/events', [API\NetworkController::class, 'getNetworkEventsv2']); - }); + Route::post('/', [API\EventController::class, 'createEventv2']); - Route::prefix('/moderate')->group(function() { - Route::middleware('auth:api')->group(function () - { - Route::get('/groups', [API\GroupController::class, 'moderateGroupsv2']); - Route::get('/events', [API\EventController::class, 'moderateEventsv2']); - }); - }); + Route::patch('{id}', [API\EventController::class, 'updateEventv2']); + }); + + // Networks API + Route::prefix('networks')->group(function () { + Route::get('/', [API\NetworkController::class, 'getNetworksv2']); + Route::get('{id}', [API\NetworkController::class, 'getNetworkv2']); + Route::get('{id}/groups', [API\NetworkController::class, 'getNetworkGroupsv2']); + Route::get('{id}/events', [API\NetworkController::class, 'getNetworkEventsv2']); + }); + + // Devices API + Route::prefix('devices')->group(function () { + Route::get('{id}', [API\DeviceController::class, 'getDevicev2']); + + Route::post('/', [API\DeviceController::class, 'createDevicev2']); - // Admin Groups Management API - Route::prefix('/admin/groups')->middleware(['auth:api', App\Http\Middleware\AdminMiddleware::class])->group(function() { + Route::patch('{id}', [API\DeviceController::class, 'updateDevicev2']); + + Route::delete('{id}', [API\DeviceController::class, 'deleteDevicev2']); + }); + + // Items API + Route::get('items', [API\ItemController::class, 'listItemsv2']); + + // Alerts API + Route::prefix('alerts')->group(function () { + Route::get('/', [API\AlertController::class, 'listAlertsv2']); + + Route::put('/', [API\AlertController::class, 'addAlertv2']); + + Route::patch('{id}', [API\AlertController::class, 'updateAlertv2']); + }); + + // Moderation API + Route::prefix('moderate')->middleware('auth:api')->group(function () { + Route::get('groups', [API\GroupController::class, 'moderateGroupsv2']); + Route::get('events', [API\EventController::class, 'moderateEventsv2']); + }); + + // Admin API + Route::prefix('admin')->middleware(['auth:api', 'admin'])->group(function () { + Route::prefix('groups')->group(function () { Route::get('/', [App\Http\Controllers\API\GroupsController::class, 'index']); - Route::get('/export', [App\Http\Controllers\API\GroupsController::class, 'exportGroups']); + Route::get('export', [App\Http\Controllers\API\GroupsController::class, 'exportGroups']); Route::post('import', [App\Http\Controllers\API\GroupsController::class, 'importGroups']); Route::post('bulk/{action}', [App\Http\Controllers\API\GroupsController::class, 'performBulkActions']); Route::post('{id}/{action}', [App\Http\Controllers\API\GroupsController::class, 'performSingleAction']); }); - - Route::get('/items', [API\ItemController::class, 'listItemsv2']); - - Route::prefix('/alerts')->group(function() { - Route::get('/', [API\AlertController::class, 'listAlertsv2']); - Route::put('/', [API\AlertController::class, 'addAlertv2']); - Route::patch('/{id}', [API\AlertController::class, 'updateAlertv2']); - }); - - Route::prefix('/devices')->group(function() { - Route::get('{id}', [API\DeviceController::class, 'getDevicev2']); - Route::post('', [API\DeviceController::class, 'createDevicev2']); - Route::patch('{id}', [API\DeviceController::class, 'updateDevicev2']); - Route::delete('{id}', [API\DeviceController::class, 'deleteDevicev2']); - }); }); -}); - -Route::get('/timezone', [API\TimeZoneController::class, 'lookup']); \ No newline at end of file +}); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index c5b41dd8ae..f5916f599b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,36 +1,28 @@ route($fallbackRoute); + }; +} + +/** + * Helper function to register gamification routes that redirect to discourse + */ +function registerGameRoutes($prefix) { + Route::prefix($prefix)->group(function() { + Route::get('/', redirectToDiscourse()); + Route::get('/{any}', redirectToDiscourse()); + }); +} + +// ============================================================================= +// PUBLIC ROUTES (No Authentication Required) +// ============================================================================= + Route::middleware('ensureAPIToken')->group(function () { + + // === HOME & LANDING === + Route::middleware('guest')->group(function () { + Route::get('/', [HomeController::class, 'index'])->name('home'); + Route::get('/about', [HomeController::class, 'index']); + }); + + // === AUTHENTICATION & USER REGISTRATION === Route::prefix('user')->group(function () { Route::get('/', [HomeController::class, 'index']); Route::get('reset', [UserController::class, 'reset']); @@ -53,540 +83,273 @@ Route::get('register/{hash?}', [UserController::class, 'getRegister'])->name('registration'); Route::post('register/check-valid-email', [UserController::class, 'postEmail']); Route::post('register/{hash?}', [UserController::class, 'postRegister']); - Route::get('/thumbnail/', [UserController::class, 'getThumbnail']); - Route::get('/menus/', [UserController::class, 'getUserMenus']); + Route::get('thumbnail', [UserController::class, 'getThumbnail']); + Route::get('menus', [UserController::class, 'getUserMenus']); + Route::get('forbidden', fn() => view('user.forbidden', ['title' => 'Oops'])); }); - - Route::get('/user/forbidden', function () { - return view('user.forbidden', [ - 'title' => 'Oops', - ]); - }); - -// We use the Laravel login route. + + // Laravel auth routes Auth::routes(); - - Route::middleware('guest')->group(function () - { - Route::get('/', [HomeController::class, 'index'])->name('home'); - }); - -// We are not using Laravel's default registration methods. So we redirect /register to /user/register. Route::redirect('register', '/user/register'); - Route::get('/logout', [UserController::class, 'logout']); - - Route::get('/about/cookie-policy', function () { - return View::make('features.cookie-policy'); - }); - -// Temp - Route::get('/visualisations', function () { - return View::make('visualisations'); - }); - - Route::get('/party/view/{id}', [PartyController::class, 'view']); - - // Device export is also called from https://therestartproject.org/download-dataset, - // so we allow anonymous access. + Route::get('logout', [UserController::class, 'logout']); + + // === PARTY/EVENT VIEWING === + Route::get('party/view/{id}', [PartyController::class, 'view']); + + // === INVITATION ACCEPTANCE === + Route::get('party/invite/{code}', [PartyController::class, 'confirmCodeInvite']); + Route::get('group/invite/{code}', [GroupController::class, 'confirmCodeInvite']); + + // === STATIC PAGES === + Route::get('about/cookie-policy', fn() => View::make('features.cookie-policy')); + Route::get('visualisations', fn() => View::make('visualisations')); + + // === EXPORT ROUTES (Public access for external downloads) === Route::prefix('export')->group(function() { - Route::get('/devices/event/{id}', [ExportController::class, 'devicesEvent']); - Route::get('/devices/group/{id}', [ExportController::class, 'devicesGroup']); - Route::get('/devices', [ExportController::class, 'devices']); - Route::get('/groups/{id}/events', [ExportController::class, 'groupEvents']); - Route::get('/networks/{id}/events', [ExportController::class, 'networkEvents']); + Route::get('devices/event/{id}', [ExportController::class, 'devicesEvent']); + Route::get('devices/group/{id}', [ExportController::class, 'devicesGroup']); + Route::get('devices', [ExportController::class, 'devices']); + Route::get('groups/{id}/events', [ExportController::class, 'groupEvents']); + Route::get('networks/{id}/events', [ExportController::class, 'networkEvents']); }); - - // Calendar routes do not require authentication. - // (You would not be able to subscribe from a calendar application if they did.) + + // === CALENDAR ROUTES (Public for external calendar apps) === Route::prefix('calendar')->group(function () { - Route::get('/user/{calendar_hash}', [CalendarEventsController::class, 'allEventsByUser'])->name('calendar-events-by-user'); - Route::get('/group/{group}', [CalendarEventsController::class, 'allEventsByGroup'])->name('calendar-events-by-group'); - Route::get('/network/{network}', [CalendarEventsController::class, 'allEventsByNetwork'])->name('calendar-events-by-network'); - Route::get('/group-area/{area}', [CalendarEventsController::class, 'allEventsByArea'])->name('calendar-events-by-area'); - Route::get('/all-events/{hash_env}', [CalendarEventsController::class, 'allEvents'])->name('calendar-events-all'); - }); - - Route::prefix('FaultCat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('faultcat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('MiscCat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('misccat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); + Route::get('user/{calendar_hash}', [CalendarEventsController::class, 'allEventsByUser']) + ->name('calendar-events-by-user'); + Route::get('group/{group}', [CalendarEventsController::class, 'allEventsByGroup']) + ->name('calendar-events-by-group'); + Route::get('network/{network}', [CalendarEventsController::class, 'allEventsByNetwork']) + ->name('calendar-events-by-network'); + Route::get('group-area/{area}', [CalendarEventsController::class, 'allEventsByArea']) + ->name('calendar-events-by-area'); + Route::get('all-events/{hash_env}', [CalendarEventsController::class, 'allEvents']) + ->name('calendar-events-all'); }); - - Route::prefix('MobiFix')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('mobifix')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('MobiFixOra')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('mobifixora')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('TabiCat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('tabicat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('PrintCat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('printcat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('BattCat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('battcat')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('DustUp')->group(function () { - Route::get('/', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::prefix('dustup')->group(function () { - Route::get('/{any}', function() { - if (config('restarters.features.discourse_integration')) { - return redirect('https://talk.restarters.net/t/our-work-on-repair-data/1150'); - } else { - return redirect()->route('home'); - } - }); - }); - - Route::middleware('guest')->group(function () { - Route::get('/', [HomeController::class, 'index'])->name('home'); - Route::get('/about', [HomeController::class, 'index'])->name('home'); + + // === GAMIFICATION ROUTES (Redirect to Discourse) === + registerGameRoutes('FaultCat'); + registerGameRoutes('faultcat'); + registerGameRoutes('MiscCat'); + registerGameRoutes('misccat'); + registerGameRoutes('MobiFix'); + registerGameRoutes('mobifix'); + registerGameRoutes('MobiFixOra'); + registerGameRoutes('mobifixora'); + registerGameRoutes('TabiCat'); + registerGameRoutes('tabicat'); + registerGameRoutes('PrintCat'); + registerGameRoutes('printcat'); + registerGameRoutes('BattCat'); + registerGameRoutes('battcat'); + registerGameRoutes('DustUp'); + registerGameRoutes('dustup'); + + // === IFRAME ROUTES === + Route::get('outbound/info/{type}/{id}/{format?}', [OutboundController::class, 'info']); + Route::get('group/stats/{id}/{format?}', [GroupController::class, 'stats']); + Route::get('group-tag/stats/{group_tag_id}/{format?}', [GroupController::class, 'statsByGroupTag']); + Route::get('admin/stats/{type?}', [AdminController::class, 'stats']); + Route::get('party/stats/{id}/wide', [PartyController::class, 'stats']); + + // === UTILITY ROUTES === + Route::get('markAsRead/{id?}', function ($id = null) { + $notifications = auth()->user()->unReadNotifications; + if ($id) { + $notifications = $notifications->where('id', $id); + } + $notifications->markAsRead(); + return redirect()->back(); + })->name('markAsRead'); + + Route::get('set-lang/{locale}', [LocaleController::class, 'setLang']); + Route::post('set-cookie', InformationAlertCookieController::class); + + // === DEVELOPMENT & TESTING === + Route::get('test/check-auth', fn() => new \App\Services\CheckAuthService); + + // === STYLE GUIDE === + Route::prefix('style')->group(function () { + Route::get('/', [StyleController::class, 'index']); + Route::get('guide', [StyleController::class, 'guide']); + Route::get('find', [StyleController::class, 'find']); }); }); -Route::middleware('auth', 'verifyUserConsent', 'ensureAPIToken')->group(function () { - //User Controller +// ============================================================================= +// AUTHENTICATED ROUTES +// ============================================================================= + +Route::middleware(['auth', 'verifyUserConsent', 'ensureAPIToken'])->group(function () { + + // === USER PROFILE & SETTINGS === Route::prefix('profile')->group(function () { Route::get('/', [UserController::class, 'index'])->name('profile'); - Route::get('/notifications', [UserController::class, 'getNotifications'])->name('notifications'); - Route::get('/edit/{id?}', [UserController::class, 'getProfileEdit'])->name('edit-profile'); - Route::get('/{id}', [UserController::class, 'index']); - Route::post('/edit-info', [UserController::class, 'postProfileInfoEdit']); - Route::post('/edit-password', [UserController::class, 'postProfilePasswordEdit']); - Route::post('/edit-language', [UserController::class, 'storeLanguage']); - Route::post('/edit-preferences', [UserController::class, 'postProfilePreferencesEdit']); - Route::post('/edit-tags', [UserController::class, 'postProfileTagsEdit']); - Route::post('/edit-photo', [UserController::class, 'postProfilePictureEdit']); - Route::post('/edit-admin-settings', [UserController::class, 'postAdminEdit']); - Route::post('/edit-repair-directory', [UserController::class, 'postProfileRepairDirectory']); + Route::get('notifications', [UserController::class, 'getNotifications'])->name('notifications'); + Route::get('edit/{id?}', [UserController::class, 'getProfileEdit'])->name('edit-profile'); + Route::get('{id}', [UserController::class, 'index']); + + // Profile update routes + Route::post('edit-info', [UserController::class, 'postProfileInfoEdit']); + Route::post('edit-password', [UserController::class, 'postProfilePasswordEdit']); + Route::post('edit-language', [UserController::class, 'storeLanguage']); + Route::post('edit-preferences', [UserController::class, 'postProfilePreferencesEdit']); + Route::post('edit-tags', [UserController::class, 'postProfileTagsEdit']); + Route::post('edit-photo', [UserController::class, 'postProfilePictureEdit']); + Route::post('edit-admin-settings', [UserController::class, 'postAdminEdit']); + Route::post('edit-repair-directory', [UserController::class, 'postProfileRepairDirectory']); }); - + + // === USER MANAGEMENT === Route::prefix('user')->group(function () { - Route::post('/create', [UserController::class, 'create']); - Route::get('/all', [UserController::class, 'all'])->name('users'); - Route::get('/all/search', [UserController::class, 'search']); - Route::get('/edit/{id}', [UserController::class, 'getProfileEdit']); - Route::post('/edit/{id}', [UserController::class, 'edit']); - Route::post('/soft-delete', [UserController::class, 'postSoftDeleteUser']); - Route::get('/onboarding-complete', [UserController::class, 'getOnboardingComplete']); - }); - - //Admin Controller - Route::prefix('admin')->middleware(App\Http\Middleware\AdminMiddleware::class)->group(function () { - Route::get('/stats', [AdminController::class, 'stats']); - Route::get('/groups', [AdminController::class, 'groups'])->name('admin.groups'); + Route::post('create', [UserController::class, 'create']); + Route::get('all', [UserController::class, 'all'])->name('users'); + Route::get('all/search', [UserController::class, 'search']); + Route::get('edit/{id}', [UserController::class, 'getProfileEdit']); + Route::post('edit/{id}', [UserController::class, 'edit']); + Route::post('soft-delete', [UserController::class, 'postSoftDeleteUser']); + Route::get('onboarding-complete', [UserController::class, 'getOnboardingComplete']); }); - - //Category Controller - Route::prefix('category')->group(function () { - Route::get('/', [CategoryController::class, 'index'])->name('category'); - Route::get('/edit/{id}', [CategoryController::class, 'getEditCategory']); - Route::post('/edit/{id}', [CategoryController::class, 'postEditCategory']); - }); - - //Dashboard Controller + + // === DASHBOARD === Route::prefix('dashboard')->group(function () { - Route::get('/', [DashboardController::class, 'index'])->name('dashboard')->middleware('AcceptUserInvites'); - Route::get('/host', [DashboardController::class, 'getHostDash']); + Route::get('/', [DashboardController::class, 'index']) + ->name('dashboard') + ->middleware('AcceptUserInvites'); + Route::get('host', [DashboardController::class, 'getHostDash']); }); - + + // === FIXOMETER (DEVICES) === Route::prefix('fixometer')->group(function () { Route::get('/', [DeviceController::class, 'index'])->name('devices'); }); - - // TODO: the rest of these to be redirected properly. + Route::prefix('device')->group(function () { - Route::get('/', function () { - return redirect('/fixometer'); - }); - Route::get('/search', [DeviceController::class, 'search']); - Route::post('/image-upload/{id}', [DeviceController::class, 'imageUpload']); - Route::get('/image/delete/{iddevices}/{idxref}', [DeviceController::class, 'deleteImage']); + Route::get('/', fn() => redirect('/fixometer')); + Route::get('search', [DeviceController::class, 'search']); + Route::post('image-upload/{id}', [DeviceController::class, 'imageUpload']); + Route::get('image/delete/{iddevices}/{idxref}', [DeviceController::class, 'deleteImage']); }); - - Route::resource('networks', NetworkController::class)->only([ - 'index', 'show', 'edit', 'update' - ]); - Route::prefix('networks')->group(function () { - Route::post('/{network}/groups', [NetworkController::class, 'associateGroup'])->name('networks.associate-group'); - }); - - //Group Controller + + // === NETWORKS === + Route::resource('networks', NetworkController::class)->only(['index', 'show', 'edit', 'update']); + Route::post('networks/{network}/groups', [NetworkController::class, 'associateGroup']) + ->name('networks.associate-group'); + + // === GROUPS === Route::prefix('group')->group(function () { - Route::get('/create', [GroupController::class, 'create'])->name('create-group'); - Route::post('/create', [GroupController::class, 'create']); - Route::get('/edit/{id}', [GroupController::class, 'edit']); - Route::post('/edit/{id}', [GroupController::class, 'edit']); - Route::get('/view/{id}', [GroupController::class, 'view'])->name('group.show'); - Route::post('/invite', [GroupController::class, 'postSendInvite']); - Route::get('/accept-invite/{id}/{hash}', [GroupController::class, 'confirmInvite']); - Route::get('/join/{id}', [GroupController::class, 'getJoinGroup']); - Route::post('/image-upload/{id}', [GroupController::class, 'imageUpload']); - Route::get('/image/delete/{idgroups}/{id}/{path}', [GroupController::class, 'ajaxDeleteImage']); Route::get('/', [GroupController::class, 'mine'])->name('groups'); - Route::get('/all', [GroupController::class, 'all']); - Route::get('/mine', [GroupController::class, 'mine']); - Route::get('/nearby', [GroupController::class, 'nearby']); - Route::get('/network/{id}', [GroupController::class, 'network']); - Route::get('/nearbyinvite/{groupId}/{userId}', [GroupController::class, 'inviteNearbyRestarter']); - Route::get('/delete/{id}', [GroupController::class, 'delete']); + Route::get('create', [GroupController::class, 'create'])->name('create-group'); + Route::post('create', [GroupController::class, 'create']); + Route::get('edit/{id}', [GroupController::class, 'edit']); + Route::post('edit/{id}', [GroupController::class, 'edit']); + Route::get('view/{id}', [GroupController::class, 'view'])->name('group.show'); + Route::get('all', [GroupController::class, 'all']); + Route::get('mine', [GroupController::class, 'mine']); + Route::get('nearby', [GroupController::class, 'nearby']); + Route::get('network/{id}', [GroupController::class, 'network']); + Route::get('join/{id}', [GroupController::class, 'getJoinGroup']); + Route::get('delete/{id}', [GroupController::class, 'delete']); + + // Group invitations + Route::post('invite', [GroupController::class, 'postSendInvite']); + Route::get('accept-invite/{id}/{hash}', [GroupController::class, 'confirmInvite']); + Route::get('nearbyinvite/{groupId}/{userId}', [GroupController::class, 'inviteNearbyRestarter']); + + // Group images + Route::post('image-upload/{id}', [GroupController::class, 'imageUpload']); + Route::get('image/delete/{idgroups}/{id}/{path}', [GroupController::class, 'ajaxDeleteImage']); }); - - //Outbound Controller - Route::get('/outbound', [OutboundController::class, 'index']); - - //Party Controller + + // === EVENTS (PARTIES) === Route::prefix('party')->group(function () { Route::get('/', [PartyController::class, 'index'])->name('events'); - Route::get('/all', [PartyController::class, 'allUpcoming'])->name('all-upcoming-events'); - Route::get('/all-past', [PartyController::class, 'allPast'])->name('all-past-events'); - Route::get('/group/{group_id?}', [PartyController::class, 'index'])->name('group-events'); - Route::get('/create/{group_id?}', [PartyController::class, 'create']); - Route::get('/edit/{id}', [PartyController::class, 'edit']); - Route::post('/edit/{id}', [PartyController::class, 'edit']); - Route::get('/duplicate/{id}', [PartyController::class, 'duplicate']); - Route::post('/delete/{id}', [PartyController::class, 'deleteEvent']); - Route::get('/deleteimage', [PartyController::class, 'deleteimage']); - Route::get('/join/{id}', [PartyController::class, 'getJoinEvent']); - Route::post('/invite', [PartyController::class, 'postSendInvite']); - Route::get('/accept-invite/{id}/{hash}', [PartyController::class, 'confirmInvite']); - Route::get('/cancel-invite/{id}', [PartyController::class, 'cancelInvite']); - Route::post('/remove-volunteer', [PartyController::class, 'removeVolunteer']); - Route::get('/get-group-emails-with-names/{event_id}', [PartyController::class, 'getGroupEmailsWithNames']); - Route::post('/update-quantity', [PartyController::class, 'updateQuantity']); - Route::post('/image-upload/{id}', [PartyController::class, 'imageUpload']); - Route::get('/image/delete/{idevents}/{id}/{path}', [PartyController::class, 'deleteImage']); - Route::get('/contribution/{id}', [PartyController::class, 'getContributions']); - Route::post('/update-volunteerquantity', [PartyController::class, 'updateVolunteerQuantity']); + Route::get('all', [PartyController::class, 'allUpcoming'])->name('all-upcoming-events'); + Route::get('all-past', [PartyController::class, 'allPast'])->name('all-past-events'); + Route::get('group/{group_id?}', [PartyController::class, 'index'])->name('group-events'); + Route::get('create/{group_id?}', [PartyController::class, 'create']); + Route::get('edit/{id}', [PartyController::class, 'edit']); + Route::post('edit/{id}', [PartyController::class, 'edit']); + Route::get('duplicate/{id}', [PartyController::class, 'duplicate']); + Route::post('delete/{id}', [PartyController::class, 'deleteEvent']); + Route::get('deleteimage', [PartyController::class, 'deleteimage']); + Route::get('join/{id}', [PartyController::class, 'getJoinEvent']); + Route::get('contribution/{id}', [PartyController::class, 'getContributions']); + + // Event invitations & volunteers + Route::post('invite', [PartyController::class, 'postSendInvite']); + Route::get('accept-invite/{id}/{hash}', [PartyController::class, 'confirmInvite']); + Route::get('cancel-invite/{id}', [PartyController::class, 'cancelInvite']); + Route::post('remove-volunteer', [PartyController::class, 'removeVolunteer']); + Route::get('get-group-emails-with-names/{event_id}', [PartyController::class, 'getGroupEmailsWithNames']); + Route::post('update-quantity', [PartyController::class, 'updateQuantity']); + Route::post('update-volunteerquantity', [PartyController::class, 'updateVolunteerQuantity']); + + // Event images + Route::post('image-upload/{id}', [PartyController::class, 'imageUpload']); + Route::get('image/delete/{idevents}/{id}/{path}', [PartyController::class, 'deleteImage']); }); - - //Role Controller + + // === ADMINISTRATIVE ROUTES === + + // Admin Dashboard + Route::prefix('admin')->middleware(App\Http\Middleware\AdminMiddleware::class)->group(function () { + Route::get('stats', [AdminController::class, 'stats']); + Route::get('groups', [AdminController::class, 'groups'])->name('admin.groups'); + }); + + // Categories + Route::prefix('category')->group(function () { + Route::get('/', [CategoryController::class, 'index'])->name('category'); + Route::get('edit/{id}', [CategoryController::class, 'getEditCategory']); + Route::post('edit/{id}', [CategoryController::class, 'postEditCategory']); + }); + + // Roles Route::prefix('role')->group(function () { Route::get('/', [RoleController::class, 'index'])->name('roles'); - Route::get('/edit/{id}', [RoleController::class, 'edit']); - Route::post('/edit/{id}', [RoleController::class, 'edit']); + Route::get('edit/{id}', [RoleController::class, 'edit']); + Route::post('edit/{id}', [RoleController::class, 'edit']); }); - - //Brand Controller + + // Brands Route::prefix('brands')->group(function () { Route::get('/', [BrandsController::class, 'index'])->name('brands'); - Route::get('/create', [BrandsController::class, 'getCreateBrand']); - Route::post('/create', [BrandsController::class, 'postCreateBrand']); - Route::get('/edit/{id}', [BrandsController::class, 'getEditBrand']); - Route::post('/edit/{id}', [BrandsController::class, 'postEditBrand']); - Route::get('/delete/{id}', [BrandsController::class, 'getDeleteBrand']); + Route::get('create', [BrandsController::class, 'getCreateBrand']); + Route::post('create', [BrandsController::class, 'postCreateBrand']); + Route::get('edit/{id}', [BrandsController::class, 'getEditBrand']); + Route::post('edit/{id}', [BrandsController::class, 'postEditBrand']); + Route::get('delete/{id}', [BrandsController::class, 'getDeleteBrand']); }); - - //Skills Controller + + // Skills Route::prefix('skills')->group(function () { Route::get('/', [SkillsController::class, 'index'])->name('skills'); - Route::post('/create', [SkillsController::class, 'postCreateSkill']); - Route::get('/edit/{id}', [SkillsController::class, 'getEditSkill']); - Route::post('/edit/{id}', [SkillsController::class, 'postEditSkill']); - Route::get('/delete/{id}', [SkillsController::class, 'getDeleteSkill']); + Route::post('create', [SkillsController::class, 'postCreateSkill']); + Route::get('edit/{id}', [SkillsController::class, 'getEditSkill']); + Route::post('edit/{id}', [SkillsController::class, 'postEditSkill']); + Route::get('delete/{id}', [SkillsController::class, 'getDeleteSkill']); }); - - //GroupTags Controller + + // Group Tags Route::prefix('tags')->group(function () { Route::get('/', [GroupTagsController::class, 'index'])->name('tags'); - Route::post('/create', [GroupTagsController::class, 'postCreateTag']); - Route::get('/edit/{id}', [GroupTagsController::class, 'getEditTag']); - Route::post('/edit/{id}', [GroupTagsController::class, 'postEditTag']); - Route::get('/delete/{id}', [GroupTagsController::class, 'getDeleteTag']); + Route::post('create', [GroupTagsController::class, 'postCreateTag']); + Route::get('edit/{id}', [GroupTagsController::class, 'getEditTag']); + Route::post('edit/{id}', [GroupTagsController::class, 'postEditTag']); + Route::get('delete/{id}', [GroupTagsController::class, 'getDeleteTag']); }); + + // Outbound + Route::get('outbound', [OutboundController::class, 'index']); }); -Route::middleware('ensureAPIToken')->group(function () { - Route::get('/party/invite/{code}', [PartyController::class, 'confirmCodeInvite']); - Route::get('/group/invite/{code}', [GroupController::class, 'confirmCodeInvite']); - -//iFrames - Route::get('/outbound/info/{type}/{id}/{format?}', function ($type, $id, $format = 'fixometer') { - return App\Http\Controllers\OutboundController::info($type, $id, $format); - }); - - Route::get('/group/stats/{id}/{format?}', function ($id, $format = 'row') { - return App\Http\Controllers\GroupController::stats($id, $format); - }); - - Route::get('/group-tag/stats/{group_tag_id}/{format?}', function ($group_tag_id, $format = 'row') { - return App\Http\Controllers\GroupController::statsByGroupTag($group_tag_id, $format); - }); - - Route::get('/admin/stats/1', function () { - return App\Http\Controllers\AdminController::stats(); - }); - - Route::get('/admin/stats/2', function () { - return App\Http\Controllers\AdminController::stats(2); - }); - - Route::get('/party/stats/{id}/wide', function ($id) { - return App\Http\Controllers\PartyController::stats($id); - }); - - Route::get('markAsRead/{id?}', function ($id = null) { - $notifications = auth()->user()->unReadNotifications; - - if ($id) { - $notifications = $notifications->where('id', $id); - } - - $notifications->markAsRead(); - - return redirect()->back(); - })->name('markAsRead'); - - Route::get('/set-lang/{locale}', [LocaleController::class, 'setLang']); - - Route::post('/set-cookie', InformationAlertCookieController::class); - - Route::get('/test/check-auth', function () { - return new \App\Services\CheckAuthService; - }); - - Route::prefix('style')->group(function () { - Route::get('/', [StyleController::class, 'index']); - Route::get('/guide', [StyleController::class, 'guide']); - Route::get('/find', [StyleController::class, 'find']); - }); -}); +// ============================================================================= +// SYSTEM ROUTES +// ============================================================================= // Health check endpoint -Route::get('/healthz', function () { +Route::get('healthz', function () { $checks = []; $allPassed = true; @@ -620,11 +383,13 @@ return response()->json($response, $allPassed ? 200 : 503); }); -// Useful code to log all queries. This is particularly useful when trying to reduce the number of queries; if -// Laravel debug is turned on then the Queries tab on the client shows them briefly and then gets reset. That's -// long enough to spot pages with too many queries, but not long enough to see what they are. -//\DB::listen(function($sql) { -// \Log::info($sql->sql); -// \Log::info($sql->bindings); -// \Log::info($sql->time); -//}); \ No newline at end of file +// ============================================================================= +// DEVELOPMENT HELPERS +// ============================================================================= + +// Useful code to log all queries during development +// \DB::listen(function($sql) { +// \Log::info($sql->sql); +// \Log::info($sql->bindings); +// \Log::info($sql->time); +// }); \ No newline at end of file From 56f79aa4806a222156c768c669c0a39f123c9ca3 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 16 Jul 2025 13:07:54 -0700 Subject: [PATCH 02/12] feat(auth): implement centralized authentication strategy and configuration - Added CentralizedAuth middleware to manage authentication flows - Introduced LocalAuthStrategy and iFixitAuthStrategy for different auth methods - Updated .env and config files to include new auth settings - Removed EnsureAPIToken and VerifyUserConsent middleware as they are now integrated - Refactored routes to utilize centralizedAuth middleware for improved auth handling This will allow us to easily configure and switch between different authentication methods. We will actually be implmenenting the iFixitAuthService in later commits. --- .env.base | 7 ++ .env.template | 7 ++ app/Http/Middleware/CentralizedAuth.php | 74 +++++++++++++++++ app/Http/Middleware/EnsureAPIToken.php | 35 -------- app/Http/Middleware/VerifyUserConsent.php | 24 ------ app/Providers/AuthServiceProvider.php | 52 ++++++++++++ app/Services/Auth/AuthStrategyInterface.php | 43 ++++++++++ app/Services/Auth/AuthStrategyManager.php | 92 +++++++++++++++++++++ app/Services/Auth/LocalAuthStrategy.php | 70 ++++++++++++++++ app/Services/Auth/iFixitAuthStrategy.php | 71 ++++++++++++++++ bootstrap/app.php | 4 +- config/restarters.php | 6 ++ routes/api.php | 8 +- routes/web.php | 4 +- 14 files changed, 430 insertions(+), 67 deletions(-) create mode 100644 app/Http/Middleware/CentralizedAuth.php delete mode 100644 app/Http/Middleware/EnsureAPIToken.php delete mode 100644 app/Http/Middleware/VerifyUserConsent.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 app/Services/Auth/AuthStrategyInterface.php create mode 100644 app/Services/Auth/AuthStrategyManager.php create mode 100644 app/Services/Auth/LocalAuthStrategy.php create mode 100644 app/Services/Auth/iFixitAuthStrategy.php diff --git a/.env.base b/.env.base index 483a796d63..89f7abb909 100644 --- a/.env.base +++ b/.env.base @@ -68,6 +68,13 @@ SESSION_TABLE=laravel_sessions QUEUE_CONNECTION=database SANCTUM_STATEFUL_DOMAINS= +# ============================================================================= +# AUTH CONFIGURATION +# ============================================================================= +AUTH_STRATEGY=local # 'local', 'ifixit' +AUTH_REQUIRE_CONSENT=true +AUTH_REQUIRE_API_TOKEN=true + # ============================================================================= # REDIS CONFIGURATION # ============================================================================= diff --git a/.env.template b/.env.template index ffe9907314..aac9dbc527 100644 --- a/.env.template +++ b/.env.template @@ -68,6 +68,13 @@ SESSION_TABLE="$SESSION_TABLE" QUEUE_CONNECTION="$QUEUE_CONNECTION" SANCTUM_STATEFUL_DOMAINS="$SANCTUM_STATEFUL_DOMAINS" +# ============================================================================= +# AUTH CONFIGURATION +# ============================================================================= +AUTH_STRATEGY="$AUTH_STRATEGY" # 'local', 'ifixit' +AUTH_REQUIRE_CONSENT="$AUTH_REQUIRE_CONSENT" +AUTH_REQUIRE_API_TOKEN="$AUTH_REQUIRE_API_TOKEN" + # ============================================================================= # REDIS CONFIGURATION # ============================================================================= diff --git a/app/Http/Middleware/CentralizedAuth.php b/app/Http/Middleware/CentralizedAuth.php new file mode 100644 index 0000000000..d9d2e75119 --- /dev/null +++ b/app/Http/Middleware/CentralizedAuth.php @@ -0,0 +1,74 @@ +authManager = $authManager; + } + + /** + * Handle an incoming request. + * + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + */ + public function handle(Request $request, Closure $next, ?string $mode = null): Response + { + $isOptionalAuth = $mode === 'optional'; + + // For optional auth mode, use default strategy but skip authentication enforcement + $authStrategy = $this->authManager->getStrategy($isOptionalAuth ? null : $mode); + + // Handle optional auth mode: allow public access but run auth logic if authenticated + if ($isOptionalAuth) { + return $this->handleOptionalAuth($request, $next, $authStrategy); + } + + // Standard authentication flow - enforce authentication and consent + return $this->handleRequiredAuth($request, $next, $authStrategy); + } + + /** + * Handle optional authentication: allow public access but run auth logic if user is authenticated + */ + private function handleOptionalAuth(Request $request, Closure $next, $authStrategy): Response + { + $response = $next($request); + + // Only run auth logic if user is actually authenticated + if ($authStrategy->isAuthenticated()) { + return $authStrategy->handlePostAuth($request, $response); + } + + return $response; + } + + /** + * Handle required authentication: enforce authentication and consent requirements + */ + private function handleRequiredAuth(Request $request, Closure $next, $authStrategy): Response + { + // Check if user is authenticated + if (!$authStrategy->isAuthenticated()) { + return $authStrategy->getUnauthenticatedResponse(); + } + + // Check if consent is required and given + if ($authStrategy->requiresConsent() && !$authStrategy->hasConsent()) { + return $authStrategy->getConsentResponse(); + } + + // Process the request and handle post-authentication tasks + $response = $next($request); + return $authStrategy->handlePostAuth($request, $response); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/EnsureAPIToken.php b/app/Http/Middleware/EnsureAPIToken.php deleted file mode 100644 index bec06e124b..0000000000 --- a/app/Http/Middleware/EnsureAPIToken.php +++ /dev/null @@ -1,35 +0,0 @@ -ensureAPIToken(); - - // Return the API token as a cookie. This means it can be picked up by the Vue client. - $response = $next($request); - - if (method_exists($response, 'withCookie')) { - $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, false, false)); - } - - return $response; - } else { - return $next($request); - } - } -} diff --git a/app/Http/Middleware/VerifyUserConsent.php b/app/Http/Middleware/VerifyUserConsent.php deleted file mode 100644 index a247c042cc..0000000000 --- a/app/Http/Middleware/VerifyUserConsent.php +++ /dev/null @@ -1,24 +0,0 @@ -hasUserGivenConsent()) { - return $next($request); - } else { - return redirect('/user/register'); - } - } -} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000000..81d2380234 --- /dev/null +++ b/app/Providers/AuthServiceProvider.php @@ -0,0 +1,52 @@ +registerPolicies(); + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + // Register the iFixit auth service + $this->app->singleton(iFixitAuthService::class, fn () => new iFixitAuthService()); + + // Register the auth strategy manager as a singleton + $this->app->singleton(AuthStrategyManager::class, fn () => new AuthStrategyManager()); + + // Register auth strategies + $this->app->bind(LocalAuthStrategy::class, fn () => new LocalAuthStrategy()); + + $this->app->bind(iFixitAuthStrategy::class, fn () => new iFixitAuthStrategy($this->app->make(iFixitAuthService::class))); + + // Register the current auth strategy based on config + $this->app->bind(AuthStrategyInterface::class, fn () => $this->app->make(AuthStrategyManager::class)->getStrategy()); + } +} \ No newline at end of file diff --git a/app/Services/Auth/AuthStrategyInterface.php b/app/Services/Auth/AuthStrategyInterface.php new file mode 100644 index 0000000000..d9c4b024be --- /dev/null +++ b/app/Services/Auth/AuthStrategyInterface.php @@ -0,0 +1,43 @@ +defaultStrategy = config('restarters.auth.strategy', 'local'); + $this->registerStrategies(); + } + + /** + * Register all available auth strategies + */ + private function registerStrategies(): void + { + $this->strategies = [ + 'local' => LocalAuthStrategy::class, + 'ifixit' => iFixitAuthStrategy::class, + ]; + } + + /** + * Get the current auth strategy instance + */ + public function getStrategy(?string $strategy = null): AuthStrategyInterface + { + $strategy ??= $this->defaultStrategy; + + if (!isset($this->strategies[$strategy])) { + throw new InvalidArgumentException("Auth strategy '{$strategy}' not found"); + } + + return app($this->strategies[$strategy]); + } + + + + /** + * Get the default strategy name + */ + public function getDefaultStrategy(): string + { + return $this->defaultStrategy; + } + + /** + * Set the default strategy + */ + public function setDefaultStrategy(string $strategy): void + { + if (!isset($this->strategies[$strategy])) { + throw new InvalidArgumentException("Auth strategy '{$strategy}' not found"); + } + + $this->defaultStrategy = $strategy; + } + + /** + * Get all available strategies + */ + public function getAvailableStrategies(): array + { + return array_keys($this->strategies); + } + + /** + * Register a custom auth strategy + */ + public function registerStrategy(string $name, string $className): void + { + if (!is_subclass_of($className, AuthStrategyInterface::class)) { + throw new InvalidArgumentException("Class '{$className}' must implement AuthStrategyInterface"); + } + + $this->strategies[$name] = $className; + } + + /** + * Check if a strategy is available + */ + public function hasStrategy(string $strategy): bool + { + return isset($this->strategies[$strategy]); + } +} \ No newline at end of file diff --git a/app/Services/Auth/LocalAuthStrategy.php b/app/Services/Auth/LocalAuthStrategy.php new file mode 100644 index 0000000000..b2d8fd8512 --- /dev/null +++ b/app/Services/Auth/LocalAuthStrategy.php @@ -0,0 +1,70 @@ +requiresConsent = config('restarters.auth.require_consent', true); + $this->requiresApiToken = config('restarters.auth.require_api_token', true); + } + + public function isAuthenticated(): bool + { + return Auth::check() && Auth::user(); + } + + public function hasConsent(): bool + { + if (!$this->requiresConsent) { + return true; + } + + return Auth::check() && Auth::user() && Auth::user()->hasUserGivenConsent(); + } + + public function requiresConsent(): bool + { + return $this->requiresConsent; + } + + public function getUnauthenticatedResponse(): Response + { + return redirect()->guest(route('login')); + } + + public function getConsentResponse(): Response + { + return redirect('/user/register'); + } + + public function handlePostAuth($_, Response $response): Response + { + if (!$this->requiresApiToken || !Auth::check()) { + return $response; + } + + // Ensure API token for Vue client + $token = Auth::user()->ensureAPIToken(); + + // Add API token as cookie + if (method_exists($response, 'withCookie')) { + $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, false, false)); + } + + return $response; + } + + public function getName(): string + { + return 'local'; + } +} \ No newline at end of file diff --git a/app/Services/Auth/iFixitAuthStrategy.php b/app/Services/Auth/iFixitAuthStrategy.php new file mode 100644 index 0000000000..108f092651 --- /dev/null +++ b/app/Services/Auth/iFixitAuthStrategy.php @@ -0,0 +1,71 @@ +requiresApiToken = config('restarters.auth.require_api_token', true); + $this->ifixitService = $ifixitService; + } + + public function isAuthenticated(): bool + { + // Check if user is authenticated with iFixit service + return $this->ifixitService->isAuthenticated(); + } + + public function hasConsent(): bool + { + // iFixit users typically bypass consent + return true; + } + + public function requiresConsent(): bool + { + return false; + } + + public function getUnauthenticatedResponse(): Response + { + // Redirect to iFixit login page with callback + $callbackUrl = url('/dashboard'); + $loginUrl = $this->ifixitService->getLoginUrl($callbackUrl); + + return redirect($loginUrl); + } + + public function getConsentResponse(): Response + { + return redirect()->back(); + } + + public function handlePostAuth($_, Response $response): Response { + if (!$this->requiresApiToken || !Auth::check()) { + return $response; + } + + // Ensure API token for Vue client + $token = Auth::user()->ensureAPIToken(); + + // Add API token as cookie + if (method_exists($response, 'withCookie')) { + $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, false, false)); + } + + return $response; + } + + public function getName(): string + { + return 'ifixit'; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index a4bef9cce8..8e26662e09 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,7 @@ ->withProviders([ // App Service Providers \App\Providers\TranslationServiceProvider::class, + \App\Providers\AuthServiceProvider::class, // Package Service Providers \Mariuzzo\LaravelJsLocalization\LaravelJsLocalizationServiceProvider::class, @@ -81,14 +82,13 @@ $middleware->alias([ 'AcceptUserInvites' => \App\Http\Middleware\AcceptUserInvites::class, - 'ensureAPIToken' => \App\Http\Middleware\EnsureAPIToken::class, 'customApiAuth' => \App\Http\Middleware\CustomApiTokenAuth::class, 'admin' => \App\Http\Middleware\AdminMiddleware::class, 'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class, 'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class, 'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class, 'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class, - 'verifyUserConsent' => \App\Http\Middleware\VerifyUserConsent::class, + 'centralizedAuth' => \App\Http\Middleware\CentralizedAuth::class, ]); $middleware->priority([ diff --git a/config/restarters.php b/config/restarters.php index 4485000a12..0bddff53ee 100644 --- a/config/restarters.php +++ b/config/restarters.php @@ -9,6 +9,12 @@ 'matomo_integration' => env('FEATURE__MATOMO_INTEGRATION', false), ], + 'auth' => [ + 'strategy' => env('AUTH_STRATEGY', 'local'), // 'local', 'ifixit' + 'require_consent' => env('AUTH_REQUIRE_CONSENT', true), // Set to false for iFixit + 'require_api_token' => env('AUTH_REQUIRE_API_TOKEN', true), + ], + 'wiki' => [ 'base_url' => env('WIKI_URL'), 'cookie_prefix' => env('WIKI_COOKIE_PREFIX', 'wiki_db'), diff --git a/routes/api.php b/routes/api.php index 6f324e2850..83f6d2de20 100644 --- a/routes/api.php +++ b/routes/api.php @@ -47,7 +47,7 @@ // AUTHENTICATED API ROUTES (v1 - Legacy) // ============================================================================= -Route::middleware('auth:api')->group(function () { +Route::middleware(['centralizedAuth'])->group(function () { // User management Route::prefix('users')->group(function () { Route::get('me', [ApiController::class, 'getUserInfo']); @@ -101,7 +101,7 @@ Route::patch('{id}', [API\GroupController::class, 'updateGroupv2']); // Authenticated group endpoints - Route::middleware(['auth:api'])->group(function () { + Route::middleware(['centralizedAuth'])->group(function () { Route::patch('{id}/volunteers/{iduser}', [API\GroupController::class, 'patchVolunteerForGroupv2']); Route::delete('{id}/volunteers/{iduser}', [API\GroupController::class, 'deleteVolunteerForGroupv2']); }); @@ -148,13 +148,13 @@ }); // Moderation API - Route::prefix('moderate')->middleware('auth:api')->group(function () { + Route::prefix('moderate')->middleware(['centralizedAuth'])->group(function () { Route::get('groups', [API\GroupController::class, 'moderateGroupsv2']); Route::get('events', [API\EventController::class, 'moderateEventsv2']); }); // Admin API - Route::prefix('admin')->middleware(['auth:api', 'admin'])->group(function () { + Route::prefix('admin')->middleware(['centralizedAuth', 'admin'])->group(function () { Route::prefix('groups')->group(function () { Route::get('/', [App\Http\Controllers\API\GroupsController::class, 'index']); Route::get('export', [App\Http\Controllers\API\GroupsController::class, 'exportGroups']); diff --git a/routes/web.php b/routes/web.php index f5916f599b..10e74e7b9b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -65,7 +65,7 @@ function registerGameRoutes($prefix) { // PUBLIC ROUTES (No Authentication Required) // ============================================================================= -Route::middleware('ensureAPIToken')->group(function () { +Route::middleware('centralizedAuth:optional')->group(function () { // === HOME & LANDING === Route::middleware('guest')->group(function () { @@ -180,7 +180,7 @@ function registerGameRoutes($prefix) { // AUTHENTICATED ROUTES // ============================================================================= -Route::middleware(['auth', 'verifyUserConsent', 'ensureAPIToken'])->group(function () { +Route::middleware(['centralizedAuth'])->group(function () { // === USER PROFILE & SETTINGS === Route::prefix('profile')->group(function () { From b3f1755000908d35a57f93d8de1332d0cfcb73e3 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 16 Jul 2025 14:08:04 -0700 Subject: [PATCH 03/12] feat(auth): add iFixitAuthService for session validation and authentication - Introduced iFixitAuthService to handle session validation against the iFixit API. - Updated bootstrap/app.php to exclude 'session' in encrypted cookies. We need to exclude 'session' from encrypted cookies because it's not set by an external service. As such, we need an unencrypted cookie to be able to communicate with the iFixit API. --- app/Services/Auth/iFixitAuthService.php | 80 +++++++++++++++++++++++++ bootstrap/app.php | 3 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 app/Services/Auth/iFixitAuthService.php diff --git a/app/Services/Auth/iFixitAuthService.php b/app/Services/Auth/iFixitAuthService.php new file mode 100644 index 0000000000..98d79b841e --- /dev/null +++ b/app/Services/Auth/iFixitAuthService.php @@ -0,0 +1,80 @@ + 'application/json', + 'Cookie' => "session={$sessionCookie}", + 'User-Agent' => 'RestartProject/1.0', + ])->get("{$this->apiUrl}/user"); + + if ($response->successful()) { + $userData = $response->json(); + + // Validate required fields + if (!isset($userData['userid']) || !isset($userData['login'])) { + return null; + } + + return $userData; + } + + return null; + } catch (\Exception $e) { + Log::error('iFixit API validation failed', [ + 'error' => $e->getMessage(), + 'session_length' => strlen($sessionCookie) + ]); + return null; + } + } + + /** + * Get iFixit login URL with callback + */ + public function getLoginUrl(string $callbackUrl): string + { + return "{$this->baseUrl}/login?last_page=" . urlencode($callbackUrl); + } + + /** + * Get iFixit logout URL with callback + */ + public function getLogoutUrl(string $callbackUrl): string + { + return "{$this->baseUrl}/user/logout?last_page=" . urlencode($callbackUrl); + } + + /** + * Check if user is authenticated with iFixit + */ + public function isAuthenticated(): bool + { + $sessionCookie = request()->cookie('session'); + if (!$sessionCookie) { + return false; + } + + $userData = $this->validateSession($sessionCookie); + return $userData !== null; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index 8e26662e09..06da62039a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -58,7 +58,8 @@ 'wiki_dev_mw_UserID', 'wiki_dev_mw_UserName', 'authenticated', - 'restarters_apitoken' + 'restarters_apitoken', + 'session' ]); $middleware->append(\App\Http\Middleware\HttpsProtocol::class); From 84177b90ab055e393a5e5101a372e67a73d5fea9 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 16 Jul 2025 15:13:58 -0700 Subject: [PATCH 04/12] feat(auth): create user from iFixit session data - Updated User model to include external_user_id and external_username fields. - Implemented syncFromExternal method to create or update users from iFixit data. - Enhanced UserController's logout method to handle redirection for external users. - Added getCurrentUser method in iFixitAuthService to retrieve user data from iFixit session. - Improved iFixitAuthStrategy to authenticate users and log them in based on iFixit session data. This mainly adds the ability to create/update users from iFixit session data. This will also set the user's role to Administrator if they are an Admin in iFixit. That said, it will only affect the user's role on first creation. If we manually change the user's role, then it will not be overwritten by their iFixit role. --- app/Http/Controllers/UserController.php | 13 ++++ app/Models/User.php | 65 ++++++++++++++++++- app/Services/Auth/iFixitAuthService.php | 13 ++++ app/Services/Auth/iFixitAuthStrategy.php | 43 +++++++++++- ...dd_external_auth_fields_to_users_table.php | 32 +++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2025_07_16_212610_add_external_auth_fields_to_users_table.php diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 3af2652d23..0615c291a5 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -908,7 +908,20 @@ public function edit($id, Request $request) public function logout(): RedirectResponse { + $user = Auth::user(); + $isExternalUser = $user && $user->isExternalUser(); + Auth::logout(); + + // If user is from iFixit, redirect to iFixit logout + if ($isExternalUser && config('restarters.auth.strategy') === 'ifixit') { + $ifixitService = app(\App\Services\Auth\iFixitAuthService::class); + $logoutUrl = $ifixitService->getLogoutUrl(url('/')); + + session()->flush(); + + return redirect($logoutUrl); + } return redirect('/login'); } diff --git a/app/Models/User.php b/app/Models/User.php index 920614ea69..9cd00516e5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -41,7 +41,7 @@ class User extends Authenticatable implements Auditable, HasLocalePreference * @var array */ protected $fillable = [ - 'name', 'email', 'password', 'role', 'recovery', 'recovery_expires', 'language', 'repair_network', 'location', 'age', 'gender', 'country_code', 'newsletter', 'invites', 'biography', 'consent_future_data', 'consent_past_data', 'consent_gdpr', 'number_of_logins', 'latitude', 'longitude', 'last_login_at', 'api_token', 'access_group_tag_id', 'calendar_hash', 'repairdir_role', 'mediawiki', 'username', + 'name', 'email', 'password', 'role', 'recovery', 'recovery_expires', 'language', 'repair_network', 'location', 'age', 'gender', 'country_code', 'newsletter', 'invites', 'biography', 'consent_future_data', 'consent_past_data', 'consent_gdpr', 'number_of_logins', 'latitude', 'longitude', 'last_login_at', 'api_token', 'access_group_tag_id', 'calendar_hash', 'repairdir_role', 'mediawiki', 'username', 'external_user_id', 'external_username', ]; /** @@ -576,6 +576,69 @@ public function preferredLocale(): string return $this->language ?? config('app.locale', 'en'); } + /** + * Sync user from external service data + */ + public static function syncFromExternal(array $externalUserData): User + { + $user = self::where('external_user_id', $externalUserData['userid'])->first(); + + if ($user) { + // User exists, update without changing role to preserve manual changes + $user->update([ + 'name' => $externalUserData['username'], + 'email' => $externalUserData['login'], + 'external_username' => $externalUserData['unique_username'] ?? null, + 'username' => $externalUserData['unique_username'] ?? null, + ]); + + return $user; + } else { + // User doesn't exist, create with mapped role from iFixit privilege level + $role = Role::RESTARTER; // Default role for external users + + if (isset($externalUserData['greatest_privilege']) && $externalUserData['greatest_privilege'] === 'Admin') { + $role = Role::ADMINISTRATOR; + } + + $user = self::create([ + 'name' => $externalUserData['username'], + 'email' => $externalUserData['login'], + 'external_user_id' => $externalUserData['userid'], + 'external_username' => $externalUserData['unique_username'] ?? null, + 'role' => $role, + 'username' => $externalUserData['unique_username'] ?? null, + 'password' => null, // External users don't have local passwords + 'repairdir_role' => Role::REPAIR_DIRECTORY_NONE, + 'calendar_hash' => Str::random(15) + ]); + + // Generate username if not provided + if (!$user->username) { + $user->generateAndSetUsername(); + $user->save(); + } + + return $user; + } + } + + /** + * Check if this user is an external user (iFixit, etc.) + */ + public function isExternalUser(): bool + { + return !empty($this->external_user_id); + } + + /** + * Check if user can login with password (local users only) + */ + public function canLoginWithPassword(): bool + { + return !$this->isExternalUser() && !empty($this->password); + } + public static function userCanSeeEvent($user, $event) { // We need to filter based on approved visibility: // - where the group is approved, this event is visible diff --git a/app/Services/Auth/iFixitAuthService.php b/app/Services/Auth/iFixitAuthService.php index 98d79b841e..4b6da98b98 100644 --- a/app/Services/Auth/iFixitAuthService.php +++ b/app/Services/Auth/iFixitAuthService.php @@ -77,4 +77,17 @@ public function isAuthenticated(): bool $userData = $this->validateSession($sessionCookie); return $userData !== null; } + + /** + * Get current user data from iFixit session + */ + public function getCurrentUser(): ?array + { + $sessionCookie = request()->cookie('session'); + if (!$sessionCookie) { + return null; + } + + return $this->validateSession($sessionCookie); + } } \ No newline at end of file diff --git a/app/Services/Auth/iFixitAuthStrategy.php b/app/Services/Auth/iFixitAuthStrategy.php index 108f092651..59cd826043 100644 --- a/app/Services/Auth/iFixitAuthStrategy.php +++ b/app/Services/Auth/iFixitAuthStrategy.php @@ -2,8 +2,9 @@ namespace App\Services\Auth; -use Illuminate\Http\Request; +use App\Models\User; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\Response; class iFixitAuthStrategy implements AuthStrategyInterface @@ -19,8 +20,44 @@ public function __construct(iFixitAuthService $ifixitService) public function isAuthenticated(): bool { - // Check if user is authenticated with iFixit service - return $this->ifixitService->isAuthenticated(); + // First check if user is already authenticated in Laravel + if (Auth::check()) { + return true; + } + + // Check if user has valid iFixit session + $sessionCookie = request()->cookie('session'); + if (!$sessionCookie) { + return false; + } + + // Validate session with iFixit API + $userData = $this->ifixitService->validateSession($sessionCookie); + if (!$userData) { + return false; + } + + try { + // Sync/create user from iFixit data + $user = User::syncFromExternal($userData); + + // Log the user in + Auth::login($user); + + Log::debug('iFixit user authenticated', [ + 'user_id' => $user->id, + 'external_id' => $userData['userid'], + 'email' => $userData['login'] + ]); + + return true; + } catch (\Exception $e) { + Log::error('Failed to sync iFixit user', [ + 'error' => $e->getMessage(), + 'user_data' => $userData + ]); + return false; + } } public function hasConsent(): bool diff --git a/database/migrations/2025_07_16_212610_add_external_auth_fields_to_users_table.php b/database/migrations/2025_07_16_212610_add_external_auth_fields_to_users_table.php new file mode 100644 index 0000000000..01735e7107 --- /dev/null +++ b/database/migrations/2025_07_16_212610_add_external_auth_fields_to_users_table.php @@ -0,0 +1,32 @@ +string('external_user_id')->nullable()->unique()->after('mediawiki'); + $table->string('external_username')->nullable()->after('external_user_id'); + $table->string('password')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('external_user_id'); + $table->dropColumn('external_username'); + $table->string('password')->nullable(false)->change(); + }); + } +}; From 5d8fb5ca34cf7d16d82ced15722887ec0500702b Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 16 Jul 2025 15:33:07 -0700 Subject: [PATCH 05/12] refactor(auth): enhance CustomApiTokenAuth middleware for improved authentication This refactors the CustomApiTokenAuth middleware to simplify the authentication process and include the new auth strategies for setting and retrieving the api cookie. --- app/Http/Middleware/CustomApiTokenAuth.php | 223 ++++++++------------- 1 file changed, 84 insertions(+), 139 deletions(-) diff --git a/app/Http/Middleware/CustomApiTokenAuth.php b/app/Http/Middleware/CustomApiTokenAuth.php index bf32924366..1f6ff97a70 100644 --- a/app/Http/Middleware/CustomApiTokenAuth.php +++ b/app/Http/Middleware/CustomApiTokenAuth.php @@ -5,174 +5,119 @@ use Closure; use Illuminate\Http\Request; use App\Models\User; +use App\Services\Auth\AuthStrategyManager; use Illuminate\Auth\AuthenticationException; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; +/** + * This middleware ensures that API requests are properly authenticated by: + * 1. Checking if user is already authenticated via session + * 2. Trying centralized auth (iFixit, local, etc.) + * 3. Falling back to API token authentication (Bearer header, cookie, query param) + * 4. Ensuring authenticated users have API tokens and setting Authorization header + */ class CustomApiTokenAuth { - /** - * Handle an incoming request. - * This middleware will try multiple methods of authentication to ensure API requests succeed. - */ + private AuthStrategyManager $authManager; + + public function __construct(AuthStrategyManager $authManager) + { + $this->authManager = $authManager; + } + public function handle(Request $request, Closure $next) { - // STEP 1: Log detailed request information - Log::debug("API Request", [ - 'path' => $request->path(), - 'method' => $request->method(), - 'cookies' => array_keys($request->cookies->all()), - 'has_api_token_cookie' => $request->hasCookie('restarters_apitoken'), - 'cookie_value_length' => $request->cookie('restarters_apitoken') ? strlen($request->cookie('restarters_apitoken')) : 0, - 'has_session_cookie' => $request->hasCookie(config('session.cookie')), - 'has_authorization_header' => $request->hasHeader('Authorization'), - 'has_query_token' => $request->has('api_token') - ]); + // Step 1: Try to authenticate user + $this->authenticateUser($request); - // STEP 2: Check if the user is already authenticated via session + // Step 2: If authenticated, ensure API token access if (Auth::check()) { - $user = Auth::user(); - Log::debug("User already authenticated via session", ['user_id' => $user->id]); - - // Make sure the Authorization header is set for downstream middleware/guards - if ($user->api_token) { - $request->headers->set('Authorization', 'Bearer ' . $user->api_token); - return $next($request); - } + $this->ensureApiAccess($request); + return $next($request); } - // STEP 3: Try to authenticate using various methods - $token = $this->extractToken($request); - + // Step 3: Return 401 if not authenticated + return $this->unauthorized($request); + } + + /** + * Try to authenticate the user using available methods + */ + private function authenticateUser(Request $request): void + { + if (Auth::check()) { + return; + } + + $authStrategy = $this->authManager->getStrategy(); + if ($authStrategy->isAuthenticated()) { + return; + } + + $token = $this->extractApiToken($request); if ($token) { $user = User::where('api_token', $token)->first(); - if ($user) { - // Authenticate the user Auth::login($user); - - // Set Authorization header for downstream middleware - $request->headers->set('Authorization', 'Bearer ' . $token); - - Log::debug("API Authentication successful", [ - 'user_id' => $user->id, - 'token_source' => $this->tokenSource - ]); - - return $next($request); - } else { - Log::warning("Invalid API token", [ - 'token_length' => strlen($token), - 'token_source' => $this->tokenSource - ]); } } - - // STEP 4: If we've reached this point, authentication failed - Log::warning("API Authentication failed", [ - 'path' => $request->path(), - 'attempted_methods' => [ - 'session' => Auth::check(), - 'cookie' => $request->hasCookie('restarters_apitoken'), - 'header' => $request->hasHeader('Authorization'), - 'query' => $request->has('api_token'), - ] - ]); - - // For AJAX or API requests, return a JSON response - if ($request->expectsJson() || $request->is('api/*')) { - return response()->json([ - 'message' => 'Unauthenticated.', - 'debug' => [ - 'token_source_tried' => $this->tokenSource, - 'session_auth' => Auth::check(), - 'cookie_auth_attempted' => $request->hasCookie('restarters_apitoken'), - 'header_auth_attempted' => $request->hasHeader('Authorization'), - 'query_auth_attempted' => $request->has('api_token'), - ] - ], 401); - } - - // For web requests, redirect to login page - throw new AuthenticationException( - 'Unauthenticated.', - ['api'], - $request->expectsJson() ? null : route('login') - ); } - - /** - * The source of the token that was used - */ - private $tokenSource = 'none'; - + /** - * Try all possible methods to extract an API token from the request + * Ensure authenticated user has API access */ - private function extractToken(Request $request) + private function ensureApiAccess(Request $request): void { - // Method 1: Check for token in query string - $token = $request->query('api_token'); - if (!empty($token)) { - $this->tokenSource = 'query'; - Log::debug("Found token in query string", ['length' => strlen($token)]); - return $token; - } + $user = Auth::user(); - // Method 2: Check for token in request body - $token = $request->input('api_token'); - if (!empty($token)) { - $this->tokenSource = 'body'; - Log::debug("Found token in request body", ['length' => strlen($token)]); - return $token; - } + $token = $user->ensureAPIToken(); - // Method 3: Check for token in Authorization header + // Set Authorization header for downstream middleware + $request->headers->set('Authorization', "Bearer {$token}"); + } + + /** + * Extract API token from request + */ + private function extractApiToken(Request $request): ?string + { $bearerToken = $request->bearerToken(); - if (!empty($bearerToken)) { - $this->tokenSource = 'bearer'; - Log::debug("Found token in bearer header", ['length' => strlen($bearerToken)]); + if ($bearerToken) { return $bearerToken; } - - // Method 4: Check for token in regular Authorization header - $header = $request->header('Authorization'); - if (strpos($header, 'Bearer ') === 0) { - $token = substr($header, 7); - if (!empty($token)) { - $this->tokenSource = 'auth_header'; - Log::debug("Found token in Authorization header", ['length' => strlen($token)]); - return $token; - } - } - - // Method 5: Check for token in custom header - $token = $request->header('X-API-TOKEN'); - if (!empty($token)) { - $this->tokenSource = 'custom_header'; - Log::debug("Found token in X-API-TOKEN header", ['length' => strlen($token)]); - return $token; - } - - // Method 6: Check for token in cookie - $token = $request->cookie('restarters_apitoken'); - if (!empty($token)) { - $this->tokenSource = 'cookie'; - Log::debug("Found token in cookie", ['length' => strlen($token)]); - return $token; + + $cookieToken = $request->cookie('restarters_apitoken'); + if ($cookieToken) { + return $cookieToken; } - - // Method 7: If user is already authenticated, use their API token - if (Auth::check()) { - $user = Auth::user(); - if (!empty($user->api_token)) { - $this->tokenSource = 'session_user'; - Log::debug("Using token from authenticated user", ['user_id' => $user->id]); - return $user->api_token; - } + + $queryToken = $request->query('api_token'); + if ($queryToken) { + return $queryToken; } - - Log::warning("No token found in request"); + return null; } + + /** + * Return unauthenticated response + */ + private function unauthorized(Request $request) + { + Log::warning('API request failed authentication', [ + 'path' => $request->path(), + 'method' => $request->method(), + 'has_session' => $request->hasSession(), + 'has_api_cookie' => $request->hasCookie('restarters_apitoken'), + ]); + + if ($request->expectsJson() || $request->is('api/*')) { + return response()->json([ + 'message' => 'Unauthenticated.' + ], 401); + } + + throw new AuthenticationException('Unauthenticated.', ['api']); + } } \ No newline at end of file From b08f86c018c5ac42b1e9829376183ab2e39be0a7 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 16 Jul 2025 16:00:07 -0700 Subject: [PATCH 06/12] fix(auth): auto-redirect to dashboard on home page If there is already a valid session cookie, then we should redirect to the dashboard page instead of viewing the landing page. --- app/Http/Middleware/CentralizedAuth.php | 7 +++++-- routes/web.php | 6 ++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/Http/Middleware/CentralizedAuth.php b/app/Http/Middleware/CentralizedAuth.php index d9d2e75119..d95269c4e9 100644 --- a/app/Http/Middleware/CentralizedAuth.php +++ b/app/Http/Middleware/CentralizedAuth.php @@ -42,10 +42,13 @@ public function handle(Request $request, Closure $next, ?string $mode = null): R */ private function handleOptionalAuth(Request $request, Closure $next, $authStrategy): Response { + // Check authentication before running controller logic + // This allows controllers to see if user is authenticated and act accordingly + $isAuthenticated = $authStrategy->isAuthenticated(); + $response = $next($request); - // Only run auth logic if user is actually authenticated - if ($authStrategy->isAuthenticated()) { + if ($isAuthenticated) { return $authStrategy->handlePostAuth($request, $response); } diff --git a/routes/web.php b/routes/web.php index 10e74e7b9b..85151efc28 100644 --- a/routes/web.php +++ b/routes/web.php @@ -68,10 +68,8 @@ function registerGameRoutes($prefix) { Route::middleware('centralizedAuth:optional')->group(function () { // === HOME & LANDING === - Route::middleware('guest')->group(function () { - Route::get('/', [HomeController::class, 'index'])->name('home'); - Route::get('/about', [HomeController::class, 'index']); - }); + Route::get('/', [HomeController::class, 'index'])->name('home'); + Route::get('/about', [HomeController::class, 'index']); // === AUTHENTICATION & USER REGISTRATION === Route::prefix('user')->group(function () { From 44e2bc033e2f39e8478f1382606b8ce2f4384a8c Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 16 Jul 2025 17:53:12 -0700 Subject: [PATCH 07/12] feat(auth): conditionalize auth routes - Updated UserController to handle password recovery and reset for iFixit users. - Added AuthStrategyManager methods for login, logout, and registration URL retrieval. - Refactored LoginController to redirect iFixit users to external authentication. - Improved iFixitAuthService to provide registration URL functionality. These changes make it easier to support both authentication strategies without requiring a bunch of conditionalization in the Frontend. --- app/Http/Controllers/Auth/LoginController.php | 59 ++++++++++++----- app/Http/Controllers/UserController.php | 53 +++++++++------ app/Services/Auth/AuthStrategyManager.php | 66 ++++++++++++++++++- app/Services/Auth/iFixitAuthService.php | 5 ++ routes/web.php | 19 +++--- 5 files changed, 154 insertions(+), 48 deletions(-) diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 0078c9fc9a..cb13178c06 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -45,6 +45,48 @@ public static function middleware(): array ]; } + /** + * Show the application's login form or redirect to external auth + */ + public function showLoginForm(): View|\Illuminate\Http\RedirectResponse + { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + + if ($authManager->isUsingIFixitAuth()) { + // For iFixit, redirect to external login + return redirect($authManager->getLoginUrl(url('/dashboard'))); + } + + // For local auth, show login form + $stats = Fixometer::loginRegisterStats(); + $deviceCount = array_key_exists(0, $stats['device_count_status']) ? $stats['device_count_status'][0]->counter : 0; + + return view('auth.login', [ + 'co2Total' => $stats['waste_stats'][0]->powered_footprint + $stats['waste_stats'][0]->unpowered_footprint, + 'wasteTotal' => $stats['waste_stats'][0]->powered_waste + $stats['waste_stats'][0]->unpowered_waste, + 'partiesCount' => count($stats['allparties']), + 'deviceCount' => $deviceCount, + ]); + } + + /** + * Handle logout for any auth strategy + */ + public function logout(): \Illuminate\Http\RedirectResponse + { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + return $authManager->handleLogout(); + } + + /** + * Get login URL for current auth strategy + */ + public function getLoginUrl(string $callbackUrl = null): string + { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + return $authManager->getLoginUrl($callbackUrl); + } + /** * Override login from AuthenticateUsers * @@ -97,21 +139,4 @@ protected function validateLogin(Request $request): void 'my_time' => 'required|honeytime:1', ]); } - - /** - * Override showLoginForm from AuthenticateUsers - */ - public function showLoginForm(): View - { - $stats = Fixometer::loginRegisterStats(); - - $deviceCount = array_key_exists(0, $stats['device_count_status']) ? $stats['device_count_status'][0]->counter : 0; - - return view('auth.login', [ - 'co2Total' => $stats['waste_stats'][0]->powered_footprint + $stats['waste_stats'][0]->unpowered_footprint, - 'wasteTotal' => $stats['waste_stats'][0]->powered_waste + $stats['waste_stats'][0]->unpowered_waste, - 'partiesCount' => count($stats['allparties']), - 'deviceCount' => $deviceCount, - ]); - } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 0615c291a5..fe174a0c8f 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -406,8 +406,15 @@ public function postAdminEdit(Request $request): RedirectResponse return redirect()->back()->with('message', __('profile.admin_success')); } - public function recover(Request $request): View + public function recover(Request $request): View|RedirectResponse { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + + // Password recovery not available for external auth + if ($authManager->isUsingIFixitAuth()) { + return redirect('/login')->with('warning', 'Password recovery is not available for iFixit users. Please use iFixit\'s password recovery system.'); + } + $User = new User; $email = $request->get('email'); @@ -460,8 +467,15 @@ public function recover(Request $request): View ]); } - public function reset(Request $request) + public function reset(Request $request): View|RedirectResponse { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + + // Password reset not available for external auth + if ($authManager->isUsingIFixitAuth()) { + return redirect('/login')->with('warning', 'Password reset is not available for iFixit users. Please use iFixit\'s password recovery system.'); + } + $User = new User; $user = null; @@ -906,28 +920,18 @@ public function edit($id, Request $request) } } - public function logout(): RedirectResponse - { - $user = Auth::user(); - $isExternalUser = $user && $user->isExternalUser(); - - Auth::logout(); - - // If user is from iFixit, redirect to iFixit logout - if ($isExternalUser && config('restarters.auth.strategy') === 'ifixit') { - $ifixitService = app(\App\Services\Auth\iFixitAuthService::class); - $logoutUrl = $ifixitService->getLogoutUrl(url('/')); - - session()->flush(); - return redirect($logoutUrl); - } - - return redirect('/login'); - } public function getRegister($hash = null) { + // Check if we should redirect to external registration + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + + if ($authManager->isUsingIFixitAuth()) { + return redirect($authManager->getRegisterUrl(url('/dashboard'))); + } + + // For local auth, show registration form if (Auth::check() && Auth::user()->hasUserGivenConsent()) { return redirect('dashboard'); } @@ -949,6 +953,15 @@ public function getRegister($hash = null) ]); } + /** + * Get registration URL for current auth strategy + */ + public function getRegisterUrl(string $callbackUrl = null): string + { + $authManager = app(\App\Services\Auth\AuthStrategyManager::class); + return $authManager->getRegisterUrl($callbackUrl); + } + public function postRegister(Request $request, $hash = null): RedirectResponse { $geocoder = new \App\Helpers\Geocoder(); diff --git a/app/Services/Auth/AuthStrategyManager.php b/app/Services/Auth/AuthStrategyManager.php index 6856ffa732..01e68945a5 100644 --- a/app/Services/Auth/AuthStrategyManager.php +++ b/app/Services/Auth/AuthStrategyManager.php @@ -40,7 +40,10 @@ public function getStrategy(?string $strategy = null): AuthStrategyInterface return app($this->strategies[$strategy]); } - + public function isUsingIFixitAuth(): bool + { + return $this->defaultStrategy === 'ifixit'; + } /** * Get the default strategy name @@ -89,4 +92,65 @@ public function hasStrategy(string $strategy): bool { return isset($this->strategies[$strategy]); } + + /** + * Get login URL for current auth strategy + */ + public function getLoginUrl(string $callbackUrl = null): string + { + if ($this->isUsingIFixitAuth()) { + $ifixitService = app(iFixitAuthService::class); + return $ifixitService->getLoginUrl($callbackUrl ?: url('/dashboard')); + } + + return url('/login'); + } + + /** + * Get logout URL for current auth strategy + */ + public function getLogoutUrl(string $callbackUrl = null): string + { + if ($this->isUsingIFixitAuth()) { + $ifixitService = app(iFixitAuthService::class); + return $ifixitService->getLogoutUrl($callbackUrl ?: url('/')); + } + + return url('/logout'); + } + + /** + * Get register URL for current auth strategy + */ + public function getRegisterUrl(string $callbackUrl = null): string + { + if ($this->isUsingIFixitAuth()) { + $ifixitService = app(iFixitAuthService::class); + return $ifixitService->getRegisterUrl($callbackUrl ?: url('/dashboard')); + } + + return url('/user/register'); + } + + /** + * Handle logout for any auth strategy with session flushing + */ + public function handleLogout(): \Illuminate\Http\RedirectResponse + { + $user = \Auth::user(); + $isExternalUser = $user && $user->isExternalUser(); + + // Always logout from Laravel first + \Auth::logout(); + + // Always flush session + session()->flush(); + + // If user is from iFixit, redirect to iFixit logout + if ($isExternalUser && $this->isUsingIFixitAuth()) { + return redirect($this->getLogoutUrl(url('/login'))); + } + + return redirect('/login'); + } } \ No newline at end of file diff --git a/app/Services/Auth/iFixitAuthService.php b/app/Services/Auth/iFixitAuthService.php index 4b6da98b98..e5ca686931 100644 --- a/app/Services/Auth/iFixitAuthService.php +++ b/app/Services/Auth/iFixitAuthService.php @@ -55,6 +55,11 @@ public function getLoginUrl(string $callbackUrl): string { return "{$this->baseUrl}/login?last_page=" . urlencode($callbackUrl); } + + public function getRegisterUrl(string $callbackUrl): string + { + return "{$this->baseUrl}/Join?last_page=" . urlencode($callbackUrl); + } /** * Get iFixit logout URL with callback diff --git a/routes/web.php b/routes/web.php index 85151efc28..94a452d5c5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -19,7 +19,6 @@ use App\Http\Controllers\SkillsController; use App\Http\Controllers\StyleController; use App\Http\Controllers\UserController; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\View; @@ -72,25 +71,25 @@ function registerGameRoutes($prefix) { Route::get('/about', [HomeController::class, 'index']); // === AUTHENTICATION & USER REGISTRATION === + Route::get('login', [App\Http\Controllers\Auth\LoginController::class, 'showLoginForm'])->name('login'); + Route::post('login', [App\Http\Controllers\Auth\LoginController::class, 'login']); + Route::get('logout', [App\Http\Controllers\Auth\LoginController::class, 'logout'])->name('logout'); + Route::get('register', [UserController::class, 'getRegister']); + Route::prefix('user')->group(function () { Route::get('/', [HomeController::class, 'index']); + Route::get('register/{hash?}', [UserController::class, 'getRegister'])->name('registration'); + Route::post('register/check-valid-email', [UserController::class, 'postEmail']); + Route::post('register/{hash?}', [UserController::class, 'postRegister']); Route::get('reset', [UserController::class, 'reset']); Route::post('reset', [UserController::class, 'reset']); Route::get('recover', [UserController::class, 'recover']); Route::post('recover', [UserController::class, 'recover']); - Route::get('register/{hash?}', [UserController::class, 'getRegister'])->name('registration'); - Route::post('register/check-valid-email', [UserController::class, 'postEmail']); - Route::post('register/{hash?}', [UserController::class, 'postRegister']); Route::get('thumbnail', [UserController::class, 'getThumbnail']); Route::get('menus', [UserController::class, 'getUserMenus']); Route::get('forbidden', fn() => view('user.forbidden', ['title' => 'Oops'])); }); - - // Laravel auth routes - Auth::routes(); - Route::redirect('register', '/user/register'); - Route::get('logout', [UserController::class, 'logout']); - + // === PARTY/EVENT VIEWING === Route::get('party/view/{id}', [PartyController::class, 'view']); From 4b5d1c719b789cd42b9165c759a46141f12ff43f Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 16 Jul 2025 17:54:48 -0700 Subject: [PATCH 08/12] feat(auth): conditionalize frontend components We still need to conditionalize some of the frontend components even after addressing most of it with the routing. These components revolve around changing a user's password and inviting users to the platform. We can't change the password for an externally created user so we need to hide components related to that. In addition, we can't invite users to the platform as they would need to create an account on iFixit first and there is no current way to pre-register them. --- resources/js/components/GroupActions.vue | 16 ++++++++++------ resources/js/components/GroupVolunteers.vue | 15 +++++++++++---- resources/views/layouts/header.blade.php | 1 + resources/views/layouts/header_plain.blade.php | 1 + resources/views/layouts/navbar.blade.php | 4 +++- resources/views/user/all.blade.php | 4 +++- resources/views/user/profile/account.blade.php | 2 ++ 7 files changed, 31 insertions(+), 12 deletions(-) diff --git a/resources/js/components/GroupActions.vue b/resources/js/components/GroupActions.vue index aa605f20d4..eabb0bdc6d 100644 --- a/resources/js/components/GroupActions.vue +++ b/resources/js/components/GroupActions.vue @@ -8,13 +8,13 @@ {{ __('groups.add_event') }} - + {{ __('groups.invite_volunteers') }} {{ __('groups.volunteers_nearby') }} - + {{ __('groups.share_group_stats') }} @@ -45,7 +45,7 @@ {{ __('groups.join_group_button') }} - + {{ __('groups.share_group_stats') }} @@ -53,7 +53,8 @@ - + @@ -67,8 +68,8 @@ import group from '../mixins/group' import ConfirmModal from './ConfirmModal' export default { - components: {ConfirmModal}, - mixins: [ group ], + components: { ConfirmModal }, + mixins: [group], props: { idgroups: { type: Number, @@ -93,6 +94,9 @@ export default { computed: { group() { return this.$store.getters['groups/get'](this.idgroups) + }, + isLocalAuth() { + return (window.Laravel?.authStrategy || 'local') === 'local' } }, methods: { diff --git a/resources/js/components/GroupVolunteers.vue b/resources/js/components/GroupVolunteers.vue index b754289d8f..df6b2be014 100644 --- a/resources/js/components/GroupVolunteers.vue +++ b/resources/js/components/GroupVolunteers.vue @@ -4,7 +4,7 @@ {{ __('groups.volunteers') }} - ({{ volunteers.length}}) + ({{ volunteers.length }}) @@ -12,10 +12,12 @@
- +
@@ -34,7 +36,7 @@ import CollapsibleSection from './CollapsibleSection' import Group from '../mixins/group' export default { - components: {Group, CollapsibleSection, GroupVolunteer}, + components: { Group, CollapsibleSection, GroupVolunteer }, mixins: [group], props: { idgroups: { @@ -46,6 +48,11 @@ export default { // Get the list of group volunteers this.$store.dispatch('volunteers/fetchGroup', this.idgroups) }, + computed: { + isLocalAuth() { + return (window.Laravel?.authStrategy || 'local') === 'local' + } + } }