Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function settings()
|
||||
{
|
||||
Gate::authorize('admin settings');
|
||||
return view('profile-admin.settings');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\ProfileSwitchEvent;
|
||||
use App\Models\Admin;
|
||||
use App\Models\User;
|
||||
use App\Traits\SwitchGuardTrait;
|
||||
use Hash;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
class AdminLoginController extends Controller
|
||||
{
|
||||
use SwitchGuardTrait;
|
||||
|
||||
/**
|
||||
* Direct link to admin login - can be used in emails
|
||||
* Handles the layered authentication flow:
|
||||
* 1. If user not authenticated -> redirect to user login first
|
||||
* 2. If user authenticated -> set intent and redirect to admin login
|
||||
* 3. After admin login -> redirect to intended URL or main page
|
||||
*/
|
||||
public function directLogin(Request $request, $adminId)
|
||||
{
|
||||
// Validate admin exists and load users
|
||||
$admin = Admin::with('users')->find($adminId);
|
||||
if (!$admin) {
|
||||
abort(404, __('Admin not found'));
|
||||
}
|
||||
|
||||
// Get optional intended destination after successful admin login.
|
||||
// Validate it is a local URL to prevent open redirect attacks.
|
||||
$intendedParam = $request->query('intended');
|
||||
$intendedUrl = $this->validateLocalUrl($intendedParam, $request);
|
||||
|
||||
// Store intended URL in session if provided
|
||||
if ($intendedUrl) {
|
||||
session(['admin_login_intended_url' => $intendedUrl]);
|
||||
}
|
||||
|
||||
// Check if user is authenticated on web guard
|
||||
if (!Auth::guard('web')->check()) {
|
||||
// User not logged in - redirect to user login with return URL
|
||||
$returnUrl = route('admin.direct-login', ['adminId' => $adminId]);
|
||||
if ($intendedUrl) {
|
||||
$returnUrl .= '?intended=' . urlencode($intendedUrl);
|
||||
}
|
||||
// Store in session for Laravel to redirect after login
|
||||
session()->put('url.intended', $returnUrl);
|
||||
|
||||
// Get the first user associated with this admin to pre-populate the login form
|
||||
$firstUser = $admin->users()->first();
|
||||
|
||||
if ($firstUser) {
|
||||
// Build the login URL with the name parameter and localization
|
||||
$loginRoute = route('login') . '?name=' . urlencode($firstUser->name);
|
||||
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
|
||||
return redirect($loginUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// User is authenticated - verify they have this admin profile
|
||||
$user = Auth::guard('web')->user();
|
||||
$userWithRelations = User::with('admins')->find($user->id);
|
||||
|
||||
if (!$userWithRelations || !$userWithRelations->admins->contains('id', $adminId)) {
|
||||
// Wrong user is logged in — log out all guards and redirect to login
|
||||
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
|
||||
Auth::guard($guard)->logout();
|
||||
}
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$returnUrl = route('admin.direct-login', ['adminId' => $adminId]);
|
||||
if ($intendedUrl) {
|
||||
$returnUrl .= '?intended=' . urlencode($intendedUrl);
|
||||
}
|
||||
session()->put('url.intended', $returnUrl);
|
||||
|
||||
$firstUser = $admin->users()->first();
|
||||
if ($firstUser) {
|
||||
$loginRoute = route('login') . '?name=' . urlencode($firstUser->name);
|
||||
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
|
||||
return redirect($loginUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Set the profile switch intent in session (kept for showLoginForm)
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Admin',
|
||||
'intended_profile_switch_id' => $adminId,
|
||||
]);
|
||||
|
||||
// Redirect to admin login form — pass adminId in URL so login() doesn't
|
||||
// depend solely on session values that may be lost by Livewire writes.
|
||||
return redirect()->route('admin.login', ['admin_id' => $adminId]);
|
||||
}
|
||||
|
||||
public function showLoginForm(Request $request)
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
$id = $request->query('admin_id') ?? session('intended_profile_switch_id');
|
||||
$type = $id ? 'admin' : session('intended_profile_switch_type');
|
||||
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
|
||||
|
||||
return view('profile-admin.login', [
|
||||
'profile' => $profile,
|
||||
'adminId' => $id,
|
||||
'canResetPassword' => $this->profileCanResetPassword($user, $id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$user = Auth::guard('web')->user();
|
||||
$id = $request->input('admin_id') ?? session('intended_profile_switch_id');
|
||||
$type = $id ? 'admin' : session('intended_profile_switch_type');
|
||||
$admin = $this->getTargetProfileByTypeAndId($user, $type, $id);
|
||||
|
||||
if (!$admin) {
|
||||
session()->put('admin_login_error', __('Admin not found'));
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
|
||||
// Legacy Cyclos password support
|
||||
if (!empty($admin->cyclos_salt)) {
|
||||
info('Auth attempt using original Cyclos password');
|
||||
$concatenated = $admin->cyclos_salt . $request->password;
|
||||
$hashedInputPassword = hash("sha256", $concatenated);
|
||||
|
||||
if (strtolower($hashedInputPassword) === strtolower($admin->password)) {
|
||||
info('Auth success: Password is verified');
|
||||
// Rehash to Laravel hash and remove salt
|
||||
$admin->password = \Hash::make($request->password);
|
||||
$admin->cyclos_salt = null;
|
||||
$admin->save();
|
||||
info('Auth success: Cyclos password has been rehashed for next login');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the provided password matches the hashed password in the database
|
||||
if (\Hash::check($request->password, $admin->password)) {
|
||||
|
||||
$this->switchGuard('admin', $admin); // log in as admin
|
||||
|
||||
// Remove intended switch from session
|
||||
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
|
||||
|
||||
// Set active profile session as before
|
||||
session([
|
||||
'activeProfileType' => get_class($admin),
|
||||
'activeProfileId' => $admin->id,
|
||||
'activeProfileName' => $admin->name,
|
||||
'activeProfilePhoto' => $admin->profile_photo_path,
|
||||
'activeProfileRoleLabel' => getProfileRoleLabel($admin, $user),
|
||||
'last_activity' => now(),
|
||||
'profile-switched-notification' => true,
|
||||
]);
|
||||
|
||||
// Re-activate profile if inactive
|
||||
if (timebank_config('profile_inactive.re-activate_at_login')) {
|
||||
if (!$admin->isActive()) {
|
||||
$admin->inactive_at = null;
|
||||
$admin->save();
|
||||
info('Admin re-activated: ' . $admin->name);
|
||||
}
|
||||
}
|
||||
|
||||
event(new \App\Events\ProfileSwitchEvent($admin));
|
||||
|
||||
// Check for intended URL from direct login flow
|
||||
$intendedUrl = session('admin_login_intended_url');
|
||||
if ($intendedUrl) {
|
||||
session()->forget('admin_login_intended_url');
|
||||
return redirect($intendedUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('main'); // Or your special admin main page
|
||||
}
|
||||
|
||||
info('Auth failed: Input password does not match stored password');
|
||||
session()->put('admin_login_error', __('Invalid admin password'));
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
|
||||
//TODO: Move to Trait as suggested below.
|
||||
/**
|
||||
* Helper method to get the target profile model based on the index.
|
||||
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
|
||||
* this logic into a service or trait if used in multiple places.
|
||||
*/
|
||||
private function getTargetProfileByTypeAndId($user, $type, $id)
|
||||
{
|
||||
if (!$type || !$id || !$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
|
||||
if (!$userWithRelations) return null;
|
||||
|
||||
if (strtolower($type) === 'organization') {
|
||||
return $userWithRelations->organizations->firstWhere('id', $id);
|
||||
} elseif (strtolower($type) === 'bank') {
|
||||
return $userWithRelations->banksManaged->firstWhere('id', $id);
|
||||
} elseif (strtolower($type) === 'admin') {
|
||||
return $userWithRelations->admins->firstWhere('id', $id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function profileCanResetPassword($user, $adminId): bool
|
||||
{
|
||||
if (!$user || !$adminId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$prefix = "Admin\\{$adminId}\\";
|
||||
$scopedRoles = $user->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
|
||||
|
||||
foreach ($scopedRoles as $spatieRole) {
|
||||
$roleName = str_replace($prefix, '', $spatieRole->name);
|
||||
$adminRole = \App\Models\AdminRole::where('name', $roleName)->first();
|
||||
if ($adminRole && in_array('admin change-password', $adminRole->permissions ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class AppVersionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Return the latest Android app version info.
|
||||
*
|
||||
* To publish a new version:
|
||||
* 1. Build a signed APK in Android Studio.
|
||||
* 2. Rename it to the android_app.apk_file name in the platform config, e.g. timebank-cc-1.0.1.apk.
|
||||
* 3. Upload it to storage/app/public/apk/.
|
||||
* 4. Update $versionCode and $versionName below.
|
||||
* 5. Run: php artisan storage:link (once, if not already done)
|
||||
*/
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
abort_unless(timebank_config('android_app.enabled', true), 404);
|
||||
|
||||
return response()->json(self::latest());
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest version and its download URL; also used by the static Android app page.
|
||||
* scripts/deploy-apk.sh rewrites the two version lines below.
|
||||
*/
|
||||
public static function latest(): array
|
||||
{
|
||||
$versionCode = 6;
|
||||
$versionName = '1.0.4';
|
||||
|
||||
$apkFile = str_replace('{version}', $versionName, timebank_config('android_app.apk_file', 'timebank-cc-{version}.apk'));
|
||||
|
||||
return [
|
||||
'versionCode' => $versionCode,
|
||||
'versionName' => $versionName,
|
||||
'apkUrl' => url("storage/apk/{$apkFile}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\ProfileAuthorizationHelper;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use ZipArchive;
|
||||
|
||||
class BackupChunkUploadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Receive a single chunk of the backup file.
|
||||
* POST /posts/backup-upload/chunk
|
||||
*/
|
||||
public function uploadChunk(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeAdminAccess();
|
||||
|
||||
$request->validate([
|
||||
'uploadId' => 'required|string|uuid',
|
||||
'chunkIndex' => 'required|integer|min:0|max:999',
|
||||
'totalChunks' => 'required|integer|min:1|max:1000',
|
||||
'chunk' => 'required|file|max:3072', // 3MB max per chunk
|
||||
]);
|
||||
|
||||
$uploadId = $request->input('uploadId');
|
||||
$chunkIndex = (int) $request->input('chunkIndex');
|
||||
$totalChunks = (int) $request->input('totalChunks');
|
||||
|
||||
if ($chunkIndex >= $totalChunks) {
|
||||
return response()->json(['error' => 'Invalid chunk index'], 422);
|
||||
}
|
||||
|
||||
$chunkDir = storage_path("app/temp/chunks/{$uploadId}");
|
||||
if (!File::isDirectory($chunkDir)) {
|
||||
File::makeDirectory($chunkDir, 0755, true);
|
||||
}
|
||||
|
||||
$request->file('chunk')->move($chunkDir, "chunk_{$chunkIndex}");
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'chunkIndex' => $chunkIndex,
|
||||
'received' => count(File::files($chunkDir)),
|
||||
'total' => $totalChunks,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize: reassemble chunks into the complete file.
|
||||
* POST /posts/backup-upload/finalize
|
||||
*/
|
||||
public function finalize(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeAdminAccess();
|
||||
|
||||
$request->validate([
|
||||
'uploadId' => 'required|string|uuid',
|
||||
'totalChunks' => 'required|integer|min:1|max:1000',
|
||||
'fileName' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$uploadId = $request->input('uploadId');
|
||||
$totalChunks = (int) $request->input('totalChunks');
|
||||
$fileName = basename($request->input('fileName'));
|
||||
|
||||
$chunkDir = storage_path("app/temp/chunks/{$uploadId}");
|
||||
$outputDir = storage_path('app/temp');
|
||||
|
||||
if (!File::isDirectory($outputDir)) {
|
||||
File::makeDirectory($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
// Verify all chunks exist
|
||||
for ($i = 0; $i < $totalChunks; $i++) {
|
||||
if (!File::exists("{$chunkDir}/chunk_{$i}")) {
|
||||
return response()->json(['error' => "Missing chunk {$i}"], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Reassemble
|
||||
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['zip', 'json'])) {
|
||||
File::deleteDirectory($chunkDir);
|
||||
return response()->json(['error' => 'Invalid file type'], 422);
|
||||
}
|
||||
|
||||
$assembledName = 'restore_' . uniqid() . '.' . $ext;
|
||||
$assembledPath = "{$outputDir}/{$assembledName}";
|
||||
|
||||
$output = fopen($assembledPath, 'wb');
|
||||
for ($i = 0; $i < $totalChunks; $i++) {
|
||||
$chunkPath = "{$chunkDir}/chunk_{$i}";
|
||||
$input = fopen($chunkPath, 'rb');
|
||||
stream_copy_to_stream($input, $output);
|
||||
fclose($input);
|
||||
}
|
||||
fclose($output);
|
||||
|
||||
// Clean up chunks
|
||||
File::deleteDirectory($chunkDir);
|
||||
|
||||
// Validate ZIP if applicable
|
||||
if ($ext === 'zip') {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($assembledPath) !== true) {
|
||||
@unlink($assembledPath);
|
||||
return response()->json(['error' => 'Invalid ZIP file'], 422);
|
||||
}
|
||||
if ($zip->getFromName('backup.json') === false) {
|
||||
$zip->close();
|
||||
@unlink($assembledPath);
|
||||
return response()->json(['error' => 'ZIP does not contain backup.json'], 422);
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
// Store path in session for Livewire to retrieve
|
||||
session(["backup_restore_file_{$uploadId}" => $assembledPath]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'uploadId' => $uploadId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization check matching RequiresAdminAuthorization trait logic.
|
||||
*/
|
||||
private function authorizeAdminAccess(): void
|
||||
{
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
|
||||
if (!$activeProfileType || !$activeProfileId) {
|
||||
abort(403, 'No active profile selected');
|
||||
}
|
||||
|
||||
$profile = $activeProfileType::find($activeProfileId);
|
||||
if (!$profile) {
|
||||
abort(403, 'Active profile not found');
|
||||
}
|
||||
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if ($profile instanceof \App\Models\Admin) {
|
||||
return;
|
||||
}
|
||||
if ($profile instanceof \App\Models\Bank && $profile->level === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort(403, 'Admin or central bank access required');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
|
||||
class BankController extends Controller
|
||||
{
|
||||
public function show($id)
|
||||
{
|
||||
return app(ProfileController::class)->showBank($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$bank = getActiveProfile();
|
||||
|
||||
// Verify this is a bank profile
|
||||
if (!($bank instanceof \App\Models\Bank)) {
|
||||
abort(403, 'Not a valid bank profile');
|
||||
}
|
||||
|
||||
// Verify the user can manage this bank
|
||||
$user = Auth::guard('web')->user();
|
||||
if (!$user->banksManaged->contains($bank->id)) {
|
||||
abort(403, 'You do not have permission to edit this bank');
|
||||
}
|
||||
|
||||
return view('profile-bank.edit', [
|
||||
'bank' => $bank
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function settings()
|
||||
{
|
||||
return view('profile-bank.settings');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\ProfileSwitchEvent;
|
||||
use App\Models\Bank;
|
||||
use App\Models\User;
|
||||
use App\Traits\SwitchGuardTrait;
|
||||
use Hash;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
class BankLoginController extends Controller
|
||||
{
|
||||
use SwitchGuardTrait;
|
||||
|
||||
/**
|
||||
* Direct link to bank login - can be used in emails
|
||||
* Handles the layered authentication flow:
|
||||
* 1. If user not authenticated -> redirect to user login first
|
||||
* 2. If user authenticated -> set intent and redirect to bank login
|
||||
* 3. After bank login -> redirect to intended URL or main page
|
||||
*/
|
||||
public function directLogin(Request $request, $bankId)
|
||||
{
|
||||
// Validate bank exists and load managers
|
||||
$bank = Bank::with('managers')->find($bankId);
|
||||
if (!$bank) {
|
||||
abort(404, __('Bank not found'));
|
||||
}
|
||||
|
||||
// Get optional intended destination after successful bank login.
|
||||
// Validate it is a local URL to prevent open redirect attacks.
|
||||
$intendedParam = $request->query('intended');
|
||||
$intendedUrl = $this->validateLocalUrl($intendedParam, $request);
|
||||
|
||||
// Store intended URL in session if provided
|
||||
if ($intendedUrl) {
|
||||
session(['bank_login_intended_url' => $intendedUrl]);
|
||||
}
|
||||
|
||||
// Check if user is authenticated on web guard
|
||||
if (!Auth::guard('web')->check()) {
|
||||
// User not logged in - redirect to user login with return URL
|
||||
$returnUrl = route('bank.direct-login', ['bankId' => $bankId]);
|
||||
if ($intendedUrl) {
|
||||
$returnUrl .= '?intended=' . urlencode($intendedUrl);
|
||||
}
|
||||
// Store in session for Laravel to redirect after login
|
||||
session()->put('url.intended', $returnUrl);
|
||||
|
||||
// Get the first manager's name to pre-populate the login form
|
||||
$firstManager = $bank->managers()->first();
|
||||
|
||||
if ($firstManager) {
|
||||
// Build the login URL with the name parameter and localization
|
||||
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
|
||||
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
|
||||
return redirect($loginUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// User is authenticated - verify they manage this bank
|
||||
$user = Auth::guard('web')->user();
|
||||
$userWithRelations = User::with('banksManaged')->find($user->id);
|
||||
|
||||
if (!$userWithRelations || !$userWithRelations->banksManaged->contains('id', $bankId)) {
|
||||
// Wrong user is logged in — log out all guards and redirect to login
|
||||
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
|
||||
Auth::guard($guard)->logout();
|
||||
}
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$returnUrl = route('bank.direct-login', ['bankId' => $bankId]);
|
||||
if ($intendedUrl) {
|
||||
$returnUrl .= '?intended=' . urlencode($intendedUrl);
|
||||
}
|
||||
session()->put('url.intended', $returnUrl);
|
||||
|
||||
$firstManager = $bank->managers()->first();
|
||||
if ($firstManager) {
|
||||
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
|
||||
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
|
||||
return redirect($loginUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Set the profile switch intent in session (kept for showLoginForm)
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bankId,
|
||||
]);
|
||||
|
||||
// Redirect to bank login form — pass bankId in URL so login() doesn't
|
||||
// depend solely on session values that may be lost by Livewire writes.
|
||||
return redirect()->route('bank.login', ['bank_id' => $bankId]);
|
||||
}
|
||||
|
||||
public function showLoginForm(Request $request)
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
// Prefer URL param (reliable), fall back to session (may be lost)
|
||||
$id = $request->query('bank_id') ?? session('intended_profile_switch_id');
|
||||
$type = $id ? 'bank' : session('intended_profile_switch_type');
|
||||
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
|
||||
|
||||
return view('profile-bank.login', [
|
||||
'profile' => $profile,
|
||||
'bankId' => $id,
|
||||
'canResetPassword' => $this->profileCanResetPassword($user, 'Bank', $id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$user = Auth::guard('web')->user();
|
||||
// Prefer POST field (reliable), fall back to session (may be lost to Livewire writes)
|
||||
$id = $request->input('bank_id') ?? session('intended_profile_switch_id');
|
||||
$type = $id ? 'bank' : session('intended_profile_switch_type');
|
||||
$bank = $this->getTargetProfileByTypeAndId($user, $type, $id);
|
||||
|
||||
if (!$bank) {
|
||||
return back()->withErrors(['index' => __('Bank not found')]);
|
||||
}
|
||||
|
||||
// Legacy Cyclos password support
|
||||
if (!empty($bank->cyclos_salt)) {
|
||||
info('Auth attempt using original Cyclos password');
|
||||
$concatenated = $bank->cyclos_salt . $request->password;
|
||||
$hashedInputPassword = hash("sha256", $concatenated);
|
||||
|
||||
if (strtolower($hashedInputPassword) === strtolower($bank->password)) {
|
||||
info('Auth success: Password is verified');
|
||||
// Rehash to Laravel hash and remove salt
|
||||
$bank->password = \Hash::make($request->password);
|
||||
$bank->cyclos_salt = null;
|
||||
$bank->save();
|
||||
info('Auth success: Cyclos password has been rehashed for next login');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the provided password matches the hashed password in the database
|
||||
if (\Hash::check($request->password, $bank->password)) {
|
||||
|
||||
$this->switchGuard('bank', $bank); // Log in as bank
|
||||
|
||||
// Remove intended switch from session
|
||||
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
|
||||
|
||||
// Set active profile session as before
|
||||
session([
|
||||
'activeProfileType' => get_class($bank),
|
||||
'activeProfileId' => $bank->id,
|
||||
'activeProfileName' => $bank->name,
|
||||
'activeProfilePhoto' => $bank->profile_photo_path,
|
||||
'activeProfileRoleLabel' => getProfileRoleLabel($bank, $user),
|
||||
'last_activity' => now(),
|
||||
'profile-switched-notification' => true,
|
||||
]);
|
||||
|
||||
|
||||
// Re-activate profile if inactive
|
||||
if (timebank_config('profile_inactive.re-activate_at_login')) {
|
||||
if (!$bank->isActive()) {
|
||||
$bank->inactive_at = null;
|
||||
$bank->save();
|
||||
info('Bank re-activated: ' . $bank->name);
|
||||
}
|
||||
}
|
||||
|
||||
event(new \App\Events\ProfileSwitchEvent($bank));
|
||||
|
||||
// Check for intended URL from direct login flow
|
||||
$intendedUrl = session('bank_login_intended_url');
|
||||
if ($intendedUrl) {
|
||||
session()->forget('bank_login_intended_url');
|
||||
return redirect($intendedUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('main'); // TODO: Or special bank main page
|
||||
}
|
||||
|
||||
info('Auth failed: Input password does not match stored password');
|
||||
return back()->withErrors(['password' => __('Invalid bank password')]);
|
||||
}
|
||||
|
||||
//TODO: Move to Trait as suggested below.
|
||||
/**
|
||||
* Helper method to get the target profile model based on the index.
|
||||
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
|
||||
* this logic into a service or trait if used in multiple places.
|
||||
*/
|
||||
private function getTargetProfileByTypeAndId($user, $type, $id)
|
||||
{
|
||||
if (!$type || !$id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
|
||||
if (!$userWithRelations) return null;
|
||||
|
||||
if (strtolower($type) === 'organization') {
|
||||
return $userWithRelations->organizations->firstWhere('id', $id);
|
||||
} elseif (strtolower($type) === 'bank') {
|
||||
return $userWithRelations->banksManaged->firstWhere('id', $id);
|
||||
} elseif (strtolower($type) === 'admin') {
|
||||
return $userWithRelations->admins->firstWhere('id', $id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function profileCanResetPassword($user, string $profileType, $profileId): bool
|
||||
{
|
||||
if (!$user || !$profileId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$prefix = "{$profileType}\\{$profileId}\\";
|
||||
$scopedRoles = $user->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
|
||||
|
||||
foreach ($scopedRoles as $spatieRole) {
|
||||
$roleName = str_replace($prefix, '', $spatieRole->name);
|
||||
$orgRole = \App\Models\OrganizationRole::where('name', $roleName)->first();
|
||||
if ($orgRole && in_array('org change-password', $orgRole->permissions ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Broadcasting\BroadcastController as BaseBroadcastController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
class BroadcastController extends BaseBroadcastController
|
||||
{
|
||||
/**
|
||||
* Authenticate the request for channel access.
|
||||
*
|
||||
* This custom implementation supports multi-guard authentication.
|
||||
* It uses the active guard from the session to authenticate broadcasting requests.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function authenticate(Request $request)
|
||||
{
|
||||
// Get the active guard from session (set by SwitchGuardTrait)
|
||||
$activeGuard = session('active_guard', 'web');
|
||||
|
||||
// Ensure the user is authenticated on the active guard
|
||||
if (!Auth::guard($activeGuard)->check()) {
|
||||
abort(403, 'Unauthorized - No active authentication found');
|
||||
}
|
||||
|
||||
// Get the authenticated user from the active guard
|
||||
$user = Auth::guard($activeGuard)->user();
|
||||
|
||||
// Set the default guard to use for this request
|
||||
Auth::shouldUse($activeGuard);
|
||||
|
||||
// Get the channel name from the request
|
||||
$channelName = $request->input('channel_name');
|
||||
|
||||
// Attempt to authorize the channel
|
||||
try {
|
||||
$result = Broadcast::auth($request);
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Broadcasting authentication failed', [
|
||||
'guard' => $activeGuard,
|
||||
'user_id' => $user->id,
|
||||
'channel' => $channelName,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Call;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CallController extends Controller
|
||||
{
|
||||
public function showById($id)
|
||||
{
|
||||
$call = Call::withTrashed()->with([
|
||||
'callable',
|
||||
'translations',
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'location.city.translations',
|
||||
'location.district.translations',
|
||||
'location.country.translations',
|
||||
'location.division.translations',
|
||||
'loveReactant.reactionCounters',
|
||||
])->findOrFail($id);
|
||||
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isAdmin = $activeProfileType === \App\Models\Admin::class;
|
||||
$isCallable = $activeProfileType === $call->callable_type
|
||||
&& (int) $activeProfileId === (int) $call->callable_id;
|
||||
|
||||
// Deleted calls are only visible to admins and the callable itself
|
||||
$isDeleted = $call->trashed();
|
||||
if ($isDeleted && !$isAdmin && !$isCallable) {
|
||||
return view('calls.unavailable');
|
||||
}
|
||||
|
||||
// Blocked calls are only visible to admins and the callable itself
|
||||
if ($call->is_suppressed && !$isAdmin && !$isCallable) {
|
||||
return view('calls.unavailable');
|
||||
}
|
||||
|
||||
// Paused calls are only visible to admins and the callable itself
|
||||
if ($call->is_paused && !$isAdmin && !$isCallable) {
|
||||
return view('calls.unavailable');
|
||||
}
|
||||
|
||||
// Guests cannot view private calls
|
||||
if (!$call->is_public && !Auth::check()) {
|
||||
return redirect()->route('login')
|
||||
->with('status', __('This call is private. Please log in to view it.'));
|
||||
}
|
||||
|
||||
// Expired calls are only visible to the owner and admins
|
||||
$isExpired = $call->till !== null && $call->till->isPast();
|
||||
if ($isExpired && !$isCallable && !$isAdmin) {
|
||||
return view('calls.unavailable');
|
||||
}
|
||||
|
||||
$locale = App::getLocale();
|
||||
|
||||
// Build category hierarchy
|
||||
$tagCategory = $call->tag?->contexts->first()?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
foreach ($tagCategory->ancestorsAndSelf()->get()->reverse() as $cat) {
|
||||
$name = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($name) {
|
||||
$tagCategories[] = ['id' => $cat->id, 'name' => $name, 'color' => $cat->relatedColor ?? 'gray'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build callable profile's location short string (city first, fallback to division or country)
|
||||
$callableLocation = null;
|
||||
if ($call->callable && method_exists($call->callable, 'locations')) {
|
||||
$firstLoc = $call->callable->locations()->with(['city.translations', 'division.translations', 'country.translations'])->first();
|
||||
if ($firstLoc) {
|
||||
$cCity = optional($firstLoc->city?->translations->first())->name;
|
||||
$cDivision = optional($firstLoc->division?->translations->first())->name;
|
||||
$cCountry = optional($firstLoc->country?->translations->first())->name;
|
||||
$callableLocation = $cCity ?? $cDivision ?? $cCountry ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build call exchange location: "City District, Division, COUNTRY_CODE"
|
||||
$callLocation = null;
|
||||
if ($call->location) {
|
||||
$loc = $call->location;
|
||||
$parts = [];
|
||||
$cityPart = null;
|
||||
if ($loc->city) {
|
||||
$cityPart = optional($loc->city->translations->first())->name;
|
||||
}
|
||||
if ($loc->district) {
|
||||
$districtName = optional($loc->district->translations->first())->name;
|
||||
$cityPart = $cityPart ? $cityPart . ' ' . $districtName : $districtName;
|
||||
}
|
||||
if ($cityPart) $parts[] = $cityPart;
|
||||
if ($loc->division) {
|
||||
$divisionName = optional($loc->division->translations->first())->name;
|
||||
if ($divisionName) $parts[] = $divisionName;
|
||||
}
|
||||
if ($loc->country && $loc->country->code !== 'XX') {
|
||||
$parts[] = strtoupper($loc->country->code);
|
||||
} elseif ($loc->country && $loc->country->code === 'XX') {
|
||||
$parts[] = __('Location not specified');
|
||||
}
|
||||
$callLocation = $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
|
||||
// Determine whether to show the expiry badge (within configured warning window)
|
||||
$expiryBadgeText = $call->till
|
||||
? __('Expires') . ': ' . $call->till->translatedFormat('d F Y')
|
||||
: null;
|
||||
|
||||
$callableType = $call->callable ? strtolower(class_basename($call->callable)) : null;
|
||||
|
||||
$callDescription = $call->translations->firstWhere('locale', App::getLocale())?->content
|
||||
?? $call->translations->firstWhere('locale', config('app.fallback_locale'))?->content
|
||||
?? $call->translations->first()?->content;
|
||||
|
||||
$viewName = Auth::check() ? 'calls.show' : 'calls.show-guest';
|
||||
return view($viewName, compact('call', 'tagColor', 'tagCategories', 'callableType', 'callableLocation', 'callLocation', 'expiryBadgeText', 'isExpired', 'isDeleted', 'isAdmin', 'callDescription'));
|
||||
}
|
||||
|
||||
public function callPdf($id)
|
||||
{
|
||||
$data = $this->buildCallPdfData($id);
|
||||
$pdf = Pdf::loadView('calls.pdf', $data)
|
||||
->setPaper('A6', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('call-' . $data['call']->id . '.pdf');
|
||||
}
|
||||
|
||||
public function callPdfBw($id)
|
||||
{
|
||||
$data = $this->buildCallPdfData($id);
|
||||
$pdf = Pdf::loadView('calls.pdf-bw', $data)
|
||||
->setPaper('A6', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('call-' . $data['call']->id . '-bw.pdf');
|
||||
}
|
||||
|
||||
public function callPdfA4($id)
|
||||
{
|
||||
$data = $this->buildCallPdfData($id);
|
||||
$pdf = Pdf::loadView('calls.pdf-a4', $data)
|
||||
->setPaper('A4', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('call-' . $data['call']->id . '-a4.pdf');
|
||||
}
|
||||
|
||||
public function callPdfA4Bw($id)
|
||||
{
|
||||
$data = $this->buildCallPdfData($id);
|
||||
$pdf = Pdf::loadView('calls.pdf-a4-bw', $data)
|
||||
->setPaper('A4', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('call-' . $data['call']->id . '-a4-bw.pdf');
|
||||
}
|
||||
|
||||
public function callPdfBulk(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$ids = array_filter(array_map('intval', explode(',', $request->input('ids', ''))));
|
||||
$perPage = in_array((int) $request->input('per_page', 1), [1, 2, 4])
|
||||
? (int) $request->input('per_page', 1)
|
||||
: 1;
|
||||
$color = $request->input('color', 'bw') === 'color' ? 'color' : 'bw';
|
||||
|
||||
if (empty($ids)) abort(400);
|
||||
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isAdmin = $activeProfileType === \App\Models\Admin::class
|
||||
|| ($activeProfileType === \App\Models\Bank::class
|
||||
&& \App\Models\Bank::find($activeProfileId)?->level === 0);
|
||||
|
||||
// Build per-call data, each in the call's own locale
|
||||
$calls = [];
|
||||
foreach ($ids as $id) {
|
||||
$call = Call::withTrashed()->find($id);
|
||||
if (!$call) continue;
|
||||
|
||||
// Authorization: non-admins can only print their own calls
|
||||
if (!$isAdmin) {
|
||||
$isOwner = $activeProfileType === $call->callable_type
|
||||
&& (int) $activeProfileId === (int) $call->callable_id;
|
||||
if (!$isOwner) continue;
|
||||
}
|
||||
|
||||
// Use the call's translation locale, fall back to callable's lang_preference
|
||||
$callLocale = $call->translations->first()?->locale
|
||||
?? $call->callable?->lang_preference
|
||||
?? App::getLocale();
|
||||
|
||||
$calls[] = $this->buildCallPdfData($id, $callLocale);
|
||||
}
|
||||
|
||||
if (empty($calls)) abort(403);
|
||||
|
||||
$view = $color === 'color' ? 'calls.pdf-bulk' : 'calls.pdf-bulk-bw';
|
||||
|
||||
$orientation = $perPage === 2 ? 'landscape' : 'portrait';
|
||||
|
||||
$pdf = Pdf::loadView($view, compact('calls', 'perPage'))
|
||||
->setPaper('A4', $orientation)
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
'margin_top' => 0,
|
||||
'margin_right' => 0,
|
||||
'margin_bottom' => 0,
|
||||
'margin_left' => 0,
|
||||
]);
|
||||
|
||||
return $pdf->download('calls-bulk-' . now()->format('Ymd-His') . '.pdf');
|
||||
}
|
||||
|
||||
protected function buildCallPdfData($id, ?string $locale = null): array
|
||||
{
|
||||
$previousLocale = App::getLocale();
|
||||
$locale = $locale ?? $previousLocale;
|
||||
App::setLocale($locale);
|
||||
|
||||
try {
|
||||
$data = $this->doBuildCallPdfData($id, $locale);
|
||||
} finally {
|
||||
App::setLocale($previousLocale);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function doBuildCallPdfData($id, string $locale): array
|
||||
{
|
||||
$call = Call::withTrashed()->with([
|
||||
'callable',
|
||||
'translations',
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'location.city.translations',
|
||||
'location.district.translations',
|
||||
'location.country.translations',
|
||||
'location.division.translations',
|
||||
])->findOrFail($id);
|
||||
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isAdmin = $activeProfileType === \App\Models\Admin::class;
|
||||
$isCallable = $activeProfileType === $call->callable_type
|
||||
&& (int) $activeProfileId === (int) $call->callable_id;
|
||||
|
||||
$isDeleted = $call->trashed();
|
||||
if ($isDeleted && !$isAdmin && !$isCallable) abort(403);
|
||||
if ($call->is_suppressed && !$isAdmin && !$isCallable) abort(403);
|
||||
if ($call->is_paused && !$isAdmin && !$isCallable) abort(403);
|
||||
if (!$call->is_public && !Auth::check()) abort(403);
|
||||
|
||||
$isExpired = $call->till !== null && $call->till->isPast();
|
||||
if ($isExpired && !$isCallable && !$isAdmin) abort(403);
|
||||
|
||||
$tagCategory = $call->tag?->contexts->first()?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagColorHex = $this->tailwindColorToHex($tagColor);
|
||||
$bgHex = $tagColorHex;
|
||||
$bgDarkHex = $this->darkenHex($tagColorHex, 0.50);
|
||||
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
foreach ($tagCategory->ancestorsAndSelf()->get()->reverse() as $cat) {
|
||||
$name = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($name) {
|
||||
$tagCategories[] = [
|
||||
'id' => $cat->id,
|
||||
'name' => $name,
|
||||
'colorHex' => $this->tailwindColorToHex($cat->relatedColor ?? 'gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$callableLocation = null;
|
||||
if ($call->callable && method_exists($call->callable, 'locations')) {
|
||||
$firstLoc = $call->callable->locations()->with(['city.translations', 'division.translations', 'country.translations'])->first();
|
||||
if ($firstLoc) {
|
||||
$cCity = optional($firstLoc->city?->translations->first())->name;
|
||||
$cDivision = optional($firstLoc->division?->translations->first())->name;
|
||||
$cCountry = optional($firstLoc->country?->translations->first())->name;
|
||||
$callableLocation = $cCity ?? $cDivision ?? $cCountry ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
$callLocation = null;
|
||||
if ($call->location) {
|
||||
$loc = $call->location;
|
||||
$parts = [];
|
||||
$cityPart = null;
|
||||
if ($loc->city) $cityPart = optional($loc->city->translations->first())->name;
|
||||
if ($loc->district) {
|
||||
$districtName = optional($loc->district->translations->first())->name;
|
||||
$cityPart = $cityPart ? $cityPart . ' ' . $districtName : $districtName;
|
||||
}
|
||||
if ($cityPart) $parts[] = $cityPart;
|
||||
if ($loc->division) {
|
||||
$divisionName = optional($loc->division->translations->first())->name;
|
||||
if ($divisionName) $parts[] = $divisionName;
|
||||
}
|
||||
if ($loc->country && $loc->country->code !== 'XX') {
|
||||
$parts[] = strtoupper($loc->country->code);
|
||||
} elseif ($loc->country && $loc->country->code === 'XX') {
|
||||
$parts[] = __('Location not specified');
|
||||
}
|
||||
$callLocation = $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
|
||||
$expiryBadgeText = $call->till
|
||||
? __('Expires') . ': ' . $call->till->translatedFormat('d F Y')
|
||||
: null;
|
||||
|
||||
// Pre-translate status labels in the call's locale (used by PDF templates)
|
||||
$statusLabelDeleted = __('DELETED');
|
||||
$statusLabelBlocked = __('BLOCKED');
|
||||
$statusLabelPaused = __('PAUSED');
|
||||
$statusLabelExpired = __('EXPIRED');
|
||||
|
||||
$rawContent = $call->translations->firstWhere('locale', $locale)?->content
|
||||
?? $call->translations->firstWhere('locale', config('app.fallback_locale'))?->content
|
||||
?? $call->translations->first()?->content
|
||||
?? '';
|
||||
$callContent = mb_strlen($rawContent) > 250 ? mb_substr($rawContent, 0, 247) . '…' : $rawContent;
|
||||
|
||||
$avatarBase64 = null;
|
||||
if ($call->callable?->profile_photo_path) {
|
||||
$avatarPath = storage_path('app/public/' . $call->callable->profile_photo_path);
|
||||
$ext = strtolower(pathinfo($avatarPath, PATHINFO_EXTENSION));
|
||||
if (file_exists($avatarPath)) {
|
||||
if ($ext === 'svg') {
|
||||
// SVG avatars cannot be rendered by GD — skip silently.
|
||||
} else {
|
||||
$mime = mime_content_type($avatarPath) ?: 'image/jpeg';
|
||||
$avatarBase64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($avatarPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$logoBase64 = null;
|
||||
$logoPath = theme_email_logo_file();
|
||||
if (file_exists($logoPath)) {
|
||||
$logoBase64 = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath));
|
||||
}
|
||||
|
||||
// QR codes as inline SVG (no ImageMagick needed)
|
||||
$callUrl = route('call.show', ['id' => $call->id]);
|
||||
$qrSvg = 'data:image/svg+xml;base64,' . base64_encode(
|
||||
\SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
|
||||
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->backgroundColor(255, 255, 255)->generate($callUrl)
|
||||
);
|
||||
$qrSvgBw = 'data:image/svg+xml;base64,' . base64_encode(
|
||||
\SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
|
||||
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->generate($callUrl)
|
||||
);
|
||||
|
||||
return compact(
|
||||
'call', 'tagColor', 'tagColorHex', 'bgHex', 'bgDarkHex', 'tagCategories',
|
||||
'callableLocation', 'callLocation', 'expiryBadgeText',
|
||||
'isExpired', 'isDeleted', 'avatarBase64', 'logoBase64', 'callContent', 'qrSvg', 'qrSvgBw',
|
||||
'statusLabelDeleted', 'statusLabelBlocked', 'statusLabelPaused', 'statusLabelExpired'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken a 6-char hex color by the given factor (0–1).
|
||||
* Used to simulate the dark overlay on the call card background.
|
||||
*/
|
||||
private function darkenHex(string $hex, float $factor): string
|
||||
{
|
||||
$r = (int) hexdec(substr($hex, 0, 2));
|
||||
$g = (int) hexdec(substr($hex, 2, 2));
|
||||
$b = (int) hexdec(substr($hex, 4, 2));
|
||||
$r = (int) round($r * (1 - $factor));
|
||||
$g = (int) round($g * (1 - $factor));
|
||||
$b = (int) round($b * (1 - $factor));
|
||||
return sprintf('%02x%02x%02x', $r, $g, $b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Tailwind color name to its -400 hex value for use in DomPDF.
|
||||
*/
|
||||
private function tailwindColorToHex(string $color): string
|
||||
{
|
||||
return match ($color) {
|
||||
'red' => 'f87171',
|
||||
'orange' => 'fb923c',
|
||||
'amber' => 'fbbf24',
|
||||
'yellow' => 'facc15',
|
||||
'lime' => 'a3e635',
|
||||
'green' => '4ade80',
|
||||
'emerald' => '34d399',
|
||||
'teal' => '2dd4bf',
|
||||
'cyan' => '22d3ee',
|
||||
'sky' => '38bdf8',
|
||||
'blue' => '60a5fa',
|
||||
'indigo' => '818cf8',
|
||||
'violet' => 'a78bfa',
|
||||
'purple' => 'c084fc',
|
||||
'fuchsia' => 'e879f9',
|
||||
'pink' => 'f472b6',
|
||||
'rose' => 'fb7185',
|
||||
default => '9ca3af', // gray-400
|
||||
};
|
||||
}
|
||||
|
||||
public function manage()
|
||||
{
|
||||
return view('calls.manage');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function manage()
|
||||
{
|
||||
return view('categories.manage');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new conversation with a specific profile and redirect to the chat.
|
||||
*
|
||||
* @param string $profileType The type of profile (user, organization, bank, admin)
|
||||
* @param int $id The ID of the profile
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function startConversationWith(string $profileType, int $id)
|
||||
{
|
||||
// Ensure user is authenticated
|
||||
if (!Auth::guard('web')->check() &&
|
||||
!Auth::guard('organization')->check() &&
|
||||
!Auth::guard('bank')->check() &&
|
||||
!Auth::guard('admin')->check()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Map profile type to model class
|
||||
$modelClass = match(strtolower($profileType)) {
|
||||
'user' => User::class,
|
||||
'organization' => Organization::class,
|
||||
'bank' => Bank::class,
|
||||
'admin' => Admin::class,
|
||||
default => abort(404, 'Invalid profile type'),
|
||||
};
|
||||
|
||||
// Find the recipient profile
|
||||
$recipient = $modelClass::find($id);
|
||||
|
||||
if (!$recipient) {
|
||||
abort(404, 'Profile not found');
|
||||
}
|
||||
|
||||
// Check if recipient is removed
|
||||
if (method_exists($recipient, 'isRemoved') && $recipient->isRemoved()) {
|
||||
abort(404, 'Profile no longer available');
|
||||
}
|
||||
|
||||
// Get the active profile using the helper function
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// Prevent creating conversation with yourself
|
||||
if ($activeProfile->id === $recipient->id && get_class($activeProfile) === get_class($recipient)) {
|
||||
return redirect()->route('chats')->with('error', __('You cannot start a conversation with yourself.'));
|
||||
}
|
||||
|
||||
// Create or get existing conversation
|
||||
$conversation = $activeProfile->createConversationWith($recipient);
|
||||
|
||||
// Redirect to the chat page
|
||||
return redirect()->route('chat', ['conversation' => $conversation->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
use Laravel\Fortify\Contracts\LoginResponse;
|
||||
use Laravel\Fortify\Http\Requests\LoginRequest;
|
||||
|
||||
class CustomAuthenticatedSessionController
|
||||
{
|
||||
public function store(LoginRequest $request): LoginResponse
|
||||
{
|
||||
return $this->loginPipeline($request)->then(function ($request) {
|
||||
// Skip session regeneration in Docker environment to avoid session persistence issues
|
||||
if (!env('IS_DOCKER', false)) {
|
||||
$request->session()->regenerate();
|
||||
}
|
||||
|
||||
return app(LoginResponse::class);
|
||||
});
|
||||
}
|
||||
|
||||
protected function loginPipeline(LoginRequest $request)
|
||||
{
|
||||
if (method_exists($this, 'fortify')) {
|
||||
return $this->fortify()->loginPipeline($request);
|
||||
}
|
||||
|
||||
return (new Pipeline(app()))->send($request)->through(array_filter([
|
||||
config('fortify.limiters.login') ? null : \Laravel\Fortify\Actions\EnsureLoginIsNotThrottled::class,
|
||||
config('fortify.lowercase_usernames') ? \Laravel\Fortify\Actions\CanonicalizeUsername::class : null,
|
||||
\Laravel\Fortify\Actions\AttemptToAuthenticate::class,
|
||||
// Skip PrepareAuthenticatedSession in Docker as it calls session()->migrate() which causes issues
|
||||
env('IS_DOCKER', false) ? null : \Laravel\Fortify\Actions\PrepareAuthenticatedSession::class,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
|
||||
class CustomLogoutController extends Controller
|
||||
{
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$presenceService = app(PresenceService::class);
|
||||
|
||||
// Log out from all guards and set them offline
|
||||
foreach (['web', 'admin', 'bank', 'organization'] as $guard) {
|
||||
if (Auth::guard($guard)->check()) {
|
||||
$user = Auth::guard($guard)->user();
|
||||
|
||||
// Set user offline before logging out
|
||||
$presenceService->setUserOffline($user, $guard);
|
||||
|
||||
// Clear presence caches
|
||||
Cache::forget("presence_{$guard}_{$user->id}");
|
||||
Cache::forget("presence_last_update_{$guard}_{$user->id}");
|
||||
|
||||
// Logout
|
||||
Auth::guard($guard)->logout();
|
||||
}
|
||||
}
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// For AJAX/Livewire requests, force a hard redirect
|
||||
if ($request->expectsJson() || $request->hasHeader('X-Livewire')) {
|
||||
$redirectUrl = LaravelLocalization::localizeUrl('/');
|
||||
return response('<script>window.location.href = "' . $redirectUrl . '";</script>', 200)
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
// Force a full page redirect with no-cache headers
|
||||
$response = redirect(LaravelLocalization::localizeUrl('/'));
|
||||
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
$response->headers->set('Pragma', 'no-cache');
|
||||
$response->headers->set('Expires', '0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exports\TransactionsExport;
|
||||
use App\Exports\UsersExport;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
class ExportController extends Controller
|
||||
{
|
||||
public function allUsersExport($year = null)
|
||||
{
|
||||
return Excel::download(new UsersExport(), 'users.xlsx');
|
||||
}
|
||||
|
||||
public function transactionsExport($type)
|
||||
{
|
||||
$dataArray = Session::get('export_data', []);
|
||||
Session::forget('export_data'); // Optionally clear the session data after use
|
||||
$data = collect($dataArray); // Convert array to collection
|
||||
|
||||
// Process the data and generate the export
|
||||
return Excel::download(new TransactionsExport($data), "export.{$type}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class LangJsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Returns a JavaScript file containing all the translations for the supported locales in Laravel.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function js()
|
||||
{
|
||||
$locales = config('app.locales');
|
||||
$translations = [];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$strings = File::get(resource_path("lang/{$locale}.json"));
|
||||
// ds($strings)->label('LanJsController');
|
||||
$translations[$locale] = json_decode($strings, true);
|
||||
}
|
||||
|
||||
$js = "window.i18n = " . json_encode($translations) . ";";
|
||||
$response = response($js, 200);
|
||||
$response->header('Content-Type', 'application/javascript');
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Mailing;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class MailingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the mailings management page
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Check authorization - admins need at least view or manage mailings; banks always allowed
|
||||
if (Auth::guard('admin')->check()) {
|
||||
$webUser = Auth::guard('web')->user();
|
||||
if (!$webUser || !Gate::forUser($webUser)->allows('access mailings')) {
|
||||
abort(403, 'Unauthorized to access mailings management.');
|
||||
}
|
||||
} elseif (!Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to access mailings management.');
|
||||
}
|
||||
|
||||
return view('mailings.manage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new mailing
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
// Authorization check
|
||||
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to create mailings.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'type' => ['required', Rule::in(['local_newsletter', 'general_newsletter', 'system_message'])],
|
||||
'subject' => 'required|string|max:255',
|
||||
'content_blocks' => 'nullable|array',
|
||||
'content_blocks.*.post_id' => 'required|integer|exists:posts,id',
|
||||
'content_blocks.*.order' => 'required|integer|min:1',
|
||||
'scheduled_at' => 'nullable|date|after:now',
|
||||
]);
|
||||
|
||||
// Determine creator
|
||||
$creator = Auth::guard('admin')->user() ?: Auth::guard('bank')->user();
|
||||
|
||||
$mailing = Mailing::create([
|
||||
'title' => $validated['title'],
|
||||
'type' => $validated['type'],
|
||||
'subject' => $validated['subject'],
|
||||
'content_blocks' => $validated['content_blocks'] ?? [],
|
||||
'scheduled_at' => $validated['scheduled_at'] ?? null,
|
||||
'status' => $validated['scheduled_at'] ? 'scheduled' : 'draft',
|
||||
'created_by_id' => $creator->id,
|
||||
'created_by_type' => get_class($creator),
|
||||
]);
|
||||
|
||||
// Update recipient count
|
||||
$mailing->recipients_count = $mailing->getRecipientsCount();
|
||||
$mailing->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Mailing created successfully.',
|
||||
'mailing' => $mailing->load('createdBy')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing mailing
|
||||
*/
|
||||
public function update(Request $request, Mailing $mailing)
|
||||
{
|
||||
// Authorization check
|
||||
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to update mailings.');
|
||||
}
|
||||
|
||||
// Can only edit drafts
|
||||
if (!$mailing->canBeScheduled()) {
|
||||
return response()->json(['error' => 'Only draft mailings can be edited.'], 422);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'type' => ['required', Rule::in(['local_newsletter', 'general_newsletter', 'system_message'])],
|
||||
'subject' => 'required|string|max:255',
|
||||
'content_blocks' => 'nullable|array',
|
||||
'content_blocks.*.post_id' => 'required|integer|exists:posts,id',
|
||||
'content_blocks.*.order' => 'required|integer|min:1',
|
||||
'scheduled_at' => 'nullable|date|after:now',
|
||||
]);
|
||||
|
||||
$mailing->update([
|
||||
'title' => $validated['title'],
|
||||
'type' => $validated['type'],
|
||||
'subject' => $validated['subject'],
|
||||
'content_blocks' => $validated['content_blocks'] ?? [],
|
||||
'scheduled_at' => $validated['scheduled_at'] ?? null,
|
||||
'status' => $validated['scheduled_at'] ? 'scheduled' : 'draft',
|
||||
]);
|
||||
|
||||
// Update recipient count
|
||||
$mailing->recipients_count = $mailing->getRecipientsCount();
|
||||
$mailing->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Mailing updated successfully.',
|
||||
'mailing' => $mailing->load('createdBy')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a mailing (soft delete)
|
||||
*/
|
||||
public function destroy(Mailing $mailing)
|
||||
{
|
||||
// Authorization check
|
||||
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to delete mailings.');
|
||||
}
|
||||
|
||||
// Can only delete drafts and scheduled mailings
|
||||
if (!in_array($mailing->status, ['draft', 'scheduled'])) {
|
||||
return response()->json(['error' => 'Cannot delete sent or sending mailings.'], 422);
|
||||
}
|
||||
|
||||
$mailing->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Mailing deleted successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a mailing immediately
|
||||
*/
|
||||
public function send(Request $request, Mailing $mailing)
|
||||
{
|
||||
// Authorization check
|
||||
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to send mailings.');
|
||||
}
|
||||
if (!Gate::allows('send mailings')) {
|
||||
abort(403, 'Unauthorized to send mailings.');
|
||||
}
|
||||
|
||||
if (!$mailing->canBeSent()) {
|
||||
return response()->json(['error' => 'Mailing cannot be sent in its current status.'], 422);
|
||||
}
|
||||
|
||||
// Update status to sending
|
||||
$mailing->update(['status' => 'sending']);
|
||||
|
||||
// Dispatch bulk email job (to be implemented in Phase 6)
|
||||
// SendBulkMailJob::dispatch($mailing);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Mailing is being sent. This process may take several minutes.',
|
||||
'mailing' => $mailing
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a mailing for future sending
|
||||
*/
|
||||
public function schedule(Request $request, Mailing $mailing)
|
||||
{
|
||||
// Authorization check — scheduling is deferred sending, requires send permission
|
||||
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to schedule mailings.');
|
||||
}
|
||||
if (!Gate::allows('send mailings')) {
|
||||
abort(403, 'Unauthorized to schedule mailings.');
|
||||
}
|
||||
|
||||
if (!$mailing->canBeScheduled()) {
|
||||
return response()->json(['error' => 'Mailing cannot be scheduled in its current status.'], 422);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'scheduled_at' => 'required|date|after:now',
|
||||
]);
|
||||
|
||||
$mailing->update([
|
||||
'scheduled_at' => $validated['scheduled_at'],
|
||||
'status' => 'scheduled'
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Mailing scheduled successfully.',
|
||||
'mailing' => $mailing
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a scheduled mailing
|
||||
*/
|
||||
public function cancel(Mailing $mailing)
|
||||
{
|
||||
// Authorization check
|
||||
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to cancel mailings.');
|
||||
}
|
||||
if (!Gate::allows('send mailings')) {
|
||||
abort(403, 'Unauthorized to cancel mailings.');
|
||||
}
|
||||
|
||||
if (!$mailing->canBeCancelled()) {
|
||||
return response()->json(['error' => 'Mailing cannot be cancelled in its current status.'], 422);
|
||||
}
|
||||
|
||||
$mailing->update([
|
||||
'scheduled_at' => null,
|
||||
'status' => 'draft'
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Scheduled mailing cancelled and reverted to draft.',
|
||||
'mailing' => $mailing
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a mailing
|
||||
*/
|
||||
public function preview(Request $request, Mailing $mailing)
|
||||
{
|
||||
// Authorization check
|
||||
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to preview mailings.');
|
||||
}
|
||||
|
||||
// Get current user as preview recipient
|
||||
$recipient = Auth::guard('admin')->user() ?: Auth::guard('bank')->user();
|
||||
|
||||
// Generate preview using NewsletterMail
|
||||
$newsletterMail = new \App\Mail\NewsletterMail($mailing, $recipient);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'preview_url' => route('mailings.preview_render', $mailing->id)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render mailing preview as HTML
|
||||
*/
|
||||
public function previewRender(Mailing $mailing)
|
||||
{
|
||||
// Authorization check
|
||||
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
|
||||
abort(403, 'Unauthorized to preview mailings.');
|
||||
}
|
||||
|
||||
// Resolve a locale that actually has content for this mailing
|
||||
$previewLocale = $mailing->resolvePreviewLocale();
|
||||
|
||||
// Generate and render preview for that locale
|
||||
$newsletterMail = new \App\Mail\NewsletterMail($mailing, $previewLocale, true);
|
||||
|
||||
return $newsletterMail->render();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\MessageSetting;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class NewsletterUnsubscribeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle newsletter unsubscribe requests
|
||||
*/
|
||||
public function unsubscribe(Request $request)
|
||||
{
|
||||
$email = $request->get('email');
|
||||
$type = $request->get('type');
|
||||
$signature = $request->get('signature');
|
||||
|
||||
// Verify the signature to prevent unauthorized unsubscribes
|
||||
$expectedSignature = hash_hmac('sha256', $email . $type, config('app.key'));
|
||||
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
return view('newsletter.unsubscribe-error', [
|
||||
'message' => 'Invalid unsubscribe link. Please contact support if you need help unsubscribing.'
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate newsletter type
|
||||
if (!in_array($type, ['local_newsletter', 'general_newsletter', 'system_message'])) {
|
||||
return view('newsletter.unsubscribe-error', [
|
||||
'message' => 'Invalid newsletter type.'
|
||||
]);
|
||||
}
|
||||
|
||||
// Find the user or organization by email
|
||||
$recipient = $this->findRecipientByEmail($email);
|
||||
|
||||
if (!$recipient) {
|
||||
return view('newsletter.unsubscribe-error', [
|
||||
'message' => 'Email address not found in our system.'
|
||||
]);
|
||||
}
|
||||
|
||||
// Get or create message settings
|
||||
$messageSettings = $recipient->message_settings()->first();
|
||||
if (!$messageSettings) {
|
||||
$messageSettings = new MessageSetting();
|
||||
$messageSettings->message_settingable_id = $recipient->id;
|
||||
$messageSettings->message_settingable_type = get_class($recipient);
|
||||
// Set all newsletter types to true by default (assuming they were subscribed)
|
||||
$messageSettings->local_newsletter = true;
|
||||
$messageSettings->general_newsletter = true;
|
||||
$messageSettings->system_message = true;
|
||||
}
|
||||
|
||||
// Unsubscribe from the specific newsletter type
|
||||
$messageSettings->{$type} = false;
|
||||
$messageSettings->save();
|
||||
|
||||
return view('newsletter.unsubscribe-success', [
|
||||
'email' => $email,
|
||||
'type' => $type,
|
||||
'typeName' => $this->getNewsletterTypeName($type),
|
||||
'recipient' => $recipient
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find recipient (User or Organization) by email
|
||||
*/
|
||||
protected function findRecipientByEmail(string $email)
|
||||
{
|
||||
// Try to find a User first
|
||||
$user = User::where('email', $email)->first();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Try to find an Organization
|
||||
$organization = Organization::where('email', $email)->first();
|
||||
if ($organization) {
|
||||
return $organization;
|
||||
}
|
||||
|
||||
// Try to find a Bank
|
||||
$bank = Bank::where('email', $email)->first();
|
||||
if ($bank) {
|
||||
return $bank;
|
||||
}
|
||||
|
||||
// Try to find an Admin
|
||||
$admin = Admin::where('email', $email)->first();
|
||||
if ($admin) {
|
||||
return $admin;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable newsletter type name
|
||||
*/
|
||||
protected function getNewsletterTypeName(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'local_newsletter' => 'Local Newsletter',
|
||||
'general_newsletter' => 'General Newsletter',
|
||||
'system_message' => 'System Messages',
|
||||
default => 'Newsletter'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
|
||||
public function show($orgId)
|
||||
{
|
||||
$org = Organization::select([
|
||||
'id',
|
||||
'name',
|
||||
'profile_photo_path',
|
||||
'about',
|
||||
'motivation',
|
||||
'website',
|
||||
// 'phone_public',
|
||||
'created_at',
|
||||
// 'last_login_at',
|
||||
'love_reactant_id',
|
||||
'inactive_at',
|
||||
|
||||
])
|
||||
->with([
|
||||
'users:id,name,profile_photo_path',
|
||||
'accounts:id,name,accountable_type,accountable_id',
|
||||
'languages:id,name,lang_code,flag',
|
||||
'socials:id,name,icon,urL_structure',
|
||||
'locations.district.translations:district_id,name',
|
||||
'locations.city.translations:city_id,name',
|
||||
'locations.division.translations:division_id,name',
|
||||
'locations.country.translations:country_id,name',
|
||||
'loveReactant.reactions.reacter.reacterable',
|
||||
// 'loveReactant.reactions.type',
|
||||
'loveReactant.reactionCounters',
|
||||
// 'loveReactant.reactionTotal',
|
||||
])
|
||||
->find($orgId);
|
||||
|
||||
if ($org->count() >= 1) {
|
||||
|
||||
$org->reactionCounter = $org->loveReactant->reactionCounters->first() ? (int)$org->loveReactant->reactionCounters->first()->weight : null;
|
||||
$registerDate = Carbon::createFromTimeStamp(strtotime($org->created_at), config('app.timezone'))->isoFormat('LL');
|
||||
$lastLoginDate = Carbon::createFromTimeStamp(strtotime($org->last_login_at), config('app.timezone'))->isoFormat('LL');
|
||||
|
||||
} else {
|
||||
return view('profile-organization.not_found');
|
||||
}
|
||||
|
||||
//TODO: add permission check
|
||||
//TODO: if 403, but has permission, redirect with message to switch profile
|
||||
//TODO: replace 403 with custom redirect page incl explanation
|
||||
return ($org != null ? view('profile-organization.show', compact(['org'])) : abort(403));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
return view('profile-organization.edit');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function settings()
|
||||
{
|
||||
return view('profile-organization.settings');
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\ProfileSwitchEvent;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Traits\SwitchGuardTrait;
|
||||
use Hash;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
class OrganizationLoginController extends Controller
|
||||
{
|
||||
use SwitchGuardTrait;
|
||||
|
||||
/**
|
||||
* Direct link to organization profile - can be used in emails
|
||||
* Organizations don't require password re-authentication
|
||||
* Handles the authentication flow:
|
||||
* 1. If user not authenticated -> redirect to user login first
|
||||
* 2. If user authenticated -> verify access and switch to organization
|
||||
* 3. Redirect to intended URL or main page
|
||||
*/
|
||||
public function directLogin(Request $request, $organizationId)
|
||||
{
|
||||
// Validate organization exists and load managers
|
||||
$organization = Organization::with('managers')->find($organizationId);
|
||||
if (!$organization) {
|
||||
abort(404, __('Organization not found'));
|
||||
}
|
||||
|
||||
// Get optional intended destination after successful org switch.
|
||||
// Validate it is a local URL to prevent open redirect attacks.
|
||||
$intendedParam = $request->query('intended');
|
||||
$intendedUrl = $this->validateLocalUrl($intendedParam, $request);
|
||||
|
||||
// Check if user is authenticated on web guard
|
||||
if (!Auth::guard('web')->check()) {
|
||||
// User not logged in - redirect to user login with return URL
|
||||
$returnUrl = route('organization.direct-login', ['organizationId' => $organizationId]);
|
||||
if ($intendedUrl) {
|
||||
$returnUrl .= '?intended=' . urlencode($intendedUrl);
|
||||
}
|
||||
// Store in session for Laravel to redirect after login
|
||||
session()->put('url.intended', $returnUrl);
|
||||
|
||||
// Get the first manager's name to pre-populate the login form
|
||||
$firstManager = $organization->managers()->first();
|
||||
|
||||
\Log::info('OrganizationLoginController: Redirecting unauthenticated user', [
|
||||
'organization_id' => $organizationId,
|
||||
'manager_found' => $firstManager ? 'yes' : 'no',
|
||||
'manager_name' => $firstManager ? $firstManager->name : null,
|
||||
]);
|
||||
|
||||
if ($firstManager) {
|
||||
// Build the login URL with the name parameter and localization
|
||||
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
|
||||
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
|
||||
|
||||
\Log::info('OrganizationLoginController: Redirect URL', [
|
||||
'url' => $loginUrl,
|
||||
]);
|
||||
|
||||
return redirect($loginUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// User is authenticated - verify they own/manage this organization
|
||||
$user = Auth::guard('web')->user();
|
||||
$userWithRelations = User::with('organizations')->find($user->id);
|
||||
|
||||
if (!$userWithRelations || !$userWithRelations->organizations->contains('id', $organizationId)) {
|
||||
// Wrong user is logged in — log out all guards and redirect to login
|
||||
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
|
||||
Auth::guard($guard)->logout();
|
||||
}
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$returnUrl = route('organization.direct-login', ['organizationId' => $organizationId]);
|
||||
if ($intendedUrl) {
|
||||
$returnUrl .= '?intended=' . urlencode($intendedUrl);
|
||||
}
|
||||
session()->put('url.intended', $returnUrl);
|
||||
|
||||
$firstManager = $organization->managers()->first();
|
||||
if ($firstManager) {
|
||||
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
|
||||
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
|
||||
return redirect($loginUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Switch to organization guard directly (no password required for organizations)
|
||||
$this->switchGuard('organization', $organization);
|
||||
|
||||
// Set active profile session
|
||||
session([
|
||||
'activeProfileType' => get_class($organization),
|
||||
'activeProfileId' => $organization->id,
|
||||
'activeProfileName' => $organization->name,
|
||||
'activeProfilePhoto' => $organization->profile_photo_path,
|
||||
'activeProfileRoleLabel' => getProfileRoleLabel($organization, $user),
|
||||
'last_activity' => now(),
|
||||
'profile-switched-notification' => true,
|
||||
]);
|
||||
|
||||
// Re-activate profile if inactive
|
||||
if (timebank_config('profile_inactive.re-activate_at_login')) {
|
||||
if (!$organization->isActive()) {
|
||||
$organization->inactive_at = null;
|
||||
$organization->save();
|
||||
info('Organization re-activated: ' . $organization->name);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast profile switch event
|
||||
event(new \App\Events\ProfileSwitchEvent($organization));
|
||||
|
||||
// Redirect to intended URL or main page
|
||||
if ($intendedUrl) {
|
||||
return redirect($intendedUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('main');
|
||||
}
|
||||
|
||||
public function showLoginForm()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
$type = session('intended_profile_switch_type');
|
||||
$id = session('intended_profile_switch_id');
|
||||
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
|
||||
|
||||
return view('profile-organization.login', [
|
||||
'profile' => $profile,
|
||||
'canResetPassword' => $this->profileCanResetPassword($user, 'Organization', $id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$user = Auth::guard('web')->user();
|
||||
$type = session('intended_profile_switch_type');
|
||||
$id = session('intended_profile_switch_id');
|
||||
$organization = $this->getTargetProfileByTypeAndId($user, $type, $id);
|
||||
|
||||
if (!$organization) {
|
||||
return back()->withErrors(['index' => __('Organization not found')]);
|
||||
}
|
||||
|
||||
// Legacy Cyclos password support
|
||||
if (!empty($organization->cyclos_salt)) {
|
||||
info('Auth attempt using original Cyclos password');
|
||||
$concatenated = $organization->cyclos_salt . $request->password;
|
||||
$hashedInputPassword = hash("sha256", $concatenated);
|
||||
|
||||
if (strtolower($hashedInputPassword) === strtolower($organization->password)) {
|
||||
info('Auth success: Password is verified');
|
||||
// Rehash to Laravel hash and remove salt
|
||||
$organization->password = \Hash::make($request->password);
|
||||
$organization->cyclos_salt = null;
|
||||
$organization->save();
|
||||
info('Auth success: Cyclos password has been rehashed for next login');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the provided password matches the hashed password in the database
|
||||
if (\Hash::check($request->password, $organization->password)) {
|
||||
|
||||
$this->switchGuard('organization', $organization); // log in as organization
|
||||
|
||||
// Remove intended switch from session
|
||||
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
|
||||
|
||||
// Set active profile session as before
|
||||
session([
|
||||
'activeProfileType' => get_class($organization),
|
||||
'activeProfileId' => $organization->id,
|
||||
'activeProfileName' => $organization->name,
|
||||
'activeProfilePhoto' => $organization->profile_photo_path,
|
||||
'activeProfileRoleLabel' => getProfileRoleLabel($organization, $user),
|
||||
'last_activity' => now(),
|
||||
'profile-switched-notification' => true,
|
||||
]);
|
||||
|
||||
// Re-activate profile if inactive
|
||||
if (timebank_config('profile_inactive.re-activate_at_login')) {
|
||||
if (!$organization->isActive()) {
|
||||
$organization->inactive_at = null;
|
||||
$organization->save();
|
||||
info('Organization re-activated: ' . $organization->name);
|
||||
}
|
||||
}
|
||||
|
||||
event(new \App\Events\ProfileSwitchEvent($organization));
|
||||
|
||||
// Check for intended URL from direct login flow
|
||||
$intendedUrl = session('organization_login_intended_url');
|
||||
if ($intendedUrl) {
|
||||
session()->forget('organization_login_intended_url');
|
||||
return redirect($intendedUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('main'); // Or your special organization main page
|
||||
}
|
||||
|
||||
info('Auth failed: Input password does not match stored password');
|
||||
return back()->withErrors(['password' => __('Invalid organization password')]);
|
||||
}
|
||||
|
||||
//TODO: Move to Trait as suggested below.
|
||||
/**
|
||||
* Helper method to get the target profile model based on the index.
|
||||
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
|
||||
* this logic into a service or trait if used in multiple places.
|
||||
*/
|
||||
private function getTargetProfileByTypeAndId($user, $type, $id)
|
||||
{
|
||||
if (!$type || !$id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
|
||||
if (!$userWithRelations) return null;
|
||||
|
||||
if (strtolower($type) === 'organization') {
|
||||
return $userWithRelations->organizations->firstWhere('id', $id);
|
||||
} elseif (strtolower($type) === 'bank') {
|
||||
return $userWithRelations->banksManaged->firstWhere('id', $id);
|
||||
} elseif (strtolower($type) === 'admin') {
|
||||
return $userWithRelations->admins->firstWhere('id', $id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function profileCanResetPassword($user, string $profileType, $profileId): bool
|
||||
{
|
||||
if (!$user || !$profileId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$prefix = "{$profileType}\\{$profileId}\\";
|
||||
$scopedRoles = $user->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
|
||||
|
||||
foreach ($scopedRoles as $spatieRole) {
|
||||
$roleName = str_replace($prefix, '', $spatieRole->name);
|
||||
$orgRole = \App\Models\OrganizationRole::where('name', $roleName)->first();
|
||||
if ($orgRole && in_array('org change-password', $orgRole->permissions ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
public function manage()
|
||||
{
|
||||
return view('permissions.manage');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
|
||||
/**
|
||||
* Dedicated page of a poll: its post (title, image, intro, text) with the poll form below.
|
||||
*/
|
||||
class PollController extends Controller
|
||||
{
|
||||
public function show(string $slug)
|
||||
{
|
||||
// Polls are for logged-in profiles only. The route requires a login already; this is a
|
||||
// safety net should the route ever be moved out of its protected group.
|
||||
if (!Auth::guard('web')->check()) {
|
||||
session(['url.intended' => request()->url()]);
|
||||
|
||||
return redirect(LaravelLocalization::localizeUrl('/login'));
|
||||
}
|
||||
|
||||
$postId = PostTranslation::where('slug', $slug)->value('post_id');
|
||||
$post = $postId ? Post::with(['poll', 'category.translations'])->find($postId) : null;
|
||||
if (!$post || !$post->poll) {
|
||||
return view('posts.not_found');
|
||||
}
|
||||
|
||||
// Poll admins may preview unpublished polls and drafts; for everyone else a draft does
|
||||
// not exist yet, even when its post already has a publication date (security audit R3)
|
||||
$preview = Poll::viewerIsPollAdmin();
|
||||
if ($post->poll->status === Poll::STATUS_DRAFT && !$preview) {
|
||||
return view('posts.not_found');
|
||||
}
|
||||
$published = function ($query) use ($preview) {
|
||||
if (!$preview) {
|
||||
$query->whereDate('from', '<=', now())
|
||||
->where(fn ($q) => $q->whereDate('till', '>', now())->orWhereNull('till'));
|
||||
}
|
||||
};
|
||||
|
||||
// The current language, otherwise any published language
|
||||
$translation = $post->translations()->where('locale', App::getLocale())->where($published)->first()
|
||||
?? $post->translations()->where($published)->first();
|
||||
if (!$translation) {
|
||||
return view('posts.not_found');
|
||||
}
|
||||
|
||||
// Another of the user's profiles may take part while the active one may not: right after
|
||||
// logging in to this poll (a one-time session marker set by LoginResponse) with only one
|
||||
// such profile, switch to it straight away (its own login flow, password for banks and
|
||||
// admins); otherwise offer them on the page.
|
||||
$justLoggedIn = session()->pull('poll_switch_after_login') === '/' . ltrim(request()->path(), '/');
|
||||
$switchProfiles = $this->switchProfiles($post->poll, request()->url());
|
||||
if ($justLoggedIn && count($switchProfiles) === 1) {
|
||||
return redirect($switchProfiles[0]['url']);
|
||||
}
|
||||
|
||||
$media = $post->getFirstMedia('posts');
|
||||
$category = $post->category?->translations->firstWhere('locale', App::getLocale())?->name
|
||||
?? $post->category?->translations->first()?->name;
|
||||
$update = Carbon::parse($translation->updated_at)->isoFormat('LL');
|
||||
|
||||
return view('polls.show', compact('post', 'translation', 'media', 'category', 'update', 'switchProfiles'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The logged-in user's other profiles that may take part when the active profile may not,
|
||||
* each with the direct login link of that profile that returns to this poll.
|
||||
*
|
||||
* @return array<int, array{name: string, type: string, url: string}>
|
||||
*/
|
||||
private function switchProfiles(Poll $poll, string $pollUrl): array
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
$active = getActiveProfile();
|
||||
if (!$user instanceof User || !$active || !$poll->isOpen() || $poll->isTargetedAt($active, $user)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$user->loadMissing(['organizations', 'banksManaged', 'admins']);
|
||||
$routes = [
|
||||
User::class => ['user.direct-login', 'userId'],
|
||||
Organization::class => ['organization.direct-login', 'organizationId'],
|
||||
Bank::class => ['bank.direct-login', 'bankId'],
|
||||
Admin::class => ['admin.direct-login', 'adminId'],
|
||||
];
|
||||
|
||||
$profiles = collect([$user])->concat($user->organizations)->concat($user->banksManaged)->concat($user->admins)
|
||||
->reject(fn (Model $profile) => $profile->is($active))
|
||||
->filter(fn (Model $profile) => $poll->isTargetedAt($profile, $user));
|
||||
|
||||
return $profiles->map(function (Model $profile) use ($routes, $pollUrl) {
|
||||
[$route, $parameter] = $routes[get_class($profile)];
|
||||
|
||||
return [
|
||||
'name' => $profile->name,
|
||||
'type' => class_basename($profile),
|
||||
'url' => route($route, [$parameter => $profile->getKey()]) . '?intended=' . urlencode($pollUrl),
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\ProfileAuthorizationHelper;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Poll;
|
||||
use App\Polls\ResultsCsv;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Poll results as a CSV download. A plain HTTP route (not a Livewire action) so the file is
|
||||
* streamed instead of buffered in memory.
|
||||
*/
|
||||
class PollResultsExportController extends Controller
|
||||
{
|
||||
public function __invoke(Poll $poll)
|
||||
{
|
||||
// Admin (or central bank) profile allowed to see who voted
|
||||
$profile = getActiveProfile();
|
||||
if (!$profile || !($profile instanceof Admin || ($profile instanceof Bank && $profile->level === 0))) {
|
||||
abort(403);
|
||||
}
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
if (!Gate::allows('export polls')) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Log::info('Poll results exported', ['poll_id' => $poll->id, 'user_id' => auth('web')->id()]);
|
||||
|
||||
$csv = new ResultsCsv($poll, App::getLocale());
|
||||
$filename = 'poll-' . $poll->id . '-results-' . now()->format('Y-m-d') . '.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($csv) {
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM, so Excel shows accents correctly
|
||||
fputcsv($out, $csv->header());
|
||||
foreach ($csv->rows() as $row) {
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
fclose($out);
|
||||
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\User;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Verification page of a poll and its ledger as JSON.
|
||||
*
|
||||
* The page is for the profiles the poll is meant for (and poll admins), once its post is
|
||||
* published: chain state, seal and the receipt check. Level "public" adds the anonymous
|
||||
* ballot list, the recount and the ledger download, but only for viewers who may see the
|
||||
* results under the poll's results setting: the ballots are the results.
|
||||
*/
|
||||
class PollVerificationController extends Controller
|
||||
{
|
||||
public function show(Poll $poll, Ledger $ledger)
|
||||
{
|
||||
if (!$this->mayView($poll)) {
|
||||
return view('posts.not_found');
|
||||
}
|
||||
|
||||
$public = $this->maySeeBallots($poll);
|
||||
$entries = PollLedgerEntry::where('poll_id', $poll->id)->get(['sequence', 'entry_type', 'refers_to_sequence']);
|
||||
$counts = [
|
||||
'cast' => $entries->where('entry_type', PollLedgerEntry::CAST)->count(),
|
||||
'change' => $entries->where('entry_type', PollLedgerEntry::CHANGE)->count(),
|
||||
'withdraw' => $entries->where('entry_type', PollLedgerEntry::WITHDRAW)->count(),
|
||||
'active' => $ledger->activeBallots($poll)->count(),
|
||||
];
|
||||
|
||||
return view('polls.verify', [
|
||||
'poll' => $poll,
|
||||
'title' => $this->title($poll),
|
||||
'slug' => $this->slug($poll),
|
||||
'chain' => $ledger->verifyChain($poll),
|
||||
'counts' => $counts,
|
||||
'public' => $public,
|
||||
'ballotsHidden' => !$public && $poll->verification === Poll::VERIFICATION_PUBLIC,
|
||||
'recount' => $public ? $this->recount($poll, $ledger) : null,
|
||||
'ballots' => $public ? PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->paginate(50) : null,
|
||||
'replacedBy' => $entries->whereNotNull('refers_to_sequence')->pluck('entry_type', 'refers_to_sequence'),
|
||||
'questions' => $poll->questions()->get()->keyBy('id'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The public ledger, for verification in the browser or with any other tool. */
|
||||
public function ledger(Poll $poll, Ledger $ledger)
|
||||
{
|
||||
if (!$this->mayView($poll) || !$this->maySeeBallots($poll)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$entries = PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->get()
|
||||
->map(fn (PollLedgerEntry $entry) => $ledger->publicEntry($entry));
|
||||
|
||||
return response()->json([
|
||||
'poll_id' => $poll->id,
|
||||
'format' => 'hash = SHA-256 of canonical JSON of {v, poll_id, sequence, entry_type, fingerprint, refers_to_sequence, payload, recorded_at, previous_hash}; keys sorted by UTF-8 bytes; first previous_hash is 64 zeros. Verifier: resources/js/poll-ledger-verifier.js',
|
||||
'sealed_at' => $poll->sealed_at?->toIso8601String(),
|
||||
'seal_hash' => $poll->seal_hash,
|
||||
'published_tally' => $ledger->publishedTally($poll),
|
||||
'entries' => $entries,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verification page: verification on, not a draft, and the viewer is a poll admin or
|
||||
* a profile the poll is meant for, with the poll's post published. Anyone else gets the
|
||||
* same "not found" as for a poll that does not exist.
|
||||
*/
|
||||
private function mayView(Poll $poll): bool
|
||||
{
|
||||
if ($poll->verification === Poll::VERIFICATION_OFF || $poll->status === Poll::STATUS_DRAFT) {
|
||||
return false;
|
||||
}
|
||||
if ($this->isPollAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$published = $poll->post?->translations()
|
||||
->whereDate('from', '<=', now())
|
||||
->where(fn ($q) => $q->whereDate('till', '>', now())->orWhereNull('till'))
|
||||
->exists();
|
||||
[$profile, $user] = $this->viewer();
|
||||
|
||||
return $published && $profile && $user
|
||||
&& $poll->matchesProfile($profile) && $poll->memberMayVote($profile, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ballot list, recount and ledger JSON reveal the results, so they follow the poll's
|
||||
* results setting (after voting / after closing / never), except for poll admins.
|
||||
*/
|
||||
private function maySeeBallots(Poll $poll): bool
|
||||
{
|
||||
if ($poll->verification !== Poll::VERIFICATION_PUBLIC) {
|
||||
return false;
|
||||
}
|
||||
if ($this->isPollAdmin()) {
|
||||
return true;
|
||||
}
|
||||
[$profile, $user] = $this->viewer();
|
||||
|
||||
return $profile && $user && $poll->resultsVisibleToVoter($poll->responseFor($profile, $user->id) !== null);
|
||||
}
|
||||
|
||||
/** Same access as the Polls page and the results export. */
|
||||
private function isPollAdmin(): bool
|
||||
{
|
||||
return Poll::viewerIsPollAdmin();
|
||||
}
|
||||
|
||||
/** @return array{0: ?\Illuminate\Database\Eloquent\Model, 1: ?User} */
|
||||
private function viewer(): array
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
|
||||
return [getActiveProfile(), $user instanceof User ? $user : null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per question: does the recount of the ledger match the published result?
|
||||
*
|
||||
* @return array{matches: bool, questions: array<int, bool>}
|
||||
*/
|
||||
private function recount(Poll $poll, Ledger $ledger): array
|
||||
{
|
||||
$ledgerTally = collect($ledger->ledgerTally($poll))->groupBy('question_id');
|
||||
$publishedTally = collect($ledger->publishedTally($poll))->groupBy('question_id');
|
||||
|
||||
$questions = [];
|
||||
foreach ($poll->questions()->pluck('id') as $questionId) {
|
||||
$questions[$questionId] = $this->rows($ledgerTally->get($questionId)) === $this->rows($publishedTally->get($questionId));
|
||||
}
|
||||
|
||||
return ['matches' => !in_array(false, $questions, true) && $ledgerTally->keys()->diff($publishedTally->keys())->isEmpty(), 'questions' => $questions];
|
||||
}
|
||||
|
||||
private function rows(?Collection $rows): array
|
||||
{
|
||||
return $rows ? $rows->values()->all() : [];
|
||||
}
|
||||
|
||||
private function title(Poll $poll): string
|
||||
{
|
||||
$translations = $poll->post?->translations ?? collect();
|
||||
|
||||
return ($translations->firstWhere('locale', App::getLocale()) ?? $translations->first())?->title ?? '';
|
||||
}
|
||||
|
||||
private function slug(Poll $poll): ?string
|
||||
{
|
||||
$translations = $poll->post?->translations ?? collect();
|
||||
|
||||
return ($translations->firstWhere('locale', App::getLocale()) ?? $translations->first())?->slug;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Language;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
|
||||
public function showById($id)
|
||||
{
|
||||
$post =
|
||||
Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) {
|
||||
$query->with('translations');
|
||||
},
|
||||
'translations' => function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale());
|
||||
// ->whereDate('from', '<=', now()) //TODO: Exclude date queries ONLY for post Admins!
|
||||
// ->where( function($query) {
|
||||
// $query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
// })
|
||||
},
|
||||
])
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
// Poll posts have their own page
|
||||
if (\App\Models\Poll::where('post_id', $post->id)->exists()) {
|
||||
$slug = $post->translations->first()?->slug ?? PostTranslation::where('post_id', $post->id)->value('slug');
|
||||
if ($slug) {
|
||||
return redirect()->route('poll.show', ['slug' => $slug]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($post->media) {
|
||||
$media = Post::find($id)->getFirstMedia('posts');
|
||||
}
|
||||
|
||||
if ($post->translations->count() >= 1) {
|
||||
|
||||
if ($post->category->translations) {
|
||||
$category = $post->category->translations->where('locale', App::getLocale())->first()->name;
|
||||
}
|
||||
|
||||
$update = Carbon::createFromTimeStamp(strtotime($post->translations->first()->updated_at), config('app.timezone'))->isoFormat('LL');
|
||||
|
||||
} else {
|
||||
// No translation in current locale - check for fallback translations
|
||||
return $this->handleNoTranslation($post, $id);
|
||||
}
|
||||
|
||||
// Use guest layout for non-authenticated users, app layout for authenticated users
|
||||
$viewName = Auth::check() ? 'posts.show' : 'posts.show-guest';
|
||||
return ($post != null ? view($viewName, compact(['post','media','category','update'])) : abort(403));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle case when no translation exists for current locale.
|
||||
* Check for available translations in other locales and offer fallback.
|
||||
*/
|
||||
private function handleNoTranslation($post, $postId)
|
||||
{
|
||||
// Get all available translations for this post (active ones)
|
||||
$availableTranslation = PostTranslation::where('post_id', $postId)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->first();
|
||||
|
||||
$viewData = [
|
||||
'fallbackLocale' => null,
|
||||
'fallbackLanguageName' => null,
|
||||
'fallbackUrl' => null,
|
||||
'post' => $post,
|
||||
];
|
||||
|
||||
if ($availableTranslation) {
|
||||
// Found an available translation in another locale
|
||||
$fallbackLocale = $availableTranslation->locale;
|
||||
$fallbackLanguageName = Language::where('lang_code', $fallbackLocale)->first()->name ?? $fallbackLocale;
|
||||
|
||||
// Generate URL to the same post in the fallback locale
|
||||
$fallbackUrl = \LaravelLocalization::getLocalizedURL($fallbackLocale, route('post.show', ['id' => $postId]));
|
||||
|
||||
$viewData['fallbackLocale'] = $fallbackLocale;
|
||||
$viewData['fallbackLanguageName'] = __($fallbackLanguageName);
|
||||
$viewData['fallbackUrl'] = $fallbackUrl;
|
||||
}
|
||||
|
||||
// Use guest layout for non-authenticated users, app layout for authenticated users
|
||||
$viewName = Auth::check() ? 'posts.no_translation' : 'posts.no_translation-guest';
|
||||
return view($viewName, $viewData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show view by .../posts/{slug}.
|
||||
* Post will be shown in user's App:locale() language if available, even is the slug is in another language.
|
||||
*
|
||||
* @param mixed $slug
|
||||
* @return void
|
||||
*/
|
||||
public function showBySlug($slug)
|
||||
{
|
||||
$postTranslation = PostTranslation::where('slug', $slug)->get()->first();
|
||||
if (!$postTranslation) {
|
||||
return view('posts.not_found');
|
||||
}
|
||||
$id = $postTranslation->post_id;
|
||||
$locale = $postTranslation->locale;
|
||||
|
||||
// Poll posts have their own page
|
||||
if (\App\Models\Poll::where('post_id', $id)->exists()) {
|
||||
return redirect()->route('poll.show', ['slug' => $slug]);
|
||||
}
|
||||
|
||||
$post = Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) {
|
||||
$query->with('translations');
|
||||
},
|
||||
'meeting',
|
||||
'translations' => function ($query) {
|
||||
//TODO!: Currently only user 1 (Super-admin) can view unpublished posts, change to permission/role based!
|
||||
$query->where('locale', App::getLocale());
|
||||
// Only show published posts for guests and non-admin users
|
||||
if (!Auth::guard('web')->check() || Auth::guard('web')->user()->id != 1) {
|
||||
$query->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
});
|
||||
}
|
||||
}
|
||||
])
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
|
||||
if ($post->media) {
|
||||
$media = Post::find($id)->getFirstMedia('posts');
|
||||
}
|
||||
|
||||
|
||||
if ($post->translations->count() >= 1) {
|
||||
if ($post->category->translations) {
|
||||
$category = $post->category->translations->where('locale', App::getLocale())->first()->name;
|
||||
}
|
||||
|
||||
$update = Carbon::createFromTimeStamp(strtotime($post->translations->first()->updated_at), config('app.timezone'))->isoFormat('LL');
|
||||
|
||||
} else {
|
||||
// No translation in current locale - check for fallback translations
|
||||
return $this->handleNoTranslation($post, $id);
|
||||
}
|
||||
|
||||
// Use guest layout for non-authenticated users, app layout for authenticated users
|
||||
$viewName = Auth::check() ? 'posts.show' : 'posts.show-guest';
|
||||
return ($post != null ? view($viewName, compact(['post','media','category','update'])) : abort(403));
|
||||
|
||||
}
|
||||
|
||||
|
||||
public function manage()
|
||||
{
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, __('Admin profile required'));
|
||||
}
|
||||
|
||||
return view('posts.manage');
|
||||
}
|
||||
|
||||
|
||||
public function postPdf($id)
|
||||
{
|
||||
$data = $this->buildPostPdfData($id);
|
||||
$pdf = Pdf::loadView('posts.pdf', $data)
|
||||
->setPaper('A4', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('post-' . $data['post']->id . '.pdf');
|
||||
}
|
||||
|
||||
public function postPdfBulk(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$ids = array_filter(array_map('intval', explode(',', $request->input('ids', ''))));
|
||||
$perPage = in_array((int) $request->input('per_page', 1), [1, 2, 4])
|
||||
? (int) $request->input('per_page', 1)
|
||||
: 1;
|
||||
if (empty($ids)) abort(400);
|
||||
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isAdmin = $activeProfileType === \App\Models\Admin::class
|
||||
|| ($activeProfileType === \App\Models\Bank::class
|
||||
&& \App\Models\Bank::find($activeProfileId)?->level === 0);
|
||||
|
||||
if (!$isAdmin) abort(403);
|
||||
|
||||
$posts = [];
|
||||
foreach ($ids as $translationId) {
|
||||
$translation = PostTranslation::withTrashed()->find($translationId);
|
||||
if (!$translation) continue;
|
||||
|
||||
$locale = $translation->locale ?? App::getLocale();
|
||||
$posts[] = $this->buildPostPdfDataForLocale($translation->post_id, $locale);
|
||||
}
|
||||
|
||||
if (empty($posts)) abort(403);
|
||||
|
||||
$view = 'posts.pdf-bulk';
|
||||
$orientation = $perPage === 2 ? 'landscape' : 'portrait';
|
||||
|
||||
$pdf = Pdf::loadView($view, compact('posts', 'perPage'))
|
||||
->setPaper('A4', $orientation)
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
'margin_top' => 0,
|
||||
'margin_right' => 0,
|
||||
'margin_bottom' => 0,
|
||||
'margin_left' => 0,
|
||||
]);
|
||||
|
||||
return $pdf->download('posts-bulk-' . now()->format('Ymd-His') . '.pdf');
|
||||
}
|
||||
|
||||
private function buildPostPdfDataForLocale(int $postId, string $locale): array
|
||||
{
|
||||
$previous = App::getLocale();
|
||||
App::setLocale($locale);
|
||||
try {
|
||||
return $this->buildPostPdfData($postId);
|
||||
} finally {
|
||||
App::setLocale($previous);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPostPdfData($id): array
|
||||
{
|
||||
$locale = App::getLocale();
|
||||
|
||||
$post = Post::with([
|
||||
'author',
|
||||
'category.translations',
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale);
|
||||
},
|
||||
'meeting.meetingable',
|
||||
'meeting.location.city.translations',
|
||||
])->findOrFail($id);
|
||||
|
||||
// Polls are for logged-in profiles only
|
||||
abort_if($post->isPoll() && !Auth::guard('web')->check(), 404);
|
||||
|
||||
$translation = $post->translations->first();
|
||||
|
||||
$categoryName = $post->category?->translations->firstWhere('locale', $locale)?->name
|
||||
?? $post->category?->translations->first()?->name;
|
||||
|
||||
$categoryType = $post->category?->type ?? '';
|
||||
$isEvent = $categoryType === 'App\Models\Meeting';
|
||||
$isArticle = $categoryType === 'App\Models\Article';
|
||||
$isNews = $categoryType === 'App\Models\News';
|
||||
$isImagePost = str_starts_with($categoryType, 'App\Models\ImagePost');
|
||||
|
||||
$update = $translation
|
||||
? Carbon::parse($translation->updated_at)->isoFormat('LL')
|
||||
: null;
|
||||
|
||||
// Hero image as base64 for DomPDF (GD for webp, direct read for others)
|
||||
$heroBase64 = null;
|
||||
$heroMedia = $post->getFirstMedia('posts');
|
||||
if ($heroMedia) {
|
||||
$heroPath = $heroMedia->getPath();
|
||||
if (file_exists($heroPath)) {
|
||||
$raw = @file_get_contents($heroPath);
|
||||
if ($raw) {
|
||||
$src = @imagecreatefromstring($raw);
|
||||
if ($src) {
|
||||
// Crop to A4 portrait aspect ratio (cover behaviour)
|
||||
$srcW = imagesx($src);
|
||||
$srcH = imagesy($src);
|
||||
$dstW = 1240; // 210mm @ 150dpi
|
||||
$dstH = 1754; // 297mm @ 150dpi
|
||||
$scale = max($dstW / $srcW, $dstH / $srcH);
|
||||
$scaledW = (int) round($srcW * $scale);
|
||||
$scaledH = (int) round($srcH * $scale);
|
||||
$cropX = (int) round(($scaledW - $dstW) / 2);
|
||||
$cropY = (int) round(($scaledH - $dstH) / 2);
|
||||
$dst = imagecreatetruecolor($dstW, $dstH);
|
||||
imagecopyresampled($dst, $src, 0, 0, (int) round($cropX / $scale), (int) round($cropY / $scale), $dstW, $dstH, (int) round($dstW / $scale), (int) round($dstH / $scale));
|
||||
// Boost saturation +50%: blend color image with its grayscale at factor > 1
|
||||
// output = gray + (color - gray) * 1.5 → clamp to 0-255
|
||||
$gray = imagecreatetruecolor($dstW, $dstH);
|
||||
imagecopy($gray, $dst, 0, 0, 0, 0, $dstW, $dstH);
|
||||
imagefilter($gray, IMG_FILTER_GRAYSCALE);
|
||||
for ($px = 0; $px < $dstW; $px++) {
|
||||
for ($py = 0; $py < $dstH; $py++) {
|
||||
$c = imagecolorat($dst, $px, $py);
|
||||
$g = imagecolorat($gray, $px, $py);
|
||||
$gr = ($g >> 16) & 0xFF;
|
||||
$nr = max(0, min(255, (int) round($gr + ((($c >> 16) & 0xFF) - $gr) * 1.75)));
|
||||
$ng = max(0, min(255, (int) round($gr + ((($c >> 8) & 0xFF) - $gr) * 1.75)));
|
||||
$nb = max(0, min(255, (int) round($gr + (($c & 0xFF) - $gr) * 1.75)));
|
||||
imagesetpixel($dst, $px, $py, ($nr << 16) | ($ng << 8) | $nb);
|
||||
}
|
||||
}
|
||||
imagedestroy($gray);
|
||||
ob_start();
|
||||
imagejpeg($dst, null, 85);
|
||||
$heroBase64 = 'data:image/jpeg;base64,' . base64_encode(ob_get_clean());
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Author avatar as base64 (SVG skipped — GD cannot render SVG)
|
||||
$avatarBase64 = null;
|
||||
$author = $post->author;
|
||||
if ($author && $author->profile_photo_path) {
|
||||
$avatarPath = storage_path('app/public/' . $author->profile_photo_path);
|
||||
$ext = strtolower(pathinfo($avatarPath, PATHINFO_EXTENSION));
|
||||
if (file_exists($avatarPath) && $ext !== 'svg') {
|
||||
$mime = mime_content_type($avatarPath) ?: 'image/jpeg';
|
||||
$avatarBase64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($avatarPath));
|
||||
}
|
||||
}
|
||||
|
||||
$brandColor = theme_color('brand') ?: '#000000';
|
||||
|
||||
// Platform logo
|
||||
$logoBase64 = null;
|
||||
$logoPath = theme_email_logo_file();
|
||||
if (file_exists($logoPath)) {
|
||||
$logoBase64 = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath));
|
||||
}
|
||||
|
||||
// Hero logo: invert RGB to white while preserving alpha transparency
|
||||
$logoWhiteBase64 = null;
|
||||
if ($logoPath && file_exists($logoPath)) {
|
||||
$src = @imagecreatefrompng($logoPath);
|
||||
if ($src) {
|
||||
$w = imagesx($src);
|
||||
$h = imagesy($src);
|
||||
$dst = imagecreatetruecolor($w, $h);
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
// Fill with transparent background
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefill($dst, 0, 0, $transparent);
|
||||
// Copy pixel by pixel, inverting RGB but keeping alpha
|
||||
for ($x = 0; $x < $w; $x++) {
|
||||
for ($y = 0; $y < $h; $y++) {
|
||||
$rgba = imagecolorat($src, $x, $y);
|
||||
$alpha = ($rgba >> 24) & 0x7F;
|
||||
$r = 255 - (($rgba >> 16) & 0xFF);
|
||||
$g = 255 - (($rgba >> 8) & 0xFF);
|
||||
$b = 255 - ($rgba & 0xFF);
|
||||
imagesetpixel($dst, $x, $y, imagecolorallocatealpha($dst, $r, $g, $b, $alpha));
|
||||
}
|
||||
}
|
||||
ob_start();
|
||||
imagepng($dst);
|
||||
$logoWhiteBase64 = 'data:image/png;base64,' . base64_encode(ob_get_clean());
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
}
|
||||
}
|
||||
|
||||
// QR code as inline SVG (no ImageMagick needed)
|
||||
$postUrl = route('post.show', ['id' => $post->id]);
|
||||
$qrSvg = \SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
|
||||
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->generate($postUrl);
|
||||
$qrBase64 = 'data:image/svg+xml;base64,' . base64_encode($qrSvg);
|
||||
|
||||
// Sanitize and truncate content for PDF, preserving HTML formatting.
|
||||
// Truncation is based on plain-text length; the HTML is kept intact up to that point.
|
||||
$rawContent = $translation?->content ?? '';
|
||||
$plainContent = strip_tags($rawContent);
|
||||
$contentMax = 800;
|
||||
$isTruncated = mb_strlen($plainContent) > $contentMax;
|
||||
$pdfContent = \App\Helpers\StringHelper::sanitizeHtml($rawContent);
|
||||
|
||||
// Pass the full excerpt — CSS overflow:hidden on .hero-excerpt clips it within the hero.
|
||||
$rawExcerpt = $translation?->excerpt ?? '';
|
||||
$pdfExcerpt = $rawExcerpt;
|
||||
|
||||
$meetingCityName = null;
|
||||
if ($isEvent && $post->meeting?->location?->city) {
|
||||
$city = $post->meeting->location->city;
|
||||
$meetingCityName = $city->translations->firstWhere('locale', $locale)?->name
|
||||
?? $city->translations->first()?->name;
|
||||
}
|
||||
|
||||
return compact(
|
||||
'post', 'translation', 'categoryName', 'update',
|
||||
'categoryType', 'isEvent', 'isArticle', 'isNews', 'isImagePost',
|
||||
'heroBase64', 'avatarBase64', 'logoBase64', 'logoWhiteBase64', 'qrBase64',
|
||||
'pdfContent', 'isTruncated', 'pdfExcerpt', 'author', 'brandColor',
|
||||
'meetingCityName'
|
||||
);
|
||||
}
|
||||
|
||||
public function notFound()
|
||||
{
|
||||
return view('posts.not_found');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
// 2. Presence Controller for AJAX calls
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PresenceController extends Controller
|
||||
{
|
||||
protected $presenceService;
|
||||
|
||||
public function __construct(PresenceService $presenceService)
|
||||
{
|
||||
$this->presenceService = $presenceService;
|
||||
}
|
||||
|
||||
public function heartbeat(Request $request)
|
||||
{
|
||||
// Use the session active guard as the authoritative source
|
||||
$guard = $request->session()->get('active_guard', $request->get('guard', 'web'));
|
||||
|
||||
if (auth($guard)->check()) {
|
||||
$this->presenceService->updatePresence(auth($guard)->user(), $guard);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'online',
|
||||
'timestamp' => now(),
|
||||
'user_id' => auth($guard)->id()
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
public function setOffline(Request $request)
|
||||
{
|
||||
// Use the session active guard as the authoritative source
|
||||
$guard = $request->session()->get('active_guard', $request->get('guard', 'web'));
|
||||
|
||||
if (auth($guard)->check()) {
|
||||
$this->presenceService->setUserOffline(auth($guard)->user(), $guard);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'offline',
|
||||
'timestamp' => now(),
|
||||
'user_id' => auth($guard)->id()
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
public function getOnlineUsers(Request $request)
|
||||
{
|
||||
$guard = $request->get('guard', 'web');
|
||||
$users = $this->presenceService->getOnlineUsers($guard);
|
||||
|
||||
return response()->json([
|
||||
'users' => $users,
|
||||
'count' => $users->count(),
|
||||
'guard' => $guard,
|
||||
'updated_at' => now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Traits\ActiveStatesTrait;
|
||||
use App\Traits\LocationTrait;
|
||||
use App\Traits\ProfilePermissionTrait;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
use ActiveStatesTrait;
|
||||
use LocationTrait;
|
||||
use ProfilePermissionTrait;
|
||||
|
||||
|
||||
|
||||
public function show($type, $id)
|
||||
{
|
||||
if (strtolower($type) === strtolower(__('user'))) {
|
||||
return $this->showUser($id);
|
||||
}
|
||||
if (strtolower($type) === strtolower(__('organization'))) {
|
||||
return $this->showOrganization($id);
|
||||
}
|
||||
if (strtolower($type) === strtolower(__('bank'))) {
|
||||
return $this->showBank($id);
|
||||
}
|
||||
return view('profile.not_found');
|
||||
}
|
||||
|
||||
|
||||
public function showActive()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
return $this->show(__(class_basename($profile)), $profile->id);
|
||||
}
|
||||
|
||||
|
||||
public function showUser($id)
|
||||
{
|
||||
$user = User::select([
|
||||
'id',
|
||||
'name',
|
||||
'full_name',
|
||||
'profile_photo_path',
|
||||
'about',
|
||||
'about_short',
|
||||
'motivation',
|
||||
'date_of_birth',
|
||||
'website',
|
||||
'phone_public',
|
||||
'phone',
|
||||
'cyclos_skills',
|
||||
'lang_preference',
|
||||
'email_verified_at',
|
||||
'created_at',
|
||||
'last_login_at',
|
||||
'love_reactant_id',
|
||||
'inactive_at',
|
||||
'deleted_at'
|
||||
])
|
||||
->with([
|
||||
'organizations:id,name,profile_photo_path',
|
||||
'accounts:id,name,accountable_type,accountable_id',
|
||||
'languages:id,name,lang_code,flag',
|
||||
'tags:tag_id',
|
||||
'socials:id,name,icon,urL_structure',
|
||||
'locations.district.translations:district_id,name',
|
||||
'locations.city.translations:city_id,name',
|
||||
'locations.division.translations:division_id,name',
|
||||
'locations.country.translations:country_id,name',
|
||||
'loveReactant.reactions.reacter.reacterable',
|
||||
'loveReactant.reactionCounters',
|
||||
])
|
||||
->find($id);
|
||||
|
||||
if (!$user) {
|
||||
return view('profile.not_found');
|
||||
}
|
||||
|
||||
$states = $this->getActiveStates($user);
|
||||
$canManageProfiles = $this->getCanManageProfiles();
|
||||
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
|
||||
$isViewingOwnProfile = getActiveProfile() &&
|
||||
get_class(getActiveProfile()) === User::class &&
|
||||
getActiveProfile()->id === $user->id;
|
||||
|
||||
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
|
||||
if ($canManageProfiles) {
|
||||
$states['hidden'] = false;
|
||||
$states['inactiveLabel'] = !$user->isActive();
|
||||
$states['inactiveSince'] = $user->inactive_at
|
||||
? \Illuminate\Support\Carbon::parse($user->inactive_at)->diffForHumans()
|
||||
: '';
|
||||
$states['emailUnverifiedLabel'] = !$user->isEmailVerified();
|
||||
$states['incompleteLabel'] = $user->hasIncompleteProfile($user);
|
||||
}
|
||||
|
||||
// When viewing own profile, never hide but show all labels
|
||||
if ($isViewingOwnProfile) {
|
||||
$states['hidden'] = false;
|
||||
$states['inactiveLabel'] = !$user->isActive();
|
||||
$states['inactiveSince'] = $user->inactive_at
|
||||
? \Illuminate\Support\Carbon::parse($user->inactive_at)->diffForHumans()
|
||||
: '';
|
||||
$states['emailUnverifiedLabel'] = !$user->isEmailVerified();
|
||||
$states['incompleteLabel'] = $user->hasIncompleteProfile($user);
|
||||
|
||||
// Show incomplete warning modal when viewing own profile
|
||||
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
|
||||
$user->hasIncompleteProfile($user);
|
||||
}
|
||||
|
||||
// Check if profile should be hidden
|
||||
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
|
||||
// Only allow Admin and Bank profiles to view incomplete-only profiles
|
||||
// Inactive profiles should be hidden from everyone except admins/banks
|
||||
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
|
||||
!($states['inactive'] ?? false) &&
|
||||
!($states['emailUnverifiedLabel'] ?? false);
|
||||
|
||||
// Show profile only if it's incomplete-only AND viewer is admin/bank
|
||||
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
|
||||
return view('profile.not_found');
|
||||
}
|
||||
}
|
||||
|
||||
$user->reactionCounter = $user->loveReactant->reactionCounters->first() ? (int)$user->loveReactant->reactionCounters->first()->weight : null;
|
||||
|
||||
$profile = $user;
|
||||
$header = __('Personal profile');
|
||||
|
||||
return $profile != null
|
||||
? view('profile.show', array_merge(compact('profile', 'header'), $states))
|
||||
: abort(403);
|
||||
|
||||
}
|
||||
|
||||
public function showOrganization($id)
|
||||
{
|
||||
$organization = Organization::select([
|
||||
'id',
|
||||
'name',
|
||||
'profile_photo_path',
|
||||
'about',
|
||||
'motivation',
|
||||
'website',
|
||||
'phone_public',
|
||||
'phone',
|
||||
'cyclos_skills',
|
||||
'lang_preference',
|
||||
'email_verified_at',
|
||||
'created_at',
|
||||
'last_login_at',
|
||||
'love_reactant_id',
|
||||
'inactive_at',
|
||||
'deleted_at'
|
||||
])
|
||||
->with([
|
||||
'managers:id,name,profile_photo_path',
|
||||
'accounts:id,name,accountable_type,accountable_id',
|
||||
'languages:id,name,lang_code,flag',
|
||||
'tags:tag_id',
|
||||
'socials:id,name,icon,urL_structure',
|
||||
'locations.district.translations:district_id,name',
|
||||
'locations.city.translations:city_id,name',
|
||||
'locations.division.translations:division_id,name',
|
||||
'locations.country.translations:country_id,name',
|
||||
'loveReactant.reactions.reacter.reacterable',
|
||||
// 'loveReactant.reactions.type',
|
||||
'loveReactant.reactionCounters',
|
||||
// 'loveReactant.reactionTotal',
|
||||
])
|
||||
->find($id);
|
||||
|
||||
if (!$organization) {
|
||||
return view('profile.not_found');
|
||||
}
|
||||
|
||||
$states = $this->getActiveStates($organization);
|
||||
$canManageProfiles = $this->getCanManageProfiles();
|
||||
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
|
||||
$isViewingOwnProfile = getActiveProfile() &&
|
||||
get_class(getActiveProfile()) === Organization::class &&
|
||||
getActiveProfile()->id === $organization->id;
|
||||
|
||||
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
|
||||
if ($canManageProfiles) {
|
||||
$states['hidden'] = false;
|
||||
$states['inactiveLabel'] = !$organization->isActive();
|
||||
$states['inactiveSince'] = $organization->inactive_at
|
||||
? \Illuminate\Support\Carbon::parse($organization->inactive_at)->diffForHumans()
|
||||
: '';
|
||||
$states['emailUnverifiedLabel'] = !$organization->isEmailVerified();
|
||||
$states['incompleteLabel'] = $organization->hasIncompleteProfile($organization);
|
||||
}
|
||||
|
||||
// When viewing own profile, never hide but show all labels
|
||||
if ($isViewingOwnProfile) {
|
||||
$states['hidden'] = false;
|
||||
$states['inactiveLabel'] = !$organization->isActive();
|
||||
$states['inactiveSince'] = $organization->inactive_at
|
||||
? \Illuminate\Support\Carbon::parse($organization->inactive_at)->diffForHumans()
|
||||
: '';
|
||||
$states['emailUnverifiedLabel'] = !$organization->isEmailVerified();
|
||||
$states['incompleteLabel'] = $organization->hasIncompleteProfile($organization);
|
||||
|
||||
// Show incomplete warning modal when viewing own profile
|
||||
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
|
||||
$organization->hasIncompleteProfile($organization);
|
||||
}
|
||||
|
||||
// Check if profile should be hidden
|
||||
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
|
||||
// Only allow Admin and Bank profiles to view incomplete-only profiles
|
||||
// Inactive profiles should be hidden from everyone except admins/banks
|
||||
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
|
||||
!($states['inactive'] ?? false) &&
|
||||
!($states['emailUnverifiedLabel'] ?? false);
|
||||
|
||||
// Show profile only if it's incomplete-only AND viewer is admin/bank
|
||||
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
|
||||
return view('profile.not_found');
|
||||
}
|
||||
}
|
||||
|
||||
$organization->reactionCounter = $organization->loveReactant->reactionCounters->first() ? (int)$organization->loveReactant->reactionCounters->first()->weight : null;
|
||||
|
||||
$profile = $organization;
|
||||
$header = __('Organization profile');
|
||||
|
||||
return $profile != null
|
||||
? view('profile.show', array_merge(compact('profile', 'header'), $states))
|
||||
: abort(403);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function showBank($id)
|
||||
{
|
||||
$bank = Bank::select([
|
||||
'id',
|
||||
'name',
|
||||
'profile_photo_path',
|
||||
'about',
|
||||
'motivation',
|
||||
'website',
|
||||
'phone_public',
|
||||
'phone',
|
||||
'cyclos_skills',
|
||||
'lang_preference',
|
||||
'email_verified_at',
|
||||
'created_at',
|
||||
'last_login_at',
|
||||
'love_reactant_id',
|
||||
'inactive_at',
|
||||
'deleted_at',
|
||||
'level',
|
||||
])
|
||||
->with([
|
||||
'managers:id,name,profile_photo_path',
|
||||
'accounts:id,name,accountable_type,accountable_id',
|
||||
'languages:id,name,lang_code,flag',
|
||||
'tags:tag_id',
|
||||
'socials:id,name,icon,urL_structure',
|
||||
'locations.district.translations:district_id,name',
|
||||
'locations.city.translations:city_id,name',
|
||||
'locations.division.translations:division_id,name',
|
||||
'locations.country.translations:country_id,name',
|
||||
'loveReactant.reactions.reacter.reacterable',
|
||||
// 'loveReactant.reactions.type',
|
||||
'loveReactant.reactionCounters',
|
||||
// 'loveReactant.reactionTotal',
|
||||
])
|
||||
->find($id);
|
||||
|
||||
if (!$bank) {
|
||||
return view('profile.not_found');
|
||||
}
|
||||
|
||||
$states = $this->getActiveStates($bank);
|
||||
$canManageProfiles = $this->getCanManageProfiles();
|
||||
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
|
||||
$isViewingOwnProfile = getActiveProfile() &&
|
||||
get_class(getActiveProfile()) === Bank::class &&
|
||||
getActiveProfile()->id === $bank->id;
|
||||
|
||||
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
|
||||
if ($canManageProfiles) {
|
||||
$states['hidden'] = false;
|
||||
$states['inactiveLabel'] = !$bank->isActive();
|
||||
$states['inactiveSince'] = $bank->inactive_at
|
||||
? \Illuminate\Support\Carbon::parse($bank->inactive_at)->diffForHumans()
|
||||
: '';
|
||||
$states['emailUnverifiedLabel'] = !$bank->isEmailVerified();
|
||||
$states['incompleteLabel'] = $bank->hasIncompleteProfile($bank);
|
||||
}
|
||||
|
||||
// When viewing own profile, never hide but show all labels
|
||||
if ($isViewingOwnProfile) {
|
||||
$states['hidden'] = false;
|
||||
$states['inactiveLabel'] = !$bank->isActive();
|
||||
$states['inactiveSince'] = $bank->inactive_at
|
||||
? \Illuminate\Support\Carbon::parse($bank->inactive_at)->diffForHumans()
|
||||
: '';
|
||||
$states['emailUnverifiedLabel'] = !$bank->isEmailVerified();
|
||||
$states['incompleteLabel'] = $bank->hasIncompleteProfile($bank);
|
||||
|
||||
// Show incomplete warning modal when viewing own profile
|
||||
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
|
||||
$bank->hasIncompleteProfile($bank);
|
||||
}
|
||||
|
||||
// Hide bank by level for the current viewer type
|
||||
if (!$canManageProfiles && !$isViewingOwnProfile && $bank->level !== null && !canViewerSeeBankLevel($bank->level, 'profile_visible')) {
|
||||
$states['hidden'] = true;
|
||||
}
|
||||
|
||||
// Check if profile should be hidden
|
||||
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
|
||||
// Only allow Admin and Bank profiles to view incomplete-only profiles
|
||||
// Inactive profiles should be hidden from everyone except admins/banks
|
||||
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
|
||||
!($states['inactive'] ?? false) &&
|
||||
!($states['emailUnverifiedLabel'] ?? false);
|
||||
|
||||
// Show profile only if it's incomplete-only AND viewer is admin/bank
|
||||
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
|
||||
return view('profile.not_found');
|
||||
}
|
||||
}
|
||||
|
||||
$bank->reactionCounter = $bank->loveReactant->reactionCounters->first() ? (int)$bank->loveReactant->reactionCounters->first()->weight : null;
|
||||
|
||||
$profile = $bank;
|
||||
$header = __('Bank profile');
|
||||
|
||||
return $profile != null
|
||||
? view('profile.show', array_merge(compact('profile', 'header'), $states))
|
||||
: abort(403);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$type = strtolower(class_basename(getActiveProfile()));
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if ($profile instanceof Organization || $profile instanceof Bank) {
|
||||
Gate::authorize('org edit-profile');
|
||||
}
|
||||
|
||||
// Check if profile is incomplete - show warning if config allows
|
||||
$showIncompleteWarning = false;
|
||||
|
||||
if (timebank_config('profile_incomplete.show_warning_modal') &&
|
||||
method_exists($profile, 'hasIncompleteProfile') &&
|
||||
$profile->hasIncompleteProfile($profile)) {
|
||||
$showIncompleteWarning = true;
|
||||
}
|
||||
|
||||
return view('profile-' . $type . '.edit', compact('showIncompleteWarning'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the authenticated user to their profile settings page with locale-specific routing.
|
||||
*
|
||||
* This method is used by the route profile.settings.no_locale.
|
||||
* The email footer template uses this route because when sending the locale of the
|
||||
* recipient is unknown.
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse Redirects to the localized profile settings page.
|
||||
*/
|
||||
public function settingsNoLocale()
|
||||
{
|
||||
$user = auth()->user();
|
||||
$locale = $user->lang_preference ?? config('app.fallback_locale');
|
||||
|
||||
// Load the route translations for the specific locale
|
||||
$routeTranslations = include resource_path("lang/{$locale}/routes.php");
|
||||
$translatedRoute = $routeTranslations['profile.settings'] ?? 'profile/settings';
|
||||
|
||||
$localizedUrl = url("/{$locale}/{$translatedRoute}");
|
||||
|
||||
return redirect($localizedUrl . '#message_settings');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the user settings screen.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function settings(Request $request)
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
$type = strtolower(class_basename($profile));
|
||||
|
||||
if ($profile instanceof Organization || $profile instanceof Bank) {
|
||||
Gate::authorize('org settings');
|
||||
} elseif ($profile instanceof \App\Models\Admin) {
|
||||
Gate::authorize('admin settings');
|
||||
}
|
||||
|
||||
if ($type === 'user') {
|
||||
|
||||
return view('profile.settings', [
|
||||
'request' => $request,
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
|
||||
} else {
|
||||
|
||||
return view('profile-' . $type . '.settings');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function manage()
|
||||
{
|
||||
return view('profiles.manage');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determines the inactive status flags for a given profile.
|
||||
*
|
||||
* @param mixed $profile The profile object to check for inactivity.
|
||||
* @return array An associative array containing:
|
||||
* - 'inactive' (bool): Whether the profile is inactive.
|
||||
* - 'hidden' (bool): Whether the profile should be hidden.
|
||||
* - 'inactiveLabel' (bool): Whether the profile should be labeled as inactive.
|
||||
* - 'inactiveSince' (string): Human-readable duration since the profile became inactive.
|
||||
*/
|
||||
private function getActiveStates($profile)
|
||||
{
|
||||
$inactive = false;
|
||||
$hidden = false;
|
||||
$inactiveLabel = false;
|
||||
$inactiveSince = '';
|
||||
$emailUnverifiedLabel = false;
|
||||
$isIncomplete = false;
|
||||
$incompleteLabel = false;
|
||||
$noExchangesYetLabel = false;
|
||||
$removedSince = '';
|
||||
|
||||
if (method_exists($profile, 'isActive') && !$profile->isActive()) {
|
||||
$inactive = true;
|
||||
if (timebank_config('profile_inactive.profile_hidden')) {
|
||||
$hidden = true;
|
||||
}
|
||||
if (timebank_config('profile_inactive.profile_labeled')) {
|
||||
$inactiveLabel = true;
|
||||
$inactiveSince = $profile->inactive_at
|
||||
? \Illuminate\Support\Carbon::parse($profile->inactive_at)->diffForHumans()
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
if (method_exists($profile, 'isEmailVerified') && !$profile->isEmailVerified()) {
|
||||
$emailUnverifiedLabel = false;
|
||||
if (timebank_config('profile_email_unverified.profile_hidden')) {
|
||||
$hidden = true;
|
||||
}
|
||||
if (timebank_config('profile_email_unverified.profile_labeled')) {
|
||||
$emailUnverifiedLabel = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (method_exists($profile, 'hasIncompleteProfile') && $profile->hasIncompleteProfile($profile)) {
|
||||
$isIncomplete = true;
|
||||
if (timebank_config('profile_incomplete.profile_hidden')) {
|
||||
$hidden = true;
|
||||
}
|
||||
if (timebank_config('profile_incomplete.profile_labeled')) {
|
||||
$incompleteLabel = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
timebank_config('profile_incomplete.no_exchanges_yet_label') &&
|
||||
method_exists($profile, 'hasNeverReceivedTransaction') &&
|
||||
$profile->hasNeverReceivedTransaction($profile)
|
||||
) {
|
||||
$noExchangesYetLabel = true;
|
||||
}
|
||||
|
||||
if (
|
||||
(method_exists($profile, 'isRemoved') && $profile->isRemoved()) ||
|
||||
(!empty($profile->deleted_at) && \Carbon\Carbon::parse($profile->deleted_at)->isPast())
|
||||
) {
|
||||
$hidden = true;
|
||||
$removedSince = !empty($profile->deleted_at)
|
||||
? \Carbon\Carbon::parse($profile->deleted_at)->diffForHumans()
|
||||
: '';
|
||||
}
|
||||
|
||||
return compact('inactive', 'hidden', 'inactiveLabel', 'inactiveSince', 'emailUnverifiedLabel', 'isIncomplete', 'incompleteLabel', 'noExchangesYetLabel', 'removedSince');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProfileOrgController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('profile-organization.show');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProfileUserController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('profile-user.show');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Traits\AccountInfoTrait;
|
||||
use App\Models\Transaction;
|
||||
use Carbon\Carbon;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
use AccountInfoTrait;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function reports()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
// This prevents unauthorized access to financial reports via session manipulation
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
$profileAccounts = $this->getAccountsInfo();
|
||||
|
||||
return view('reports.show', compact('profileAccounts'));
|
||||
}
|
||||
|
||||
public function downloadPdf(Request $request)
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
// This prevents unauthorized PDF report generation via session manipulation
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
$fromDate = $request->get('fromDate');
|
||||
$toDate = $request->get('toDate');
|
||||
$decimalFormat = (bool) $request->get('decimal', 0);
|
||||
|
||||
if (!$fromDate || !$toDate) {
|
||||
abort(400, 'Missing date parameters');
|
||||
}
|
||||
|
||||
// Use the same logic from SingleReport component
|
||||
$singleReport = new \App\Http\Livewire\SingleReport();
|
||||
$singleReport->fromDate = $fromDate;
|
||||
$singleReport->toDate = $toDate;
|
||||
$singleReport->decimalFormat = $decimalFormat;
|
||||
|
||||
$accountsData = $singleReport->getAccountsWithPeriodBalances();
|
||||
$transactionTypesData = $singleReport->calculateTransactionTypeTotals();
|
||||
$statisticsData = $singleReport->calculatePeriodStatistics();
|
||||
$returnRatioData = $singleReport->calculateReturnRatioTimeline();
|
||||
$returnRatioTimelineData = $returnRatioData['timeline'] ?? [];
|
||||
$returnRatioTrendData = $returnRatioData['trend'] ?? [];
|
||||
|
||||
// Check if we have chart images from the frontend
|
||||
$chartImage = null; // Legacy single chart support
|
||||
$returnRatioChartImage = null;
|
||||
$accountBalancesChartImage = null;
|
||||
$balanceDistributionChartImage = null;
|
||||
$balanceDistributionOrgChartImage = null;
|
||||
$networkChartImage = null;
|
||||
// Read before the session keys are forgotten further down.
|
||||
$networkSettings = session('report_network_settings');
|
||||
$networkLegend = session('report_network_legend');
|
||||
|
||||
$useChartImage = $request->get('with_chart'); // Legacy single chart
|
||||
$useChartsImages = $request->get('with_charts'); // New dual charts
|
||||
|
||||
Log::info('PDF generation request', [
|
||||
'useChartImage' => $useChartImage,
|
||||
'useChartsImages' => $useChartsImages,
|
||||
'hasSessionChartImageId' => session()->has('chart_image_id'),
|
||||
'hasSessionChartImageIds' => session()->has('chart_image_ids'),
|
||||
'sessionChartImageId' => session('chart_image_id'),
|
||||
'sessionChartImageIds' => session('chart_image_ids')
|
||||
]);
|
||||
|
||||
// Handle new dual charts system
|
||||
if ($useChartsImages && session('chart_image_ids')) {
|
||||
$chartImageIds = session('chart_image_ids');
|
||||
|
||||
// Each chart: read the temp PNG, inline it, delete the file. DomPDF
|
||||
// runs with isRemoteEnabled = false, so every image must be inlined.
|
||||
$charts = [
|
||||
'return_ratio_chart_id' => 'returnRatioChartImage',
|
||||
'account_balances_chart_id' => 'accountBalancesChartImage',
|
||||
'balance_distribution_chart_id' => 'balanceDistributionChartImage',
|
||||
'balance_distribution_org_chart_id' => 'balanceDistributionOrgChartImage',
|
||||
'network_chart_id' => 'networkChartImage',
|
||||
];
|
||||
|
||||
foreach ($charts as $sessionKey => $variable) {
|
||||
if (!isset($chartImageIds[$sessionKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$chartImageId = $chartImageIds[$sessionKey];
|
||||
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
|
||||
|
||||
if (!file_exists($tempImagePath)) {
|
||||
Log::warning('Chart image temp file not found', [
|
||||
'chart' => $sessionKey,
|
||||
'path' => $tempImagePath,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageData = file_get_contents($tempImagePath);
|
||||
$$variable = 'data:image/png;base64,' . base64_encode($imageData);
|
||||
|
||||
Log::info('Chart image found in temp file', [
|
||||
'chart' => $sessionKey,
|
||||
'chartImageId' => $chartImageId,
|
||||
'fileSize' => strlen($imageData),
|
||||
]);
|
||||
|
||||
unlink($tempImagePath);
|
||||
}
|
||||
|
||||
session()->forget('chart_image_ids');
|
||||
session()->forget('report_network_settings');
|
||||
session()->forget('report_network_legend');
|
||||
}
|
||||
// Handle legacy single chart system (backward compatibility)
|
||||
else if ($useChartImage && session('chart_image_id')) {
|
||||
$chartImageId = session('chart_image_id');
|
||||
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
|
||||
|
||||
if (file_exists($tempImagePath)) {
|
||||
// Read the image file and convert to base64
|
||||
$imageData = file_get_contents($tempImagePath);
|
||||
$chartImage = 'data:image/png;base64,' . base64_encode($imageData);
|
||||
|
||||
Log::info('Legacy chart image found in temp file', [
|
||||
'chartImageId' => $chartImageId,
|
||||
'fileSize' => strlen($imageData),
|
||||
'base64Length' => strlen($chartImage)
|
||||
]);
|
||||
|
||||
// Clean up temporary file
|
||||
unlink($tempImagePath);
|
||||
session()->forget('chart_image_id');
|
||||
} else {
|
||||
Log::warning('Legacy chart image temp file not found', ['path' => $tempImagePath]);
|
||||
}
|
||||
} else {
|
||||
Log::info('No chart images available, using fallback chart rendering');
|
||||
}
|
||||
|
||||
// Prepare chart data for PDF rendering (fallback if no image)
|
||||
$chartData = $this->prepareChartDataForPdf($returnRatioTimelineData, $returnRatioTrendData);
|
||||
|
||||
// Clean UTF-8 encoding for all data
|
||||
$accountsData = $this->cleanUtf8Data($accountsData);
|
||||
$transactionTypesData = $this->cleanUtf8Data($transactionTypesData);
|
||||
$statisticsData = $this->cleanUtf8Data($statisticsData);
|
||||
|
||||
// Generate title with profile information
|
||||
$fromDateCarbon = Carbon::parse($fromDate);
|
||||
$toDateCarbon = Carbon::parse($toDate);
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
$profileTitle = '';
|
||||
if ($activeProfile) {
|
||||
$fullName = $activeProfile->full_name ?? $activeProfile->name ?? '';
|
||||
$profileName = $activeProfile->name ?? '';
|
||||
$profileTitle = ' ' . $fullName . ' (' . $profileName . ')';
|
||||
}
|
||||
|
||||
$title = [
|
||||
'header' => __('Financial overview') . $profileTitle,
|
||||
'sub' => $fromDateCarbon->format('d-m-Y') . ' - ' . $toDateCarbon->format('d-m-Y')
|
||||
];
|
||||
|
||||
$pdf = Pdf::loadView('reports.pdf', [
|
||||
'title' => $title,
|
||||
'accountsData' => $accountsData,
|
||||
'transactionTypesData' => $transactionTypesData,
|
||||
'statisticsData' => $statisticsData,
|
||||
'returnRatioTimelineData' => $returnRatioTimelineData,
|
||||
'returnRatioTrendData' => $returnRatioTrendData,
|
||||
'chartData' => $chartData,
|
||||
'chartImage' => $chartImage, // Legacy single chart support
|
||||
'returnRatioChartImage' => $returnRatioChartImage,
|
||||
'accountBalancesChartImage' => $accountBalancesChartImage,
|
||||
'balanceDistributionChartImage' => $balanceDistributionChartImage,
|
||||
'balanceDistributionOrgChartImage' => $balanceDistributionOrgChartImage,
|
||||
'networkChartImage' => $networkChartImage,
|
||||
'networkSettings' => $networkSettings,
|
||||
'networkLegend' => $networkLegend,
|
||||
// Only the partner count is used, for the caption — the charts
|
||||
// themselves arrive as rendered images.
|
||||
'balanceDistribution' => $singleReport->balanceDistribution($activeProfile),
|
||||
'isOrganization' => $activeProfile instanceof \App\Models\Organization,
|
||||
'decimalFormat' => $decimalFormat,
|
||||
])->setPaper('A4', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'defaultPaperSize' => 'A4',
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
|
||||
return $pdf->download('financial-report-' . now()->format('Y-m-d') . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean UTF-8 encoding for strings
|
||||
*/
|
||||
private function cleanUtf8String($string)
|
||||
{
|
||||
if (!is_string($string)) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
// Remove or replace problematic characters
|
||||
$string = mb_convert_encoding($string, 'UTF-8', 'UTF-8');
|
||||
$string = preg_replace('/[\x00-\x1F\x7F-\x9F]/u', '', $string);
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean UTF-8 encoding for arrays/collections
|
||||
*/
|
||||
private function cleanUtf8Data($data)
|
||||
{
|
||||
if (is_string($data)) {
|
||||
return $this->cleanUtf8String($data);
|
||||
}
|
||||
|
||||
if (is_array($data) || $data instanceof \Illuminate\Support\Collection) {
|
||||
$cleaned = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$cleanedKey = $this->cleanUtf8String($key);
|
||||
$cleanedValue = $this->cleanUtf8Data($value);
|
||||
$cleaned[$cleanedKey] = $cleanedValue;
|
||||
}
|
||||
return $data instanceof \Illuminate\Support\Collection ? collect($cleaned) : $cleaned;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare chart data for PDF rendering
|
||||
*/
|
||||
private function prepareChartDataForPdf($returnRatioTimelineData, $returnRatioTrendData)
|
||||
{
|
||||
if (!$returnRatioTimelineData || count($returnRatioTimelineData) <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$values = array_column($returnRatioTimelineData, 'return_ratio');
|
||||
$labels = array_column($returnRatioTimelineData, 'label');
|
||||
|
||||
// Always start from 0% as requested
|
||||
$minValue = 0;
|
||||
$maxValue = count($values) > 0 ? max(array_merge($values, [100])) : 100;
|
||||
|
||||
// Add padding to top only
|
||||
$padding = ($maxValue - $minValue) * 0.1;
|
||||
$maxValue += $padding;
|
||||
$range = $maxValue - $minValue;
|
||||
|
||||
// Ensure we have a valid range
|
||||
if ($range <= 0) {
|
||||
$range = 100;
|
||||
$maxValue = 100;
|
||||
}
|
||||
|
||||
$pointCount = count($returnRatioTimelineData);
|
||||
$points = [];
|
||||
$trendPoints = [];
|
||||
|
||||
if ($pointCount > 0 && $range > 0) {
|
||||
// Calculate data points
|
||||
foreach ($returnRatioTimelineData as $index => $point) {
|
||||
$x = $pointCount > 1 ? ($index / ($pointCount - 1)) * 100 : 50;
|
||||
$y = 100 - (($point['return_ratio'] - $minValue) / $range * 100);
|
||||
$points[] = number_format($x, 2) . ',' . number_format(max(0, min(100, $y)), 2);
|
||||
}
|
||||
|
||||
// Calculate trend points if available
|
||||
if ($returnRatioTrendData && count($returnRatioTrendData) > 0) {
|
||||
$trendCount = count($returnRatioTrendData);
|
||||
foreach ($returnRatioTrendData as $index => $point) {
|
||||
$x = $trendCount > 1 ? ($index / ($trendCount - 1)) * 100 : 50;
|
||||
$y = 100 - (($point['trend_value'] - $minValue) / $range * 100);
|
||||
$trendPoints[] = number_format($x, 2) . ',' . number_format(max(0, min(100, $y)), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'values' => $values,
|
||||
'labels' => $labels,
|
||||
'minValue' => $minValue,
|
||||
'maxValue' => $maxValue,
|
||||
'range' => $range,
|
||||
'points' => $points,
|
||||
'trendPoints' => $trendPoints,
|
||||
'pointsString' => implode(' ', $points),
|
||||
'trendPointsString' => implode(' ', $trendPoints),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ResetNonUserPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the form to request a password reset link.
|
||||
*/
|
||||
public function showLinkRequestForm(Request $request, $profileType)
|
||||
{
|
||||
$profileId = $request->query('profileId');
|
||||
if ($profileId) {
|
||||
$profile = $this->resolveProfileById($profileType, $profileId);
|
||||
if ($profile && !empty($profile->password_change_disabled)) {
|
||||
return redirect()->back()->withErrors(['email' => __('Password changes are disabled for this account.')]);
|
||||
}
|
||||
// Block if the user's role for this profile does not allow password changes
|
||||
$user = Auth::guard('web')->user();
|
||||
if ($user && !$this->userRoleAllowsPasswordChange($user, $profileType, $profileId)) {
|
||||
return redirect()->back()->withErrors(['email' => __('Password changes are disabled for this account.')]);
|
||||
}
|
||||
}
|
||||
|
||||
return view('auth.forgot-non-user-password', [
|
||||
'profileType' => $profileType,
|
||||
'profileId' => $profileId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sending the password reset link.
|
||||
*/
|
||||
public function sendResetLinkEmail(Request $request, $profileType)
|
||||
{
|
||||
$validator = Validator::make($request->all(), ['email' => 'required|email']);
|
||||
if ($validator->fails()) {
|
||||
return back()->withErrors($validator);
|
||||
}
|
||||
|
||||
// Resolve profile and check password_change_disabled + role permission
|
||||
$profileId = $request->input('profileId');
|
||||
$profile = $profileId
|
||||
? $this->resolveProfileById($profileType, $profileId)
|
||||
: $this->resolveProfileByEmail($profileType, $request->input('email'));
|
||||
if ($profile && !empty($profile->password_change_disabled)) {
|
||||
return back()->withErrors(['email' => __('Password changes are disabled for this account.')]);
|
||||
}
|
||||
$user = Auth::guard('web')->user();
|
||||
$resolvedId = $profileId ?? ($profile ? $profile->id : null);
|
||||
if ($user && $resolvedId && !$this->userRoleAllowsPasswordChange($user, $profileType, $resolvedId)) {
|
||||
return back()->withErrors(['email' => __('Password changes are disabled for this account.')]);
|
||||
}
|
||||
|
||||
$broker = $this->getPasswordBroker($profileType);
|
||||
|
||||
$status = Password::broker($broker)->sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status === Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withErrors(['email' => __($status)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the password reset view for the given token.
|
||||
*
|
||||
* If no token is present, display the link request form.
|
||||
*/
|
||||
public function showResetForm(Request $request, $profileType, $token = null)
|
||||
{
|
||||
if (is_null($token)) {
|
||||
return $this->showLinkRequestForm($profileType);
|
||||
}
|
||||
|
||||
$email = $request->query('email');
|
||||
|
||||
return view('auth.reset-non-user-password', [
|
||||
'token' => $token,
|
||||
'email' => $email,
|
||||
'profileType' => $profileType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the given profile's password.
|
||||
*/
|
||||
public function reset(Request $request, $profileType)
|
||||
{
|
||||
// Dynamically get the password validation rules from the config
|
||||
$passwordRules = timebank_config('rules.profile_' . strtolower($profileType) . '.password', ['required', 'string', 'min:8', 'confirmed']);
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => $passwordRules,
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
// Store errors in a persistent (non-flash) session key so Livewire
|
||||
// background requests don't consume the flash before the browser arrives.
|
||||
session(['password_reset_errors' => $validator->errors()->toArray()]);
|
||||
return redirect(url()->previous())->withInput($request->only('email'));
|
||||
}
|
||||
|
||||
$broker = $this->getPasswordBroker($profileType);
|
||||
|
||||
$passwordChangeDisabled = false;
|
||||
$status = Password::broker($broker)->reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function ($profile, $password) use (&$passwordChangeDisabled) {
|
||||
if (!empty($profile->password_change_disabled)) {
|
||||
$passwordChangeDisabled = true;
|
||||
return;
|
||||
}
|
||||
$profile->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
])->save();
|
||||
}
|
||||
);
|
||||
|
||||
if ($passwordChangeDisabled) {
|
||||
session(['password_reset_errors' => ['password' => [__('Password changes are disabled for this account.')]]]);
|
||||
return redirect(url()->previous())->withInput($request->only('email'));
|
||||
}
|
||||
|
||||
if ($status === Password::PASSWORD_RESET) {
|
||||
session()->forget('password_reset_errors');
|
||||
$loginPath = parse_url(route('login'), PHP_URL_PATH);
|
||||
return redirect($loginPath)->with('status', __('passwords.reset'));
|
||||
}
|
||||
|
||||
session(['password_reset_errors' => ['email' => [__($status)]]]);
|
||||
return redirect(url()->previous())->withInput($request->only('email'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the password broker for the given profile type.
|
||||
*/
|
||||
private function getPasswordBroker($profileType)
|
||||
{
|
||||
// Ensure this maps to the keys in config/auth.php 'passwords'
|
||||
$brokers = [
|
||||
'admin' => 'admins',
|
||||
'bank' => 'banks',
|
||||
// 'organization' => 'organizations', // etc.
|
||||
];
|
||||
// Fallback to 'users' broker if profileType doesn't match,
|
||||
// or handle as an error if only specific profile types are allowed here.
|
||||
return $brokers[strtolower($profileType)] ?? 'users';
|
||||
}
|
||||
|
||||
private function resolveProfileById($profileType, $id)
|
||||
{
|
||||
$modelMap = [
|
||||
'admin' => Admin::class,
|
||||
'bank' => Bank::class,
|
||||
'organization' => Organization::class,
|
||||
];
|
||||
$model = $modelMap[strtolower($profileType)] ?? null;
|
||||
return $model ? $model::find((int) $id) : null;
|
||||
}
|
||||
|
||||
private function resolveProfileByEmail($profileType, $email)
|
||||
{
|
||||
$modelMap = [
|
||||
'admin' => Admin::class,
|
||||
'bank' => Bank::class,
|
||||
'organization' => Organization::class,
|
||||
];
|
||||
$model = $modelMap[strtolower($profileType)] ?? null;
|
||||
return $model ? $model::where('email', $email)->first() : null;
|
||||
}
|
||||
|
||||
private function userRoleAllowsPasswordChange($user, string $profileType, $profileId): bool
|
||||
{
|
||||
$typeMap = [
|
||||
'admin' => ['prefix' => 'Admin', 'model' => \App\Models\AdminRole::class, 'permission' => 'admin change-password'],
|
||||
'bank' => ['prefix' => 'Bank', 'model' => \App\Models\OrganizationRole::class, 'permission' => 'org change-password'],
|
||||
'organization' => ['prefix' => 'Organization', 'model' => \App\Models\OrganizationRole::class, 'permission' => 'org change-password'],
|
||||
];
|
||||
|
||||
$config = $typeMap[strtolower($profileType)] ?? null;
|
||||
if (!$config) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$prefix = "{$config['prefix']}\\{$profileId}\\";
|
||||
$scopedRoles = $user->roles->filter(fn($r) => str_starts_with($r->name, $prefix));
|
||||
|
||||
foreach ($scopedRoles as $spatieRole) {
|
||||
$roleName = str_replace($prefix, '', $spatieRole->name);
|
||||
$role = $config['model']::where('name', $roleName)->first();
|
||||
if ($role && in_array($config['permission'], $role->permissions ?? [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
public function manage()
|
||||
{
|
||||
return view('roles.manage');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function show(Request $request)
|
||||
{
|
||||
$authId = Auth::guard('web')->id();
|
||||
$key = 'main_search_bar_results_' . $authId;
|
||||
|
||||
// When arriving via a category badge link, clear any stale cache so the
|
||||
// MainBrowseTagCategories component can run a fresh search on mount.
|
||||
if ($request->query('category')) {
|
||||
cache()->forget($key);
|
||||
}
|
||||
|
||||
$data = cache()->get($key, ['results' => [], 'searchTerm' => null, 'total' => 0,]);
|
||||
$resultRefs = $data['results'];
|
||||
$searchTerm = $data['searchTerm'];
|
||||
$total = $data['total'];
|
||||
|
||||
|
||||
// empty cache when retrieved
|
||||
// cache()->forget($key);
|
||||
|
||||
return view('search.show', [
|
||||
'resultRefs' => $resultRefs,
|
||||
'searchTerm' => $searchTerm,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class SearchIndexController extends Controller
|
||||
{
|
||||
protected $client;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = ClientBuilder::create()->build();
|
||||
}
|
||||
|
||||
|
||||
public function indexStopwords($indexName)
|
||||
{
|
||||
$stopwords = timebank_config('elasticsearch.stopwords');
|
||||
|
||||
$settings = [
|
||||
'analysis' => [
|
||||
'analyzer' => [
|
||||
'my_custom_analyzer' => [
|
||||
'type' => 'standard',
|
||||
'stopwords' => $stopwords
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
// Create the index
|
||||
$params = [
|
||||
'index' => $indexName,
|
||||
'body' => [
|
||||
'settings' => $settings,
|
||||
// other index settings...
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->client->indices()->create($params);
|
||||
|
||||
return $response;
|
||||
|
||||
}
|
||||
|
||||
public function deleteIndex($indexName)
|
||||
{
|
||||
$client = ClientBuilder::create()->build();
|
||||
$params = ['index' => $indexName,
|
||||
'alias' => $indexName];
|
||||
$response = $client->indices()->delete($params);
|
||||
|
||||
return $response;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StaticController extends Controller
|
||||
{
|
||||
/**
|
||||
* Download the full privacy policy for the current locale
|
||||
*/
|
||||
public function downloadPrivacyPolicy(Request $request)
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$platform = config('app.timebank_config');
|
||||
|
||||
// Get the base path for GDPR references
|
||||
$basePath = base_path("references/gdpr/{$platform}");
|
||||
|
||||
// Find the most recent date folder
|
||||
$dateFolders = File::isDirectory($basePath) ? File::directories($basePath) : [];
|
||||
|
||||
if (empty($dateFolders)) {
|
||||
abort(404, 'Privacy policy not found');
|
||||
}
|
||||
|
||||
// Sort folders by name (dates in YYYY-MM-DD format) descending
|
||||
rsort($dateFolders);
|
||||
$latestFolder = $dateFolders[0];
|
||||
|
||||
// Construct the file path
|
||||
$fileName = "privacy-policy-FULL-{$locale}.md";
|
||||
$filePath = "{$latestFolder}/{$fileName}";
|
||||
|
||||
if (!File::exists($filePath)) {
|
||||
abort(404, 'Privacy policy not found for this language');
|
||||
}
|
||||
|
||||
// Return the file as a download
|
||||
return response()->download($filePath, $fileName, [
|
||||
'Content-Type' => 'text/markdown',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\ProfileAuthorizationHelper;
|
||||
use App\Services\Stats\StatsFilter;
|
||||
use App\Services\StatsService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class StatsController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->authorizeStatsAccess();
|
||||
|
||||
return view('stats.show');
|
||||
}
|
||||
|
||||
public function downloadPdf(Request $request)
|
||||
{
|
||||
$this->authorizeStatsAccess();
|
||||
|
||||
$filter = StatsFilter::fromArray([
|
||||
'periodFrom' => $request->get('fromDate'),
|
||||
'periodTill' => $request->get('toDate'),
|
||||
'countryIds' => $this->intArray($request->get('countryIds')),
|
||||
'divisionIds' => $this->intArray($request->get('divisionIds')),
|
||||
'cityIds' => $this->intArray($request->get('cityIds')),
|
||||
'districtIds' => $this->intArray($request->get('districtIds')),
|
||||
'registeredFrom' => $request->get('registeredFrom'),
|
||||
'registeredTill' => $request->get('registeredTill'),
|
||||
'counterparties' => $this->counterparties($request->get('counterparties')),
|
||||
'allTime' => $request->boolean('allTime'),
|
||||
]);
|
||||
|
||||
if ($filter->isEmpty()) {
|
||||
abort(400, 'No filters selected');
|
||||
}
|
||||
|
||||
$stats = (new StatsService($filter))->all();
|
||||
|
||||
// Read before pullChartImages(), which clears the session keys.
|
||||
$networkSettings = session('stats_network_settings');
|
||||
$networkLegend = session('stats_network_legend');
|
||||
$networkSubtext = session('stats_network_subtext');
|
||||
|
||||
$charts = $this->pullChartImages($request->boolean('with_charts'));
|
||||
|
||||
$subtitle = $filter->periodFrom && $filter->periodTill
|
||||
? $filter->periodFrom->format('d-m-Y') . ' - ' . $filter->periodTill->format('d-m-Y')
|
||||
: __('All time');
|
||||
|
||||
// Every applied filter, named, so a printed report is self-describing:
|
||||
// away from the screen there is nothing else to say what it covers.
|
||||
$appliedFilters = $this->describeFilters($filter);
|
||||
|
||||
$pdf = Pdf::loadView('stats.pdf', [
|
||||
'title' => [
|
||||
'header' => __('Platform statistics'),
|
||||
'sub' => $subtitle,
|
||||
],
|
||||
'appliedFilters' => $appliedFilters,
|
||||
'networkSettings' => $networkSettings,
|
||||
'networkLegend' => $networkLegend,
|
||||
'networkSubtext' => $networkSubtext,
|
||||
// Counterparty-scoped selections describe trading partners, not
|
||||
// the platform's profiles at large, so the labels change with it.
|
||||
'partnerScoped' => !empty($filter->counterparties),
|
||||
'stats' => $stats,
|
||||
'charts' => $charts,
|
||||
'decimalFormat' => $request->boolean('decimal'),
|
||||
'anonymize' => $request->boolean('anonymize'),
|
||||
'generatedAt' => Carbon::now()->format('d-m-Y H:i'),
|
||||
])->setPaper('A4', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'defaultPaperSize' => 'A4',
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
|
||||
return $pdf->download('platform-stats-' . now()->format('Y-m-d') . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin and Bank profiles holding the stats permission.
|
||||
*/
|
||||
protected function authorizeStatsAccess(): void
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, __('No active profile'));
|
||||
}
|
||||
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if (!($profile instanceof \App\Models\Admin) && !($profile instanceof \App\Models\Bank)) {
|
||||
abort(403, __('Admin or bank access required'));
|
||||
}
|
||||
|
||||
if (!Gate::allows('access stats') && !Gate::allows('org stats')) {
|
||||
Log::warning('StatsController: permission denied', [
|
||||
'profile_type' => get_class($profile),
|
||||
'profile_id' => $profile->id,
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
abort(403, __('You do not have permission to view statistics.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read chart PNGs written by the Livewire component, inline them, and clean up.
|
||||
*/
|
||||
private function pullChartImages(bool $withCharts): array
|
||||
{
|
||||
$charts = [];
|
||||
|
||||
if (!$withCharts || !session()->has('stats_chart_image_ids')) {
|
||||
return $charts;
|
||||
}
|
||||
|
||||
foreach (session('stats_chart_image_ids', []) as $name => $id) {
|
||||
$path = storage_path('app/temp/' . $id . '.png');
|
||||
|
||||
if (!file_exists($path)) {
|
||||
Log::warning('Stats chart image temp file missing', ['path' => $path]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$charts[$name] = 'data:image/png;base64,' . base64_encode(file_get_contents($path));
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
session()->forget('stats_chart_image_ids');
|
||||
session()->forget('stats_network_settings');
|
||||
session()->forget('stats_network_legend');
|
||||
session()->forget('stats_network_subtext');
|
||||
|
||||
return $charts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable list of the filters behind this report.
|
||||
*
|
||||
* The on-screen page shows the same summary beside every section; a PDF is
|
||||
* read away from it, so the header has to carry the whole picture —
|
||||
* including which profiles were filtered on, by name.
|
||||
*
|
||||
* @return array<array{label: string, value: string}>
|
||||
*/
|
||||
private function describeFilters(StatsFilter $filter): array
|
||||
{
|
||||
$summary = [];
|
||||
|
||||
$summary[] = [
|
||||
'label' => __('Period'),
|
||||
'value' => $filter->periodFrom && $filter->periodTill
|
||||
? $filter->periodFrom->format('d-m-Y') . ' – ' . $filter->periodTill->format('d-m-Y')
|
||||
: __('All time'),
|
||||
];
|
||||
|
||||
if ($names = $this->locationNames($filter)) {
|
||||
$summary[] = ['label' => __('Location'), 'value' => $names];
|
||||
}
|
||||
|
||||
if ($filter->registeredFrom || $filter->registeredTill) {
|
||||
$from = $filter->registeredFrom?->format('d-m-Y');
|
||||
$till = $filter->registeredTill?->format('d-m-Y');
|
||||
|
||||
$summary[] = [
|
||||
'label' => __('Registered'),
|
||||
'value' => match (true) {
|
||||
$from && $till => $from . ' – ' . $till,
|
||||
(bool) $from => __('from :date', ['date' => $from]),
|
||||
default => __('until :date', ['date' => $till]),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if ($filter->notLoggedInSince) {
|
||||
$summary[] = [
|
||||
'label' => __('Not logged in since'),
|
||||
'value' => $filter->notLoggedInSince->format('d-m-Y'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($filter->noTransactionSince) {
|
||||
$summary[] = [
|
||||
'label' => __('No transaction since'),
|
||||
'value' => $filter->noTransactionSince->format('d-m-Y'),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($filter->counterparties)) {
|
||||
$summary[] = [
|
||||
'label' => __('Transactions with'),
|
||||
// Named even when anonymising. These profiles are the subject
|
||||
// of the filter, not data the report is disclosing: without
|
||||
// them the header cannot say what it covers. Anonymisation
|
||||
// still applies to the most-active table, where the names
|
||||
// would be a disclosure rather than a heading.
|
||||
'value' => $this->counterpartyNames($filter),
|
||||
];
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/** Deepest selected location level wins, translated into the active locale. */
|
||||
private function locationNames(StatsFilter $filter): string
|
||||
{
|
||||
$levels = [
|
||||
[\App\Models\Locations\District::class, $filter->districtIds],
|
||||
[\App\Models\Locations\City::class, $filter->cityIds],
|
||||
[\App\Models\Locations\Division::class, $filter->divisionIds],
|
||||
[\App\Models\Locations\Country::class, $filter->countryIds],
|
||||
];
|
||||
|
||||
foreach ($levels as [$model, $ids]) {
|
||||
if (empty($ids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $model::whereIn('id', $ids)->with('translations')->get()
|
||||
->map(fn($row) => $row->translations->firstWhere('locale', app()->getLocale())?->name
|
||||
?? $row->translations->first()?->name)
|
||||
->filter()
|
||||
->implode(', ');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function counterpartyNames(StatsFilter $filter): string
|
||||
{
|
||||
return collect($filter->counterparties)
|
||||
->groupBy('type')
|
||||
->flatMap(fn($group, $type) => $type::whereIn('id', collect($group)->pluck('id'))->pluck('name'))
|
||||
->implode(', ');
|
||||
}
|
||||
|
||||
private function intArray($value): array
|
||||
{
|
||||
if (empty($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$value = is_array($value) ? $value : explode(',', $value);
|
||||
|
||||
return array_values(array_filter(array_map('intval', $value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Type:id,Type:id" pairs, validated against the known profile classes.
|
||||
*/
|
||||
private function counterparties($value): array
|
||||
{
|
||||
if (empty($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pairs = is_array($value) ? $value : explode(',', $value);
|
||||
$out = [];
|
||||
|
||||
foreach ($pairs as $pair) {
|
||||
[$type, $id] = array_pad(explode(':', (string) $pair, 2), 2, null);
|
||||
|
||||
if (!$id || !in_array($type, StatsFilter::PROFILE_TYPES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = ['type' => $type, 'id' => (int) $id];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
public function manage()
|
||||
{
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, __('Admin profile required'));
|
||||
}
|
||||
|
||||
return view('tags.manage');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Stevebauman\Location\Facades\Location as IpLocation;
|
||||
|
||||
|
||||
class TestController extends Controller
|
||||
{
|
||||
/**
|
||||
* Test Stevbauman/Location package
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
|
||||
|
||||
public function viewIpLocation(Request $request)
|
||||
{
|
||||
if (App::environment(['local', 'staging'])) {
|
||||
$ip = '103.75.231.255'; // Static IP address Brussels for testing
|
||||
// $ip = '31.20.250.12'; // Statis IP address The Hague for testing
|
||||
// $ip = '102.129.156.0'; // Statis IP address Berlin for testing
|
||||
} else {
|
||||
$ip = $request->ip(); // Dynamic IP address
|
||||
}
|
||||
$IpLocationInfo = IpLocation::get($ip);
|
||||
// TODO: Test for correct ip address in production mode.
|
||||
// dd($ipLocatonInfo);
|
||||
// TODO: Enable alternative IpLocation info providers.
|
||||
return view('test.ip-location', compact('IpLocationInfo'));
|
||||
}
|
||||
|
||||
|
||||
public function viewDebug1(Request $request)
|
||||
{
|
||||
return view('test.debug-1');
|
||||
}
|
||||
|
||||
|
||||
public function viewDebug2(Request $request)
|
||||
{
|
||||
return view('test.debug-2');
|
||||
}
|
||||
|
||||
|
||||
public function clearCache()
|
||||
{
|
||||
Artisan::call('cache:clear');
|
||||
return "Cache is cleared";
|
||||
}
|
||||
|
||||
public function optimizeClear()
|
||||
{
|
||||
Artisan::call('optimize:clear');
|
||||
return "Events, views, caches, routes, configs cleared";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\TransferReceived;
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use App\Traits\AccountInfoTrait;
|
||||
use Cog\Laravel\Love\ReactionType\Models\ReactionType as LoveReactionType;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Http\Request;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
use Namu\WireChat\Enums\MessageType;
|
||||
use Namu\WireChat\Models\Attachment;
|
||||
use Namu\WireChat\Models\Message;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Stevebauman\Location\Facades\Location as IpLocation;
|
||||
|
||||
class TransactionController extends Controller
|
||||
{
|
||||
use AccountInfoTrait;
|
||||
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
|
||||
public function pay()
|
||||
{
|
||||
$toName = null;
|
||||
return view('pay.show', compact(['toName']));
|
||||
}
|
||||
|
||||
|
||||
public function payToName($name = null)
|
||||
{
|
||||
return view('pay.show', compact(['name']));
|
||||
}
|
||||
|
||||
|
||||
public function payAmountToName($hours = null, $minutes = null, $name = null)
|
||||
{
|
||||
return view('pay.show', compact(['hours', 'minutes', 'name']));
|
||||
}
|
||||
|
||||
|
||||
public function payAmountToNameWithDescr($hours = null, $minutes = null, $name = null, $description = null)
|
||||
{
|
||||
return view('pay.show', compact(['hours', 'minutes', 'name', 'description']));
|
||||
}
|
||||
|
||||
|
||||
// Legacy Cyclos payment link, as used by Lekkernasuh
|
||||
public function doCyclosPayment(Request $request, $minutes = null, $toAccoundId = null, $name = null, $description = null, $type = null)
|
||||
{
|
||||
$cyclos_id = $request->query('to');
|
||||
// Amount is in integer minutes
|
||||
$minutes = (int) $request->query('amount');
|
||||
$toAccountId = null;
|
||||
$name = null;
|
||||
if ($cyclos_id) {
|
||||
$accounts = Account::accountsCyclosMember($cyclos_id);
|
||||
if (count($accounts) > 1) {
|
||||
// More than 1 account found with this cyclos_id — search by name instead
|
||||
$name = $this->getNameByCyclosId($cyclos_id);
|
||||
} else {
|
||||
$toAccountId = $accounts->keys()->first();
|
||||
}
|
||||
}
|
||||
$description = $request->query('description');
|
||||
$type = $request->query('type');
|
||||
|
||||
return view('pay.show', compact(['minutes', 'toAccountId', 'name', 'description', 'type']));
|
||||
}
|
||||
|
||||
|
||||
public static function getNameByCyclosId($cyclos_id)
|
||||
{
|
||||
$user = User::where('cyclos_id', $cyclos_id)->first();
|
||||
if ($user) {
|
||||
return $user->name;
|
||||
}
|
||||
|
||||
$organization = Organization::where('cyclos_id', $cyclos_id)->first();
|
||||
if ($organization) {
|
||||
return $organization->name;
|
||||
}
|
||||
|
||||
$bank = Bank::where('cyclos_id', $cyclos_id)->first();
|
||||
if ($bank) {
|
||||
return $bank->name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public function transactions()
|
||||
{
|
||||
$profileAccounts = $this->getAccountsInfo();
|
||||
|
||||
return view('transactions.show', compact('profileAccounts'));
|
||||
}
|
||||
|
||||
|
||||
public function statement($transactionId)
|
||||
{
|
||||
$results = Transaction::with('accountTo.accountable', 'accountFrom.accountable')
|
||||
->where('id', $transactionId)
|
||||
->whereHas('accountTo', function ($query) {
|
||||
$query->where('accountable_type', Session('activeProfileType'))
|
||||
->where('accountable_id', Session('activeProfileId'));
|
||||
})
|
||||
->orWhereHas('accountFrom.accountable', function ($query) {
|
||||
$query->where('accountable_type', Session('activeProfileType'))
|
||||
->where('accountable_id', Session('activeProfileId'));
|
||||
})
|
||||
->find($transactionId);
|
||||
|
||||
|
||||
$qrModalVisible = request()->query('qrModalVisible', false);
|
||||
|
||||
|
||||
//TODO: add permission check
|
||||
//TODO: if 403, but has permission, redirect with message to switch profile
|
||||
//TODO: replace 403 with custom redirect page incl explanation
|
||||
|
||||
// Check if the transaction exists
|
||||
if ($transactionId) {
|
||||
// Pass the transactionId and transaction details to the view
|
||||
return view('transactions.statement', compact('transactionId', 'qrModalVisible'));
|
||||
} else {
|
||||
// Abort with a 403 status code if the transaction does not exist
|
||||
// TODO to static page explaining that the transactionId is not accessible for current profile.
|
||||
return abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Create a new transaction with all necessary checks.
|
||||
* This is a non-Livewire version of the transfer logic.
|
||||
*
|
||||
* @param int $fromAccountId
|
||||
* @param int $toAccountId
|
||||
* @param int $amount
|
||||
* @param string $description
|
||||
* @param int $transactionTypeId
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function createTransfer($fromAccountId, $toAccountId, $amount, $description, $transactionTypeId)
|
||||
{
|
||||
// 1. SECURITY CHECKS
|
||||
$fromAccountableType = Account::find($fromAccountId)->accountable_type;
|
||||
$fromAccountableId = Account::find($fromAccountId)->accountable_id;
|
||||
$accountsInfo = collect($this->getAccountsInfo($fromAccountableType, $fromAccountableId));
|
||||
if (!$accountsInfo->contains('id', $fromAccountId)) {
|
||||
return $this->logAndReport('Unauthorized payment attempt: illegal access of From account', $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if From and To Account is different
|
||||
if ($toAccountId == $fromAccountId) {
|
||||
return $this->logAndReport('Impossible payment attempt: To and From account are the same', $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if the To Account exists and is not removed
|
||||
$toAccount = Account::notRemoved()->find($toAccountId);
|
||||
if (!$toAccount) {
|
||||
return $this->logAndReport('Impossible payment attempt: To account not found', $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if the To Accountable exists and is not removed
|
||||
$toAccountableExists = Account::find($toAccountId)->accountable()->notRemoved()->first();
|
||||
if (!$toAccountableExists) {
|
||||
$warningMessage = 'Impossible payment attempt: To account holder not found';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if the From Account exists and is not removed
|
||||
$fromAccount = Account::notRemoved()->find($fromAccountId);
|
||||
if (!$fromAccount) {
|
||||
return $this->logAndReport('Impossible payment attempt: From account not found', $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if the From Accountable exists and is not removed
|
||||
$fromAccountableExists = Account::find($fromAccountId)->accountable()->notRemoved()->first();
|
||||
if (!$fromAccountableExists) {
|
||||
$warningMessage = 'Impossible payment attempt: From account holder not found';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
|
||||
// Check if the transactionTypeSelected is allowed, with an exception for internal migration (type 6)
|
||||
$toHolderType = $toAccount->accountable_type;
|
||||
$toHolderId = $toAccount->accountable_id;
|
||||
$isInternalMigration = (
|
||||
$fromAccountableType === $toHolderType &&
|
||||
$fromAccountableId == $toHolderId
|
||||
);
|
||||
// Only perform the check if it's not the specific internal migration case
|
||||
if (!$isInternalMigration) {
|
||||
$canReceive = timebank_config('accounts.' . strtolower(class_basename($toHolderType)) . '.receiving_types');
|
||||
$canPay = timebank_config('permissions.' . strtolower(class_basename($fromAccountableType)) . '.payment_types');
|
||||
$allowedTypeIds = array_intersect($canPay, $canReceive);
|
||||
|
||||
if (!in_array($transactionTypeId, $allowedTypeIds)) {
|
||||
$transactionTypeName = TransactionType::find($transactionTypeId)->name ?? 'id: ' . $transactionTypeId;
|
||||
return $this->logAndReport('Impossible payment attempt: transaction type not allowed', $fromAccountId, $toAccountId, $amount, $description, $transactionTypeName);
|
||||
}
|
||||
} else {
|
||||
$transactionTypeId = 6; // Enforce transactionTypeId 6 (migration) for internal transactions
|
||||
}
|
||||
|
||||
|
||||
// 2. BALANCE LIMIT CHECKS
|
||||
$fromAccount = Account::find($fromAccountId);
|
||||
$limitMinFrom = $fromAccount->limit_min;
|
||||
$effectiveLimitMaxTo = $toAccount->limit_max - $toAccount->limit_min;
|
||||
|
||||
$balanceFrom = $this->getBalance($fromAccountId);
|
||||
$balanceTo = $this->getBalance($toAccountId);
|
||||
|
||||
$transferBudgetFrom = $balanceFrom - $limitMinFrom;
|
||||
$balanceToPublic = timebank_config('account_info.' . strtolower(class_basename($toHolderType)) . '.balance_public');
|
||||
$transferBudgetTo = $effectiveLimitMaxTo - $balanceTo;
|
||||
|
||||
$limitError = $this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic, $toAccount->accountable->name);
|
||||
if ($limitError) {
|
||||
return redirect()->back()->with('error', $limitError);
|
||||
}
|
||||
|
||||
|
||||
// 3. CLEAN UP DESCRIPTION
|
||||
// Get the validation rule string from the config
|
||||
$rule = timebank_config('payment.description_rule');
|
||||
preg_match('/max:(\d+)/', $rule, $matches);
|
||||
$maxLength = $matches[1] ?? 500;
|
||||
// Use Str::limit to truncate the description if it's too long, adding ' ...'
|
||||
$description = \Illuminate\Support\Str::limit($description, $maxLength, ' ...');
|
||||
|
||||
|
||||
// 4. DATABASE TRANSACTION
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$transfer = new Transaction();
|
||||
$transfer->from_account_id = $fromAccountId;
|
||||
$transfer->to_account_id = $toAccountId;
|
||||
$transfer->amount = $amount;
|
||||
$transfer->description = $description;
|
||||
$transfer->transaction_type_id = $transactionTypeId;
|
||||
$transfer->creator_user_id = null;
|
||||
$save = $transfer->save();
|
||||
|
||||
if ($save) {
|
||||
DB::commit();
|
||||
|
||||
// 4. POST-TRANSACTION ACTIONS
|
||||
// Send chat message and an email if conditions are met
|
||||
$recipient = $transfer->accountTo->accountable;
|
||||
$sender = $transfer->accountFrom->accountable;
|
||||
$messageLocale = $recipient->lang_preference ?? $sender->lang_preference;
|
||||
$payChatMessageKey = payChatMessageKey($sender);
|
||||
if (!Lang::has($payChatMessageKey, $messageLocale)) { // Check if the translation key exists for the selected locale
|
||||
$messageLocale = config('app.fallback_locale'); // Fallback to the app's default locale
|
||||
}
|
||||
|
||||
// Translate the account name using the RECIPIENT'S language
|
||||
$toAccountName = __(ucfirst(strtolower($transfer->accountTo->name)), [], $messageLocale);
|
||||
|
||||
$chatMessage = __($payChatMessageKey, [
|
||||
'amount' => tbFormat($amount),
|
||||
'account_name' => $toAccountName,
|
||||
], $messageLocale);
|
||||
|
||||
$chatTransactionStatement = LaravelLocalization::getURLFromRouteNameTranslated($messageLocale, 'routes.statement', array('transactionId' => $transfer->id));
|
||||
|
||||
// Get or create conversation once, reuse for both messages
|
||||
$conversation = $sender->createConversationWith($recipient);
|
||||
|
||||
// Send text notification message
|
||||
$message = $sender->sendMessageTo($recipient, $chatMessage);
|
||||
broadcast(new \Namu\WireChat\Events\MessageCreated($message));
|
||||
\Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $message);
|
||||
|
||||
// Send QR code as image attachment (SVG format, renders in <img> tags)
|
||||
$qrStatementUrl = route('transaction.show', ['transactionId' => $transfer->id]);
|
||||
$qrSvg = QrCode::size(150)->errorCorrection('L')->color(17, 24, 39)->generate($qrStatementUrl);
|
||||
$storageDisk = config('wirechat.attachments.storage_disk', 'public');
|
||||
$storageFolder = config('wirechat.attachments.storage_folder', 'attachments');
|
||||
$qrFileName = 'qr-transaction-' . $transfer->id . '-' . time() . '.svg';
|
||||
$qrFilePath = $storageFolder . '/' . $qrFileName;
|
||||
Storage::disk($storageDisk)->put($qrFilePath, $qrSvg);
|
||||
|
||||
$qrMessage = Message::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'sendable_type' => $sender->getMorphClass(),
|
||||
'sendable_id' => $sender->id,
|
||||
'body' => $chatTransactionStatement,
|
||||
'type' => MessageType::ATTACHMENT,
|
||||
]);
|
||||
$qrMessage->attachment()->create([
|
||||
'file_path' => $qrFilePath,
|
||||
'file_name' => $qrFileName,
|
||||
'original_name' => $qrFileName,
|
||||
'mime_type' => 'image/svg+xml',
|
||||
'url' => Storage::disk($storageDisk)->url($qrFilePath),
|
||||
]);
|
||||
$conversation->updated_at = now();
|
||||
$conversation->save();
|
||||
|
||||
// Broadcast to open conversation (live message append) and notify chat list
|
||||
broadcast(new \Namu\WireChat\Events\MessageCreated($qrMessage));
|
||||
\Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $qrMessage);
|
||||
|
||||
|
||||
// Check if the recipient has message settings for receiving this email and has also an email address
|
||||
if (isset($recipient->email)) {
|
||||
$messageSettings = method_exists($recipient, 'message_settings') ? $recipient->message_settings()->first() : null;
|
||||
|
||||
// Always send email unless payment_received is explicitly false
|
||||
if (!$messageSettings || !($messageSettings->payment_received === false || $messageSettings->payment_received === 0)) {
|
||||
$now = now();
|
||||
Mail::to($recipient->email)->later($now->addSeconds(0), new TransferReceived($transfer, $messageLocale));
|
||||
}
|
||||
}
|
||||
|
||||
// Add Love Reaction with transaction type name on both models
|
||||
$reactionType = TransactionType::find($transactionTypeId)->name;
|
||||
try {
|
||||
$reacterFacadeSender = $sender->viaLoveReacter()->reactTo($recipient, $reactionType);
|
||||
$reacterFacadeRecipient = $recipient->viaLoveReacter()->reactTo($sender, $reactionType);
|
||||
} catch (\Exception $e) {
|
||||
// Ignore if reaction type does not exist
|
||||
}
|
||||
|
||||
|
||||
$successMessage = tbFormat($amount) . __('was paid to the ') . $toAccountName . __(' of ') . $recipient->name . '.' . '<br /><br />' . '<a href="' . route('transaction.show', ['transactionId' => $transfer->id]) . '">' . __('Show Transaction # ') . $transfer->id . '</a>';
|
||||
return redirect()->back()->with('success', $successMessage);
|
||||
|
||||
} else {
|
||||
throw new \Exception('Transaction could not be saved');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->logAndReport('Transaction failed', $fromAccountId, $toAccountId, $amount, $description, '', $e->getMessage());
|
||||
return redirect()->back()->with('error', __('Sorry we have an error: this transaction could not be saved!'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check balance limits for a transfer operation.
|
||||
* Returns an error string if a limit is exceeded, otherwise null.
|
||||
*/
|
||||
private function checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic, $toHolderName)
|
||||
{
|
||||
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom <= $transferBudgetTo) {
|
||||
return __('messages.pay_limit_error_budget_from', [
|
||||
'limitMinFrom' => tbFormat($limitMinFrom),
|
||||
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
|
||||
]);
|
||||
}
|
||||
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom > $transferBudgetTo) {
|
||||
return $balanceToPublic
|
||||
? __('messages.pay_limit_error_budget_from_and_to', [
|
||||
'limitMinFrom' => tbFormat($limitMinFrom),
|
||||
'transferBudgetTo' => tbFormat($transferBudgetTo),
|
||||
])
|
||||
: __('messages.pay_limit_error_budget_from_and_to_without_budget_to', [
|
||||
'limitMinFrom' => tbFormat($limitMinFrom),
|
||||
]);
|
||||
}
|
||||
if ($amount > $transferBudgetFrom) {
|
||||
return __('messages.pay_limit_error_budget_from', [
|
||||
'limitMinFrom' => tbFormat($limitMinFrom),
|
||||
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
|
||||
]);
|
||||
}
|
||||
if ($amount > $transferBudgetTo) {
|
||||
return $balanceToPublic
|
||||
? __('messages.pay_limit_error_budget_to', [
|
||||
'transferBudgetTo' => tbFormat($transferBudgetTo),
|
||||
])
|
||||
: __('messages.pay_limit_error_budget_to_without_budget_to', [
|
||||
'transferBudgetTo' => tbFormat($transferBudgetTo),
|
||||
'toHolderName' => $toHolderName,
|
||||
]);
|
||||
}
|
||||
return null; // No error
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper to log a warning message and report it via email to the system administrator.
|
||||
* Returns a redirect response with an error message.
|
||||
*/
|
||||
private function logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, $transactionType = '', $error = '')
|
||||
{
|
||||
$ip = request()->ip();
|
||||
$ipLocationInfo = IpLocation::get($ip);
|
||||
if (!$ipLocationInfo || App::environment(['local', 'development', 'staging'])) {
|
||||
$ipLocationInfo = (object) ['cityName' => 'local City', 'regionName' => 'local Region', 'countryName' => 'local Country'];
|
||||
}
|
||||
$eventTime = now()->toDateTimeString();
|
||||
|
||||
$fromAccountHolder = Account::find($fromAccountId)->accountable()->value('name') ?? 'N/A';
|
||||
$toAccountHolder = Account::find($toAccountId)->accountable()->value('name') ?? 'N/A';
|
||||
|
||||
Log::warning($warningMessage, [
|
||||
'fromAccountId' => $fromAccountId, 'fromAccountHolder' => $fromAccountHolder,
|
||||
'toAccountId' => $toAccountId, 'toAccountHolder' => $toAccountHolder,
|
||||
'amount' => $amount,
|
||||
'description' => $description,
|
||||
'userId' => Auth::id(), 'userName' => Auth::user()->name,
|
||||
'activeProfileId' => session('activeProfileId'), 'activeProfileType' => session('activeProfileType'), 'activeProfileName' => session('activeProfileName'),
|
||||
'transactionType' => ucfirst($transactionType),
|
||||
'IP address' => $ip, 'IP location' => "{$ipLocationInfo->cityName}, {$ipLocationInfo->regionName}, {$ipLocationInfo->countryName}",
|
||||
'Event Time' => $eventTime, 'Message' => $error,
|
||||
]);
|
||||
|
||||
$rawMessage = "{$warningMessage}.\n\n" .
|
||||
"From Account ID: {$fromAccountId}\nFrom Account Holder: {$fromAccountHolder}\n" .
|
||||
"To Account ID: {$toAccountId}\nTo Account Holder: {$toAccountHolder}\n" .
|
||||
"Amount: {$amount}\n" .
|
||||
"Description: {$description}\n" .
|
||||
"User ID: " . Auth::id() . "\nUser Name: " . Auth::user()->name . "\n" .
|
||||
"Active Profile ID: " . session('activeProfileId') . "\nActive Profile Type: " . session('activeProfileType') . "\nActive Profile Name: " . session('activeProfileName') . "\n" .
|
||||
"Transaction Type: " . ucfirst($transactionType) . "\n" .
|
||||
"IP address: {$ip}\nIP location: {$ipLocationInfo->cityName}, {$ipLocationInfo->regionName}, {$ipLocationInfo->countryName}\n" .
|
||||
"Event Time: {$eventTime}\n\n{$error}";
|
||||
|
||||
Mail::raw($rawMessage, function ($message) use ($warningMessage) {
|
||||
$message->to(timebank_config('mail.system_admin.email'))->subject($warningMessage);
|
||||
});
|
||||
|
||||
return redirect()->back()->with('error', __($warningMessage) . '. ' . __('This event has been logged and reported to our system administrator') . '.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\SwitchGuardTrait;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
class UserLoginController extends Controller
|
||||
{
|
||||
use SwitchGuardTrait;
|
||||
|
||||
/**
|
||||
* Direct link to user login - can be used in emails
|
||||
* Handles the authentication flow:
|
||||
* 1. If user not authenticated -> redirect to user login with intended URL
|
||||
* 2. If user authenticated but wrong user -> 403 forbidden
|
||||
* 3. If correct user -> redirect to intended URL or main page
|
||||
* 4. Supports custom intended URL via query parameter
|
||||
*/
|
||||
public function directLogin(Request $request, $userId)
|
||||
{
|
||||
\Log::info('UserLoginController: directLogin called', [
|
||||
'user_id' => $userId,
|
||||
'request_url' => $request->fullUrl(),
|
||||
'all_params' => $request->all(),
|
||||
]);
|
||||
|
||||
// Validate user exists
|
||||
$user = User::find($userId);
|
||||
if (!$user) {
|
||||
abort(404, __('User not found'));
|
||||
}
|
||||
|
||||
// Get optional intended destination after successful login.
|
||||
// Validate it is a local URL to prevent open redirect attacks.
|
||||
$intendedParam = $request->query('intended');
|
||||
$intendedUrl = $this->validateLocalUrl($intendedParam, $request);
|
||||
|
||||
if (!$intendedUrl) {
|
||||
$intendedUrl = LaravelLocalization::localizeURL(
|
||||
route('main'),
|
||||
$user->lang_preference ?? config('app.fallback_locale')
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user is authenticated on web guard
|
||||
\Log::info('UserLoginController: Checking authentication', [
|
||||
'is_authenticated' => Auth::guard('web')->check(),
|
||||
]);
|
||||
|
||||
if (!Auth::guard('web')->check()) {
|
||||
// User not logged in - redirect to user login with return URL
|
||||
$returnUrl = LaravelLocalization::localizeURL(
|
||||
route('user.direct-login', ['userId' => $userId]),
|
||||
$user->lang_preference ?? config('app.fallback_locale')
|
||||
);
|
||||
if ($intendedUrl) {
|
||||
$returnUrl .= '?intended=' . urlencode($intendedUrl);
|
||||
}
|
||||
|
||||
// Get the name parameter from the current request to pass along
|
||||
$nameParam = $request->query('name', $user->name);
|
||||
|
||||
\Log::info('UserLoginController: Redirecting to login', [
|
||||
'return_url' => $returnUrl,
|
||||
'intended_url' => $intendedUrl,
|
||||
'prefill_username' => $nameParam,
|
||||
]);
|
||||
|
||||
// Store in session for Laravel to redirect after login
|
||||
session()->put('url.intended', $returnUrl);
|
||||
|
||||
// Pass username as URL parameter to pre-fill login form
|
||||
// Use LaravelLocalization to ensure the parameter is preserved through localization
|
||||
$loginUrl = LaravelLocalization::localizeURL(
|
||||
route('login'),
|
||||
$user->lang_preference ?? config('app.fallback_locale')
|
||||
);
|
||||
$loginUrl .= '?name=' . urlencode($nameParam);
|
||||
|
||||
\Log::info('UserLoginController: Redirecting to login with name parameter', [
|
||||
'login_url' => $loginUrl,
|
||||
'username' => $nameParam,
|
||||
]);
|
||||
|
||||
return redirect()->to($loginUrl, 302, [], false);
|
||||
}
|
||||
|
||||
// User is authenticated - verify they are the correct user
|
||||
$authenticatedUser = Auth::guard('web')->user();
|
||||
|
||||
if ($authenticatedUser->id !== $user->id) {
|
||||
// Wrong user is logged in — log out all guards and redirect to login
|
||||
// so the recipient can authenticate as the correct profile
|
||||
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
|
||||
Auth::guard($guard)->logout();
|
||||
}
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$returnUrl = route('user.direct-login', ['userId' => $userId]);
|
||||
if ($intendedUrl) {
|
||||
$returnUrl .= '?intended=' . urlencode($intendedUrl);
|
||||
}
|
||||
$nameParam = $request->query('name', $user->name);
|
||||
|
||||
session()->put('url.intended', $returnUrl);
|
||||
|
||||
$loginUrl = LaravelLocalization::localizeURL(
|
||||
route('login'),
|
||||
$user->lang_preference ?? config('app.fallback_locale')
|
||||
);
|
||||
$loginUrl .= '?name=' . urlencode($nameParam);
|
||||
|
||||
return redirect()->to($loginUrl, 302, [], false);
|
||||
}
|
||||
|
||||
// Re-activate profile if inactive
|
||||
if (timebank_config('profile_inactive.re-activate_at_login')) {
|
||||
if (!$user->isActive()) {
|
||||
$user->inactive_at = null;
|
||||
$user->save();
|
||||
info('User re-activated: ' . $user->name);
|
||||
}
|
||||
}
|
||||
|
||||
// Switch active guard back to 'web' (User profile).
|
||||
// This is a no-op if already on web, but ensures that users arriving from an
|
||||
// elevated profile (org/bank/admin) are switched back to their User profile.
|
||||
$wasElevated = session('active_guard', 'web') !== 'web';
|
||||
$this->switchGuard('web', $user);
|
||||
session([
|
||||
'activeProfileType' => get_class($user),
|
||||
'activeProfileId' => $user->id,
|
||||
'activeProfileName' => $user->name,
|
||||
'activeProfilePhoto' => $user->profile_photo_path ?? null,
|
||||
'activeProfileRoleLabel' => getProfileRoleLabel($user),
|
||||
'last_activity' => now(),
|
||||
'profile-switched-notification' => $wasElevated,
|
||||
]);
|
||||
|
||||
\Log::info('UserLoginController: Authenticated user verified, redirecting', [
|
||||
'user_id' => $authenticatedUser->id,
|
||||
'target_user_id' => $user->id,
|
||||
'intended_url' => $intendedUrl,
|
||||
]);
|
||||
|
||||
// Redirect to intended URL
|
||||
return redirect($intendedUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Namu\WireChat\Facades\WireChat;
|
||||
use Namu\WireChat\Models\Attachment;
|
||||
|
||||
class WireChatAttachmentController extends Controller
|
||||
{
|
||||
public function serve(Request $request, Attachment $attachment)
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
abort_unless($profile !== null, 403);
|
||||
|
||||
// Verify the active profile is a participant in the conversation this attachment belongs to
|
||||
$message = $attachment->attachable;
|
||||
|
||||
abort_unless($message !== null, 404);
|
||||
|
||||
$conversation = $message->conversation;
|
||||
|
||||
abort_unless($conversation !== null, 404);
|
||||
abort_unless($profile->belongsToConversation($conversation), 403);
|
||||
|
||||
$disk = Storage::disk(WireChat::storageDisk());
|
||||
|
||||
abort_unless($disk->exists($attachment->file_path), 404);
|
||||
|
||||
$absolutePath = $disk->path($attachment->file_path);
|
||||
$mimeType = $attachment->mime_type;
|
||||
$fileSize = filesize($absolutePath);
|
||||
|
||||
// Use StreamedResponse so we can flush any stray OB content before sending binary data.
|
||||
// BinaryFileResponse writes to php://output which respects active output buffers,
|
||||
// so any pre-existing OB content (e.g. from middleware) would prepend to the binary file.
|
||||
return response()->stream(function () use ($absolutePath) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
readfile($absolutePath);
|
||||
}, 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Length' => $fileSize,
|
||||
'Cache-Control' => 'private, max-age=3600',
|
||||
'Accept-Ranges' => 'none',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user