Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Providers;
use App\Listeners\SendEmailNewMessage;
use App\Models\Bank;
use App\Models\Call;
use App\Models\Post;
use App\Models\Transaction;
use App\Observers\BankObserver;
use App\Observers\CallObserver;
use App\Observers\PostObserver;
use App\Observers\TransactionObserver;
use App\Polls\QuestionTypeRegistry;
use App\Services\PresenceService;
use App\View\Components\GuestLayout;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
use Laravel\Scout\EngineManager;
use Livewire\Livewire;
use Matchish\ScoutElasticSearch\Engines\ElasticSearchEngine;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->bind(
'Matchish\ScoutElasticSearch\ElasticSearch\EloquentHitsIteratorAggregate',
'app\Overrides\Matchish\ScoutElasticSearch\ElasticSearch\EloquentHitsIteratorAggregate'
);
// Override WireChat Attachment model so URL generation returns the authenticated
// controller route instead of calling Storage::disk('local')->url() which throws.
$this->app->bind(
\Namu\WireChat\Models\Attachment::class,
\App\Models\WireChatAttachment::class
);
// Register custom Blade directive to conditionally check on activeProfile type (User, Organization, Bank, Admin)
Blade::directive('profile', function ($expression) {
return "<?php if (strtolower(getActiveProfileType()) === strtolower($expression)): ?>";
});
Blade::directive('endprofile', function () {
return "<?php endif; ?>";
});
// Register PresenceService
$this->app->singleton(PresenceService::class);
// Register the poll question types enabled in the platform config
$this->app->singleton(QuestionTypeRegistry::class);
// Register custom SocialShare service to support custom icons
$this->app->bind(\Enflow\SocialShare\SocialShare::class, \App\Services\CustomSocialShare::class);
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Fix manifest not found error: enforce the correct location of the manifest.json file
Vite::useManifestFilename('.vite/manifest.json');
// Fix 'Specified key was too long error' when storing emoji's as string in DB.
Schema::defaultStringLength(191);
if (!Collection::hasMacro('paginate')) {
Collection::macro(
'paginate',
function ($perPage = 15, $page = null, $options = []) {
$page = $page ?: (Paginator::resolveCurrentPage() ?: 1);
return (new LengthAwarePaginator(
$this->forPage($page, $perPage),
$this->count(),
$perPage,
$page,
$options
))
->withPath('');
}
);
}
// Manual Elasticsearch driver registration
resolve(EngineManager::class)->extend('matchish-elasticsearch', function ($app) {
return resolve(ElasticSearchEngine::class);
});
// Register Guest Layout
Blade::component('guest-layout', GuestLayout::class);
// Override vendor flatpickr component to fix UTC timezone date shift bug
Blade::component('flatpickr', \App\Overrides\Asdh\LaravelFlatpickr\Components\Flatpickr::class);
// Register lay-outs depending ob authorization
Blade::directive('layout', function () {
return Auth::check() ? 'app-layout' : 'guest-layout';
});
Blade::if('usercan', function ($permission) {
// Get the web user (all permissions are assigned to Users, not to Organizations/Banks/Admins)
$webUser = Auth::guard('web')->user();
if (!$webUser) {
return false;
}
// Use request-level cache to prevent recursion during view rendering
static $permissionCache = [];
$cacheKey = $webUser->id . '_' . $permission;
if (isset($permissionCache[$cacheKey])) {
return $permissionCache[$cacheKey];
}
// Permissions are always checked on the web user (guard 'web')
// Organizations/Banks/Admins don't have their own permissions
try {
// Use can() method instead of hasPermissionTo to avoid loading all permissions
$result = $webUser->can($permission);
$permissionCache[$cacheKey] = $result;
return $result;
} catch (\Exception $e) {
// Any error - return false and cache it
$permissionCache[$cacheKey] = false;
return false;
}
});
// Override the vendor Wirechat components with custom extended ones
Livewire::component('wirechat.chat', \App\Http\Livewire\WireChat\Chat\Chat::class);
Livewire::component('wirechat.chats', \App\Http\Livewire\WireChat\Chats\Chats::class);
Livewire::component('wirechat.new.chat', \App\Http\Livewire\WireChat\New\Chat::class);
Livewire::component('wirechat.new.group', \App\Http\Livewire\WireChat\New\Group::class);
Livewire::component('wirechat.chat.group.add-members', \App\Http\Livewire\WireChat\Chat\Group\AddMembers::class);
Livewire::component('wirechat.chat.group.members', \App\Http\Livewire\WireChat\Chat\Group\Members::class);
Livewire::component('wire-chat.typing-indicator', \App\Http\Livewire\WireChat\TypingIndicator::class);
// Register Bank observer to auto-register new banks as love reacters/reactables
Bank::observe(BankObserver::class);
// Register Post observer to auto-register new posts as love reactables
Post::observe(PostObserver::class);
// Register Call observer to auto-register new calls as love reactables
Call::observe(CallObserver::class);
// Pause calls when spending profile runs out of credits after a transaction
Transaction::observe(TransactionObserver::class);
// Register Conversation observer to auto-set disappearing_started_at
\Namu\WireChat\Models\Conversation::observe(\App\Observers\ConversationObserver::class);
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
namespace App\Providers;
use App\Auth\DockerSessionGuard;
use App\Models\Bank;
use App\Policies\BankPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
/**
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{
$this->registerPolicies();
// Set remember me duration from platform configuration
$rememberMeDays = timebank_config('auth.remember_me_days', 90);
$rememberMeMinutes = $rememberMeDays * 24 * 60; // Convert days to minutes
// Use custom guard in Docker that doesn't migrate sessions
if (env('IS_DOCKER', false)) {
Auth::extend('session', function ($app, $name, array $config) use ($rememberMeMinutes) {
$provider = Auth::createUserProvider($config['provider']);
$guard = new DockerSessionGuard($name, $provider, $app['session.store']);
// Set the cookie jar on the guard
$guard->setCookieJar($app['cookie']);
// If a request is available, set it on the guard
if (method_exists($guard, 'setRequest')) {
$guard->setRequest($app->refresh('request', $guard, 'setRequest'));
}
// Set remember me duration
$guard->setRememberDuration($rememberMeMinutes);
return $guard;
});
}
// Set remember me duration for all standard guards
foreach (['web', 'organization', 'bank', 'admin'] as $guardName) {
$guard = Auth::guard($guardName);
if (method_exists($guard, 'setRememberDuration')) {
$guard->setRememberDuration($rememberMeMinutes);
}
}
// All permissions are strictly scoped to the active profile session.
//
// Rules:
// - Active profile = Admin{id}: only the scoped role Admin\{id}\{roleName}
// determines permissions. Roles from other linked admin profiles are ignored.
// - Active profile = Organization/Bank: only scoped Org/Bank roles apply.
// Admin-level permissions are NOT accessible when acting as Org/Bank.
// - Active profile = User or null/unknown: no admin permissions granted.
// Shared factory: returns a Gate closure that checks $permission on the active
// profile's single scoped role. Used for both manage and granular verbs.
$makeGate = function (string $permission) {
return function ($user) use ($permission) {
// Roles live on the User model (web guard). When the active guard is
// 'admin' (or another profile guard), Gate receives an Admin/Bank/Org
// model instead. Always resolve the web user explicitly.
if (!($user instanceof \App\Models\User)) {
$user = \Illuminate\Support\Facades\Auth::guard('web')->user();
if (!($user instanceof \App\Models\User)) {
return false;
}
}
try {
$activeType = session('activeProfileType');
$activeId = session('activeProfileId');
if (!$activeType || !$activeId) {
return false;
}
$prefixMap = [
'Admin' => "Admin\\{$activeId}\\",
'Organization' => "Organization\\{$activeId}\\",
'Bank' => "Bank\\{$activeId}\\",
];
$activeClass = class_basename($activeType);
$prefix = $prefixMap[$activeClass] ?? null;
if (!$prefix) {
return false; // User profile or unknown: no permissions
}
$scopedRoles = $user->roles
->filter(fn($r) => str_starts_with($r->name, $prefix));
if ($scopedRoles->isEmpty()) {
return false;
}
// Grant if any of the user's scoped roles for this profile has the permission.
// Also honour the manage→verb implication for granular checks.
$managePermission = null;
$parts = explode(' ', $permission, 2);
if (count($parts) === 2 && $parts[0] !== 'manage') {
$managePermission = 'manage ' . $parts[1];
}
foreach ($scopedRoles as $scopedRole) {
try {
if ($scopedRole->hasPermissionTo($permission, 'web')) {
return true;
}
} catch (\Spatie\Permission\Exceptions\PermissionDoesNotExist $e) {
// permission doesn't exist in DB, skip
}
if ($managePermission) {
try {
if ($scopedRole->hasPermissionTo($managePermission, 'web')) {
return true;
}
} catch (\Spatie\Permission\Exceptions\PermissionDoesNotExist $e) {
// manage permission doesn't exist, skip
}
}
}
return false;
} catch (\Spatie\Permission\Exceptions\PermissionDoesNotExist $e) {
return false;
} catch (\Exception $e) {
return false;
}
};
};
// manage {resource} — umbrella permission (checked by route middleware + mount())
foreach ([
'banks', 'organizations', 'admins', 'calls', 'posts', 'polls', 'tags',
'categories', 'mailings', 'profiles', 'roles', 'permissions',
'users', 'user_projects', 'accounts', 'maintenance',
] as $resource) {
Gate::define("manage {$resource}", $makeGate("manage {$resource}"));
}
// Granular action Gates — checked inside individual Livewire methods and blade views.
// Each resource lists only the verbs that make sense for it.
$granular = [
'posts' => ['view', 'create', 'update', 'publish', 'unpublish', 'delete'],
// Polls: 'export' covers who voted (Results tab) and the CSV export
'polls' => ['view', 'create', 'update', 'export', 'delete'],
'tags' => ['view', 'create', 'update', 'delete'],
'categories' => ['view', 'create', 'update', 'delete'],
'calls' => ['view', 'create', 'update', 'publish', 'unpublish', 'delete'],
'mailings' => ['view', 'create', 'update', 'send', 'delete'],
'users' => ['view', 'create', 'update', 'delete'],
'user_projects' => ['view', 'create', 'update', 'delete'],
'organizations' => ['view', 'create', 'update', 'delete'],
'banks' => ['view', 'create', 'update', 'delete'],
'admins' => ['view', 'create', 'update', 'delete'],
'accounts' => ['view', 'create', 'update', 'delete'],
'profiles' => ['view', 'create', 'update', 'delete'],
'permissions' => ['view', 'create', 'update', 'delete'],
'roles' => ['view', 'create', 'update', 'delete'],
'search-metrics' => ['view'],
'application-log' => ['view'],
'inactive-profiles-log' => ['view'],
'search-reindex-log' => ['view'],
'stats' => ['view'],
];
// Org/bank capability gates — checked inside org-context Livewire components.
foreach (['pay', 'transactions', 'reports', 'stats', 'contacts', 'calls', 'messenger', 'edit-profile', 'settings', 'change-password'] as $cap) {
Gate::define("org {$cap}", $makeGate("org {$cap}"));
}
// Admin capability gates — checked inside admin-context components.
foreach (['reports', 'contacts', 'messenger', 'settings', 'change-password'] as $cap) {
Gate::define("admin {$cap}", $makeGate("admin {$cap}"));
}
foreach ($granular as $resource => $verbs) {
foreach ($verbs as $verb) {
Gate::define("{$verb} {$resource}", $makeGate("{$verb} {$resource}"));
}
}
// Regular users can always manage their own calls — no role/permission needed.
// Ownership enforcement is done at the component level (query scoped to own calls).
$userCallVerbs = ['create', 'update', 'publish', 'unpublish', 'delete'];
foreach ($userCallVerbs as $verb) {
Gate::define("{$verb} calls", function ($user) use ($verb, $makeGate) {
$activeType = session('activeProfileType');
if ($activeType && class_basename($activeType) === 'User') {
return true;
}
return $makeGate("{$verb} calls")($user);
});
}
// access {resource} — page-level gate used on routes.
// 'view {resource}' grants read-only page entry; 'manage {resource}' grants full access.
// Roles without any permission for a resource cannot reach the page.
$accessResources = array_keys($granular);
foreach ($accessResources as $resource) {
Gate::define("access {$resource}", function ($user) use ($resource, $makeGate) {
return $makeGate("manage {$resource}")($user) || $makeGate("view {$resource}")($user);
});
}
// Route-level gate for the scoped bank-manager "manage accounts" surface
// (Profiles\Manage.php, non-central banks). Bank-scoped roles created via
// Roles\Manage.php only ever carry 'org *' style capability permissions —
// never the Admin-style 'manage accounts' permission the granular gates
// above check for — so this checks bank-manager role membership directly,
// the same convention getCanCreatePayments() already uses for banks.
Gate::define('bank manage accounts', function ($user) {
if (!($user instanceof \App\Models\User)) {
$user = \Illuminate\Support\Facades\Auth::guard('web')->user();
if (!($user instanceof \App\Models\User)) {
return false;
}
}
$activeType = session('activeProfileType');
$activeId = session('activeProfileId');
if ($activeType !== \App\Models\Bank::class || !$activeId) {
return false;
}
return $user->hasRole("Bank\\{$activeId}\\bank-manager");
});
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Broadcasting routes are now registered in routes/web.php within the localized route group
require base_path('routes/channels.php');
Broadcast::channel('switch-profile', function ($user) {
return $user;
});
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Providers;
use App\Events\Auth\RegisteredByAdmin;
use App\Events\ProfileSwitchEvent;
use App\Events\ProfileVerified;
use App\Listeners\Auth\SendAdminCreatedVerificationNotification;
use App\Listeners\LogProfileSwitch;
use App\Listeners\LoginSuccessful;
use App\Listeners\ReactionCreatedListener;
use App\Listeners\ReactionRemovedListener;
use App\Listeners\SendEmailNewMessage;
use Namu\WireChat\Events\MessageCreated;
use App\Listeners\UpdateProfileEmailVerifiedAt;
use Cog\Laravel\Love\Reaction\Events\ReactionHasBeenAdded;
use Cog\Laravel\Love\Reaction\Events\ReactionHasBeenRemoved;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* The event listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
RegisteredByAdmin::class => [
SendAdminCreatedVerificationNotification::class,
],
Login::class => [
LoginSuccessful::class
],
ReactionHasBeenAdded::class => [
ReactionCreatedListener::class
],
ReactionHasBeenRemoved::class => [
ReactionRemovedListener::class
],
'Illuminate\Auth\Events\Logout' => [
'App\Listeners\HandleUserLogout',
],
// Log the date and ip of the profile that is switched to:
// When the ProfileSwitchEvent triggers the LogProfileSwitch listens
ProfileSwitchEvent::class => [
LogProfileSwitch::class,
],
// Email verification of profile models (organization's, banks, admins etc)
ProfileVerified::class => [
UpdateProfileEmailVerifiedAt::class,
],
MessageCreated::class => [
SendEmailNewMessage::class,
],
];
/**
* Register any events for your application.
*
* @return void
*/
public function boot()
{
//
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\ValidationException;
use App\Http\Responses\PasswordResetResponse;
use Laravel\Fortify\Contracts\PasswordResetResponse as PasswordResetResponseContract;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Controllers\VerifyEmailController;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(PasswordResetResponseContract::class, PasswordResetResponse::class);
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Disable Fortify route registration, a selection of the initial fortify routes are moved ot /routes/fortify.php.
// Custom routes with the same name however are registered in web.php. This is done because Fortify does not allow to register routes with the same name.
Fortify::ignoreRoutes();
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::verifyEmailView(fn () => view('auth.verify-email'));
RateLimiter::for('login', function (Request $request) {
$loginIdentifier = (string) $request->input('name'); // Changed from $request->email to $request->input('name')
return Limit::perMinute(10)->by($loginIdentifier.$request->ip());
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
// Rehash Cyclos salted sha256 passwords on first login
Fortify::authenticateUsing(function (Request $request) {
// Attempt to find the user by email or name.
$user = User::where('email', $request->name)
->orWhere('name', $request->name)
->first();
if (!$user) {
info('Auth failed: User not identified with login identifier: ' . $request->auth);
return null;
}
if ($user->isRemoved()) {
info('Auth failed: User ' . $user->name . ' is marked as removed (soft-deleted).');
return null;
}
// If maintenance mode is enabled, check if user has admin relationship
if (isMaintenanceMode()) {
$hasAdminRelation = $user->admins()->exists();
if (!$hasAdminRelation) {
info('Auth failed: Maintenance mode is active and user ' . $user->name . ' does not have admin relationship.');
session()->flash('maintenance_mode_active', true);
throw ValidationException::withMessages([
'name' => __('Login is currently disabled due to site maintenance. Only administrator accounts can access the site at this time. Please try again later.'),
]);
}
info('Auth allowed during maintenance: User ' . $user->name . ' has admin relationship.');
}
info('User identified: ' . $user->name . ' (ID: ' . $user->id . ') attempting login with identifier: ' . $request->auth);
$passwordVerified = false;
// Check for Cyclos password first if salt exists
if (!empty($user->cyclos_salt)) {
info('Attempting Cyclos password verification for user: ' . $user->name);
$concatenated = $user->cyclos_salt . $request->password;
$hashedInputPassword = hash("sha256", $concatenated);
if (strtolower($hashedInputPassword) === strtolower($user->password)) {
info('Cyclos password verified for user: ' . $user->name);
// Prepare user model for update: rehash password and clear salt
$user->password = Hash::make($request->password);
$user->cyclos_salt = null;
$passwordVerified = true;
info('Cyclos password prepared for rehashing for user: ' . $user->name);
} else {
info('Cyclos password verification failed for user: ' . $user->name . '. Will attempt standard Laravel hash check.');
}
}
// If not verified by Cyclos, or if cyclos_salt was empty, try Laravel's default hash check
if (!$passwordVerified && Hash::check($request->password, $user->password)) {
info('Laravel password verified for user: ' . $user->name);
$passwordVerified = true;
}
if ($passwordVerified) {
info('Auth success for user: ' . $user->name);
// Update last login details
$now = Carbon::now()->toDateTimeString();
$user->last_login_at = $now;
$user->last_login_ip = $request->getClientIp();
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$user->isActive()) {
$user->inactive_at = null;
$activated = true;
}
}
// Save all changes (rehashed password, cleared salt, login details)
$user->save();
if (isset($activated) && $activated) {
info('User re-activated: ' . $user->name);
}
return $user;
}
info('Auth failed: Password mismatch for user: ' . $user->name . ' with identifier: ' . $request->auth);
return null;
});
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Providers;
use App\Actions\Jetstream\DeleteUser;
use App\Http\Livewire\ProfileUser\UpdateProfilePersonalForm;
use App\Http\Livewire\Profile\DeleteUserForm;
use App\Http\Livewire\Profile\TwoFactorAuthenticationForm;
use App\Http\Livewire\Profile\UpdatePasswordForm;
use App\Http\Livewire\Profile\UpdateProfilePhoneForm;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Jetstream;
use Livewire\Livewire;
class JetstreamServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->registerComponent('toaster');
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Jetstream::ignoreRoutes(); // Completely disable Jetstream routes, stops Jetstream-specific routes (teams, profile, API tokens, etc.).
$this->configurePermissions();
Jetstream::deleteUsersUsing(DeleteUser::class);
//TODO: fix this registration. Why is it registered here and like this?
Livewire::component('profile-user.update-profile-personal-form', UpdateProfilePersonalForm::class);
Livewire::component('profile-user.update-profile-phone-form', UpdateProfilePhoneForm::class);
// Register customized Jetstream DeleteUserForm
Livewire::component('profile.delete-user-form', DeleteUserForm::class);
// CRITICAL SECURITY: Register custom TwoFactorAuthenticationForm with authorization checks
// This overrides the vendor Jetstream component to prevent IDOR attacks
Livewire::component('profile.two-factor-authentication-form', TwoFactorAuthenticationForm::class);
// CRITICAL SECURITY: Register custom UpdatePasswordForm with authorization checks
// This overrides the vendor Jetstream component to prevent unauthorized password changes
Livewire::component('profile.update-password-form', UpdatePasswordForm::class);
// Register LoginResponse for conditional redirects in Http/Responses/LoginResponse.php
// This is used to load language preference after login
$this->app->singleton(
\Laravel\Fortify\Contracts\LoginResponse::class,
\App\Http\Responses\LoginResponse::class
);
// Register TwofactorLoginResponse for conditional redirects in Http/Responses/LoginResponse.php
// This is used to load language preference after login
$this->app->singleton(
\Laravel\Fortify\Contracts\TwoFactorLoginResponse::class,
\App\Http\Responses\LoginResponse::class
);
}
/**
* Configure the permissions that are available within the application.
*
* @return void
*/
protected function configurePermissions()
{
Jetstream::defaultApiTokenPermissions(['read']);
Jetstream::permissions([
'create',
'read',
'update',
'delete',
]);
}
protected function registerComponent(string $component)
{
Blade::component('components.' . $component, $component);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Providers;
use App\Mail\MailpitCopyListener;
use App\Mail\UniversalBounceHandler;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class MailBounceServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->app->singleton(UniversalBounceHandler::class);
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Copy to Mailpit first (before bounce handler which may return false and halt propagation)
Event::listen(MessageSending::class, [MailpitCopyListener::class, 'handle']);
// Register the universal bounce handler for all outgoing emails
Event::listen(MessageSending::class, [UniversalBounceHandler::class, 'handle']);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
/**
* Loads this installation's platform folder (platform_path(), outside git). Everything is optional: without the
* folder, or without one of its parts, the installation runs from the code exactly as before.
*
* config.php the platform config, loaded under config('app.timebank_config'), so timebank_config() reads it;
* it wins over config/<TIMEBANK_CONFIG>.php
* theme.php one theme definition with its 'id'; registered in themes.themes and made the active theme
* lang/ translation overrides, merged key by key: <locale>.json, <locale>/<group>.php and
* vendor/<package>/<locale>/<group>.php
* views/ template overrides, whole files: e.g. emails/..., logos/<theme>.blade.php; views/mail/ for the
* mail components (header, footer, button); views/vendor/<package>/ for package views
* public/ favicons, mail-logo.png, mail-logo-inverted.png; served at /platform/ through the link
* public/platform (php artisan platform:link)
*/
class PlatformServiceProvider extends ServiceProvider
{
public function register(): void
{
$config = platform_path('config.php');
if (is_file($config)) {
config([config('app.timebank_config', 'timebank-default') => require $config]);
}
$theme = platform_path('theme.php');
if (is_file($theme)) {
$definition = require $theme;
$id = $definition['id'] ?? 'platform';
unset($definition['id']);
config(["themes.themes.{$id}" => $definition, 'themes.active' => $id]);
}
// Read when the mail Markdown renderer is first made, so set it before that happens
$mailViews = platform_path('views/mail');
if (is_dir($mailViews)) {
config(['mail.markdown.paths' => [$mailViews, ...config('mail.markdown.paths', [])]]);
}
}
public function boot(): void
{
// addPath, not addJsonPath: Laravel merges the main lang folder after the JSON paths, so only a path added
// after it can override a text. It also covers vendor/<package> translations.
$lang = platform_path('lang');
if (is_dir($lang)) {
$this->app['translator']->getLoader()->addPath($lang);
}
$views = platform_path('views');
if (is_dir($views)) {
View::prependLocation($views);
// Package view overrides are looked up when the package registers its views, so a location added
// now is not searched for them: prepend each package folder to its namespace.
foreach (glob("{$views}/vendor/*", GLOB_ONLYDIR) ?: [] as $packageViews) {
View::prependNamespace(basename($packageViews), $packageViews);
}
}
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Namu\WireChat\Models\Attachment as WireChatAttachment;
class RouteServiceProvider extends ServiceProvider
{
use \Mcamara\LaravelLocalization\Traits\LoadsTranslatedCachedRoutes;
/**
* The path to the "home" route for your application.
*
* This is used by Laravel authentication to redirect users after login.
*
* @var string
*/
public const HOME = '/main-page'; // Not used because of route localization
/**
* Get the localized home route.
*
* @return string
*/
public static function localizedHome()
{
return \Mcamara\LaravelLocalization\Facades\LaravelLocalization::localizeURL(self::HOME);
}
/**
* The controller namespace for the application.
*
* When present, controller route declarations will automatically be prefixed with this namespace.
*
* @var string|null
*/
// protected $namespace = 'App\\Http\\Controllers';
/**
* Define your route model bindings, pattern filters, etc.
*
* @return void
*/
public function boot()
{
$this->configureRateLimiting();
$this->routes(function () {
Route::prefix('api')
->middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
Route::middleware('web')
->namespace($this->namespace)
->group(function () {
require base_path('routes/web.php');
require base_path('routes/fortify.php'); // <-- Fortify routes, with some routes disabled as they are customized and therefore in web.php
require base_path('routes/test.php');
});
});
// Route parameter constraints:
Route::pattern('id', '[0-9]+');
// Explicit model binding for WireChat attachment (not in App\Models namespace)
Route::model('attachment', WireChatAttachment::class);
Broadcast::routes([
'middleware' => ['auth.any:admin,bank,organization,web'],
]);
}
/**
* Configure the rate limiters for the application.
*
* Public so tests can register the limiters again on a limiter rebuilt
* over an in-memory cache (see PdfRateLimitTest).
*
* @return void
*/
public function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
});
// Public PDFs are rendered on every request and can take ~128 MB of
// PHP memory each. On 2026-10-07 one crawler fetched them ~11 times a
// second, the server ran out of memory and the kernel killed MariaDB.
// A person downloading flyers never gets near 30 a minute; the
// crawler ran at ~660.
RateLimiter::for('pdf', function (Request $request) {
return Limit::perMinute(30)->by($request->ip());
});
// Search runs an Elasticsearch query per request. Per user where
// logged in (it is behind login), so people sharing an IP - a
// community centre - do not share one allowance.
RateLimiter::for('search', function (Request $request) {
return Limit::perMinute(30)->by(optional($request->user())->id ?: $request->ip());
});
// Every Livewire interaction posts to livewire/update. On 2026-10-08
// one bot posted tampered payloads there for 9 hours, up to ~850 a
// minute. Chat (WireChat) is the busiest real use: up to ~280 a minute
// from one person with two browsers open. Logged-in people are counted
// per user, so people sharing an IP do not share one allowance; guests
// only see forms like login and registration and are counted per IP.
RateLimiter::for('livewire', function (Request $request) {
return $request->user()
? Limit::perMinute(500)->by('user:' . $request->user()->id)
: Limit::perMinute(120)->by('ip:' . $request->ip());
});
}
}
@@ -0,0 +1,106 @@
<?php
// Create this file: app/Providers/ScoutObserverServiceProvider.php
namespace App\Providers;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\Post;
use App\Models\PostTranslation;
use App\Models\User;
use App\Observers\ScoutReindexObserver;
use Elastic\Elasticsearch\ClientBuilder;
use Illuminate\Support\ServiceProvider;
class ScoutObserverServiceProvider extends ServiceProvider
{
/**
* Register observers for Scout models
*/
public function boot()
{
// Create a single observer instance to share across models
$scoutObserver = new ScoutReindexObserver();
// Register observer for User model
User::observe($scoutObserver);
// Register observer for Organization model
Organization::observe($scoutObserver);
// Register observer for Bank model
Bank::observe($scoutObserver);
// Register observer for Post model
Post::observe($scoutObserver);
// TODO NEXT: why not re-indexing with frsh data?
// Add PostTranslation observer with custom logic
PostTranslation::saved(function (PostTranslation $translation) {
if ($translation->post_id) {
$post = Post::find($translation->post_id);
\Log::info('Before searchable:', [
'post_id' => $post->id,
'translations_loaded' => $post->relationLoaded('translations'),
'translation_count' => $post->translations->count(),
'searchable_array' => $post->toSearchableArray()
]);
$post->searchable();
// Wait a moment for Elasticsearch
sleep(1);
// Correct way to get Elasticsearch client for Matchish package
$hosts = config('elastic.client.default.hosts', ['localhost:9200']);
// Ensure hosts is always an array
if (!is_array($hosts)) {
$hosts = [$hosts];
}
$client = ClientBuilder::create()
->setHosts($hosts)
->build();
try {
$exists = $client->exists([
'index' => $post->searchableAs(),
'id' => $post->getScoutKey()
]);
$indexedData = null;
if ($exists) {
$response = $client->get([
'index' => $post->searchableAs(),
'id' => $post->getScoutKey()
]);
$indexedData = $response['_source'] ?? null;
}
\Log::info('After searchable:', [
'exists_in_index' => $exists,
'index_name' => $post->searchableAs(),
'scout_key' => $post->getScoutKey(),
'indexed_till_en' => $indexedData['post_translations']['till_en'] ?? 'not found'
]);
} catch (\Exception $e) {
\Log::error('Elasticsearch check failed', [
'error' => $e->getMessage()
]);
}
}
});
PostTranslation::deleted(function (PostTranslation $translation) {
if ($translation->post) {
$translation->post->unsetRelation('translations');
$translation->post->searchable();
}
});
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Register @theme Blade directive
Blade::directive('theme', function ($expression) {
return "<?php echo theme({$expression}); ?>";
});
// Register @themeId Blade directive
Blade::directive('themeId', function () {
return "<?php echo theme_id(); ?>";
});
// Register @themeName Blade directive
Blade::directive('themeName', function () {
return "<?php echo theme_name(); ?>";
});
// Register @themeColor Blade directive
Blade::directive('themeColor', function ($expression) {
return "<?php echo theme_color({$expression}); ?>";
});
// Register @themeFont Blade directive
Blade::directive('themeFont', function ($expression) {
return "<?php echo theme_font({$expression}); ?>";
});
// Register @isTheme Blade directive
Blade::directive('isTheme', function ($expression) {
return "<?php if (is_theme({$expression})): ?>";
});
// Register @endIsTheme Blade directive
Blade::directive('endIsTheme', function () {
return "<?php endif; ?>";
});
// Register @themeCssVars Blade directive
Blade::directive('themeCssVars', function () {
return "<?php echo theme_css_vars(); ?>";
});
}
}