Signed-off-by: miguel456 <me@nogueira.codes>
This commit is contained in:
2022-10-24 01:01:10 +01:00
parent 614410e7b7
commit 0bc6c20a6d
166 changed files with 4250 additions and 1833 deletions

View File

@@ -33,11 +33,6 @@ class Application extends Model
];
public function oneoffApplicant()
{
return $this->hasOne('App\OneoffApplicant', 'application_id', 'id');
}
public function user()
{
return $this->belongsTo('App\User', 'applicantUserID', 'id');
@@ -70,11 +65,4 @@ class Application extends Model
]);
}
public function isOneoff()
{
return $this->user->id == 1; // ID 1 is always the ghost
}
}

View File

@@ -36,7 +36,7 @@ class Ban extends Model
];
public $dates = [
'suspendedUntil',
'bannedUntil',
];
public function user()

View File

@@ -22,6 +22,7 @@
namespace App\Console;
use App\Jobs\ProcessDueSuspensions;
use App\Jobs\ProcessExpiredAbsences;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@@ -54,6 +55,11 @@ class Kernel extends ConsoleKernel
->daily();
// Production value: Every day
// Development value: Every minute
$schedule->job(new ProcessExpiredAbsences)
->daily();
// Production value: Every day
// Development value: Every minute
}
/**

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class AccountNotLinkedException extends Exception
{
//
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class DiscordAccountRequiredException extends Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class IncompatibleAgeException extends Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class InvalidAgeException extends Exception
{
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class ProfileAlreadyExistsException extends Exception
{
//
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class ProfileCreationFailedException extends Exception
{
//
}

32
app/Facades/Discord.php Executable file
View File

@@ -0,0 +1,32 @@
<?php
/*
* Copyright © 2020 Miguel Nogueira
*
* This file is part of Raspberry Staff Manager.
*
* Raspberry Staff Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Raspberry Staff Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Raspberry Staff Manager. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
class Discord extends Facade
{
protected static function getFacadeAccessor()
{
return 'discordServiceFacade';
}
}

View File

@@ -40,6 +40,6 @@ class Form extends Model
public function responses()
{
return $this->belongsTo('App\Response', 'id', 'id');
return $this->belongsTo('App\Response', 'id', 'responseFormID');
}
}

236
app/Helpers/Discord.php Executable file
View File

@@ -0,0 +1,236 @@
<?php
/*
* Copyright © 2020 Miguel Nogueira
*
* This file is part of Raspberry Staff Manager.
*
* Raspberry Staff Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Raspberry Staff Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Raspberry Staff Manager. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Helpers;
use App\Exceptions\AccountNotLinkedException;
use App\User;
use Carbon\Carbon;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
// Small wrapper for the necessary sections of the Discord API; A library is overkill here
class Discord
{
/**
* The current working guild. Default is Home guild from app config
* @var string
*/
protected string $workingGuild;
/**
* Current user.
*
* @var User The user all methods will affect.
*/
protected User $user;
public function __construct() {
if (isset($this->workingGuild)) {
$this->setWorkingGuild(config('services.discord.home_guild'));
}
}
/**
* Sets the working guild
*
* @param string $workingGuild
* @return Discord
*/
public function setWorkingGuild(string $workingGuild): Discord
{
$this->workingGuild = $workingGuild;
return $this;
}
/**
* Sets the current user, upon validation
*
* @param User $user
* @return Discord
* @throws AccountNotLinkedException
*/
public function setUser(User $user): Discord
{
if ($user->hasDiscordConnection()) {
$this->user = $user;
return $this;
}
throw new AccountNotLinkedException('Specified website user has not linked their Discord account yet.');
}
/**
* Obtains the current user's authentication info. Caches the data for the access_token's TTL, thus
* preventing unnecessary API requests.
*
* @return object Current user's authentication info (has no sensitive fields)
* @throws RequestException
*/
public function getAuthorizationInfo(): object {
if (Cache::has($this->user->discord_user_id)) {
return unserialize(Cache::get($this->user->discord_user_id));
}
else {
$authInfo = (object) Http::withToken($this->user->discord_token)
->get(config('services.discord.base_url') . '/oauth2/@me')
->throw()
->json();
Cache::put($this->user->discord_user_id, serialize($authInfo), Carbon::parse($authInfo->expires));
return $authInfo;
}
}
/**
* Checks if the user's token is close to expiring.
* Tokens should be refreshed the day before they expire.
*
* @return bool Whether the user's token needs to be refreshed
* @throws RequestException
*/
public function needsRefresh(): bool {
return Carbon::parse($this->getAuthorizationInfo()->expires)->diffInDays() == 1;
}
public function exchangeRefreshToken() {
}
/**
* Adds current working user to current working guild. Bot must be member of target guild, and account must be linked
*
* @return object|bool A GuildMember object; false if member is already in guild
* @throws RequestException Any client and server errors
* @see https://discord.com/developers/docs/resources/guild#guild-member-object
*/
public function addGuildMember(): object|bool
{
$params = [
'access_token' => $this->user->discord_token
];
$member = Http::withBody(json_encode($params), 'application/json')
->withHeaders([
'Authorization' => 'Bot ' . config('services.discord.token')
])->put(config('services.discord.base_url') . "/guilds/{$this->workingGuild}/members/{$this->user->discord_user_id}")
->throw();
if ($member->successful() && $member->status() == 204) {
return false;
} else {
return (object) $member->json();
}
}
/**
* Bans a specified user from the guild.
* May be called from the suspension service optionally by the banning user
*
* @param string $reason The reason to supply Discord with
* @return void Nothing on success
* @throws RequestException
* @throws AccountNotLinkedException
*/
public function addGuildBan(string $reason): void {
Http::withHeaders([
'Authorization' => 'Bot ' . config('services.discord.token'),
'X-Audit-Log-Reason' => $reason
])->put(config('services.discord.base_url') . "/guilds/{$this->workingGuild}/bans/{$this->user->discord_user_id}")
->throw();
throw new AccountNotLinkedException('Specified website user has not linked their Discord account yet.');
}
/**
* @param string $reason The removal reason to provide Discord with (e.g. ban expired)
* @return null|bool Null on unnan, false if user is not banned
* @throws RequestException
*/
public function removeGuildBan(string $reason): null|bool {
if ($this->getGuildBan($this->user)) {
Http::withHeaders([
'Authorization' => 'Bot ' . config('services.discord.token'),
'X-Audit-Log-Reason' => $reason
])->delete(config('services.discord.base_url') . "/guilds/{$this->workingGuild}/bans/{$this->user->discord_user_id}")
->throw();
return null;
}
return false;
}
/**
* Gets (possible) ban for current user.
*
* @return object|bool Ban object if user is banned. Null
* @throws RequestException
* @see https://discord.com/developers/docs/resources/guild#ban-object
*/
public function getGuildBan(): object|bool
{
$ban = Http::withHeaders([
'Authorization' => 'Bot ' . config('services.discord.token')
])->get(config('services.discord.base_url') . "/guilds/{$this->workingGuild}/bans/{$this->user->discord_user_id}");
if ($ban->status() == 404) {
return false;
}
return ($ban->successful()) ? (object) $ban->json() : $ban->throwIf($ban->status() !== 404);
}
/**
* Retrieves list of Role objects
* @see https://discord.com/developers/docs/topics/permissions#role-object
* @return array List of role objects
*
* @throws RequestException
*/
public function getGuildRoles(): array {
return Http::withHeaders([
'Authorization' => 'Bot ' . config('services.discord.token')
])->get(config('services.discord.base_url') . "/guilds/{$this->workingGuild}/roles")
->throw()
->json();
}
}

View File

@@ -37,8 +37,9 @@ class Options
/**
* Returns an assortment of settings found in the mentioned category
*
* @param $category The category
* @param string $category The category
* @return Collection The settings in this category
* @throws EmptyOptionsException
*/
public function getCategory(string $category): Collection
{

View File

@@ -6,7 +6,9 @@ use App\Absence;
use App\Exceptions\AbsenceNotActionableException;
use App\Http\Requests\StoreAbsenceRequest;
use App\Http\Requests\UpdateAbsenceRequest;
use App\Services\AbsenceService;
use App\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Carbon;
@@ -15,28 +17,12 @@ use Illuminate\Support\Facades\Auth;
class AbsenceController extends Controller
{
/**
* Determines whether someone already has an active leave of absence request
*
* @param User $user The user to check
* @return bool Their status
*/
private function hasActiveRequest(Authenticatable $user): bool {
private AbsenceService $absenceService;
$absences = Absence::where('requesterID', $user->id)->get();
public function __construct (AbsenceService $absenceService) {
foreach ($absences as $absence) {
$this->absenceService = $absenceService;
// Or we could adjust the query (using a model scope) to only return valid absences;
// If there are any, refuse to store more, but this approach also works
// A model scope that only returns cancelled, declined and ended absences could also be implemented for future use
if (in_array($absence->getRawOriginal('status'), ['PENDING', 'APPROVED']))
{
return true;
}
}
return false;
}
/**
@@ -57,7 +43,7 @@ class AbsenceController extends Controller
* Display a listing of absences belonging to the current user.
*
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
* @throws \Illuminate\Auth\Access\AuthorizationException
* @throws AuthorizationException
*/
public function showUserAbsences()
{
@@ -76,14 +62,14 @@ class AbsenceController extends Controller
/**
* Show the form for creating a new absence request.
*
* @return \Illuminate\Http\Response
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function create()
{
$this->authorize('create', Absence::class);
return view('dashboard.absences.create')
->with('activeRequest', $this->hasActiveRequest(Auth::user()));
->with('activeRequest', $this->absenceService->hasActiveRequest(Auth::user()));
}
/**
@@ -96,21 +82,13 @@ class AbsenceController extends Controller
{
$this->authorize('create', Absence::class);
if ($this->hasActiveRequest(Auth::user())) {
if ($this->absenceService->hasActiveRequest(Auth::user())) {
return redirect()
->back()
->with('error', __('You already have an active request. Cancel it or let it expire first.'));
}
$absence = Absence::create([
'requesterID' => Auth::user()->id,
'start' => $request->start_date,
'predicted_end' => $request->predicted_end,
'available_assist' => $request->available_assist == "on",
'reason' => $request->reason,
'status' => 'PENDING',
]);
$absence = $this->absenceService->createAbsence(Auth::user(), $request);
return redirect()
->to(route('absences.show', ['absence' => $absence->id]))
@@ -120,7 +98,8 @@ class AbsenceController extends Controller
/**
* Display the specified absence request.
*
* @param \App\Absence $absence
* @param \App\Absence $absence
* @throws AuthorizationException
*/
public function show(Absence $absence)
{
@@ -138,7 +117,7 @@ class AbsenceController extends Controller
*
* @param Absence $absence
* @return RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
* @throws AuthorizationException
*/
public function approveAbsence(Absence $absence): RedirectResponse
{
@@ -146,7 +125,7 @@ class AbsenceController extends Controller
try
{
$absence->setApproved();
$this->absenceService->approveAbsence($absence);
}
catch (AbsenceNotActionableException $notActionableException)
{
@@ -166,7 +145,7 @@ class AbsenceController extends Controller
*
* @param Absence $absence
* @return RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
* @throws AuthorizationException
*/
public function declineAbsence(Absence $absence): RedirectResponse
{
@@ -174,7 +153,7 @@ class AbsenceController extends Controller
try
{
$absence->setDeclined();
$this->absenceService->declineAbsence($absence);
} catch (AbsenceNotActionableException $notActionableException)
{
return redirect()
@@ -193,7 +172,7 @@ class AbsenceController extends Controller
*
* @param Absence $absence
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
* @throws AuthorizationException
*/
public function cancelAbsence(Absence $absence): \Illuminate\Http\RedirectResponse
{
@@ -201,7 +180,7 @@ class AbsenceController extends Controller
try
{
$absence->setCancelled();
$this->absenceService->cancelAbsence($absence);
}
catch (AbsenceNotActionableException $notActionableException)
{
@@ -225,7 +204,7 @@ class AbsenceController extends Controller
{
$this->authorize('delete', $absence);
if ($absence->delete()) {
if ($this->absenceService->removeAbsence($absence)) {
return redirect()
->to(route('absences.index'))
->with('success', __('Absence request deleted.'));

View File

@@ -23,11 +23,15 @@ namespace App\Http\Controllers;
use App\Application;
use App\Exceptions\ApplicationNotFoundException;
use App\Exceptions\DiscordAccountRequiredException;
use App\Exceptions\IncompatibleAgeException;
use App\Exceptions\IncompleteApplicationException;
use App\Exceptions\InvalidAgeException;
use App\Exceptions\UnavailableApplicationException;
use App\Exceptions\VacancyNotFoundException;
use App\Facades\IP;
use App\Services\ApplicationService;
use App\Vacancy;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -82,6 +86,13 @@ class ApplicationController extends Controller
}
public function discordApply(Request $request, $vacancySlug) {
$request->session()->put('discordApplicationRedirectedSlug', $vacancySlug);
return redirect(route('discordRedirect'));
}
public function renderApplicationForm($vacancySlug)
{
try {
@@ -91,25 +102,47 @@ class ApplicationController extends Controller
return redirect()
->back()
->with('error', $ex->getMessage());
} catch (DiscordAccountRequiredException $e) {
\Log::info('Redirecting user: ' . $e->getMessage(), [
'user' => Auth::user()->email
]);
request()->session()->put('discordApplicationRedirectedSlug', $vacancySlug);
return redirect(route('discordRedirect'));
} catch (IncompatibleAgeException $e) {
return redirect()
->to(route('dashboard'))
->with('error', $e->getMessage());
} catch (InvalidAgeException $e) {
return view('dashboard.application-rendering.add-age');
}
}
public function saveApplicationAnswers(Request $request, $vacancySlug)
{
try {
if (Auth::user()->isEligible()) {
try {
$this->applicationService->fillForm(Auth::user(), $request->all(), $vacancySlug);
$this->applicationService->fillForm(Auth::user(), $request->all(), $vacancySlug);
} catch (VacancyNotFoundException | IncompleteApplicationException | UnavailableApplicationException $e) {
} catch (VacancyNotFoundException | IncompleteApplicationException | UnavailableApplicationException $e) {
return redirect()
->back()
->with('error', $e->getMessage());
}
return redirect()
->back()
->with('error', $e->getMessage());
->to(route('showUserApps'))
->with('success', __('Thank you! Your application has been processed and our team will get to it shortly.'));
}
return redirect()
->to(route('showUserApps'))
->with('success', __('Thank you! Your application has been processed and our team will get to it shortly.'));
->with('error', __('Your account is not eligible to submit a new application.'));
}
public function updateApplicationStatus(Request $request, Application $application, $newStatus)

View File

@@ -0,0 +1,101 @@
<?php
/*
* Copyright © 2020 Miguel Nogueira
*
* This file is part of Raspberry Staff Manager.
*
* Raspberry Staff Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Raspberry Staff Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Raspberry Staff Manager. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Auth;
use App\Facades\Discord;
use App\Facades\Options;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\InvalidStateException;
class DiscordController extends Controller
{
public function discordRedirect() {
return Socialite::driver('discord')
->scopes(['email', 'guilds.join', 'guilds.members.read', 'guilds'])
->redirect();
}
public function discordCallback() {
try {
$discordUser = Socialite::driver('discord')->user();
} catch (InvalidStateException $stateException) {
Log::warning('Invalid state for social authentication: ', [
'message' => $stateException->getMessage(),
'ua' => request()->userAgent(),
'ip' => request()->ip()
]);
return redirect(route('discordRedirect'));
}
$appUser = User::where('email', $discordUser->getEmail())->first();
if ($appUser) {
$appUser->discord_token = $discordUser->token;
$appUser->discord_refresh_token = $discordUser->refreshToken;
$appUser->discord_user_id = $discordUser->getId();
$appUser->discord_pfp = $discordUser->getAvatar();
$appUser->save();
Auth::login($appUser, true);
} else {
$oAuthUser = User::create([
'uuid' => null,
'name' => $discordUser->getName(),
'email' => $discordUser->getEmail(),
'email_verified_at' => now(), // verify the account since it came from a trusted provider
'username' => $discordUser->getNickname(),
'currentIp' => \request()->ip(),
'registrationIp' => request()->ip(),
'discord_user_id' => $discordUser->getId(),
'discord_pfp' => $discordUser->getAvatar(),
'discord_token' => $discordUser->token,
'discord_refresh_token' => $discordUser->refreshToken
]);
$oAuthUser->assignRole('user');
Auth::login($oAuthUser, true);
}
if (session()->has('discordApplicationRedirectedSlug')) {
return redirect(route('renderApplicationForm', ['vacancySlug' => session()->pull('discordApplicationRedirectedSlug')]));
}
return redirect()
->route('dashboard');
}
}

View File

@@ -26,8 +26,11 @@ use App\Services\AccountSuspensionService;
use App\User;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use App\Facades\IP;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
class LoginController extends Controller
@@ -76,6 +79,14 @@ class LoginController extends Controller
$isLocked = $service->isLocked($user);
if ($isBanned || $isLocked) {
Log::alert('Restricted user attempting to login.', [
'ip' => $request->ip(),
'email' => $user->email,
'isBanned' => $isBanned,
'isLocked' => $isLocked
]);
return false;
} else {
return $this->originalAttemptLogin($request);
@@ -94,17 +105,11 @@ class LoginController extends Controller
'prev' => $user->originalIP,
'new' => $request->ip()
]);
$user->originalIP = $request->ip();
$user->currentIp = $request->ip();
$user->save();
}
}
}
public function discordRedirect() {
return Socialite::driver('discord')->redirect();
}
public function discordCallback() {
// TODO;
}
}

View File

@@ -23,11 +23,13 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Profile;
use App\Services\AccountSuspensionService;
use App\User;
use App\Facades\Options;
use App\Facades\IP;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class RegisterController extends Controller
@@ -62,19 +64,6 @@ class RegisterController extends Controller
$this->middleware('guest');
}
public function showRegistrationForm()
{
$users = User::where('originalIP', \request()->ip())->get();
foreach ($users as $user) {
if ($user && $user->isBanned()) {
abort(403, 'You do not have permission to access this page.');
}
}
return view('auth.register');
}
/**
* Get a validator for an incoming registration request.
*
@@ -106,9 +95,14 @@ class RegisterController extends Controller
'uuid' => (Options::getOption('requireGameLicense') && Options::getOption('currentGame') == 'MINECRAFT') ? ['required', 'string', 'unique:users', 'min:32', 'max:32'] : ['nullable', 'string'],
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'dob' => ['required', 'string', 'date_format:Y-m-d', 'before:-13 years'],
'acceptTerms' => ['required', 'accepted'],
'password' => $password,
], [
'uuid.required' => 'Please enter a valid (and Premium) Minecraft username! We do not support cracked users.',
'dob.before' => __('You must be 13 years of age or older in order to sign up for an account.'),
'dob.required' => __('Please enter your date of birth.'),
'uuid.required' => __('Please enter a valid (and Premium) Minecraft username! We do not support cracked users.'),
'acceptTerms.required' => __('Please accept the Community Guidelines, Terms of Service and Privacy Policy to continue.')
]);
}
@@ -120,12 +114,16 @@ class RegisterController extends Controller
*/
protected function create(array $data)
{
$ip = IP::shouldCollect() ? request()->ip() : '0.0.0.0';
$user = User::create([
'uuid' => $data['uuid'] ?? "disabled",
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'originalIP' => IP::shouldCollect() ? request()->ip() : '0.0.0.0',
'registrationIp' => $ip,
'currentIp' => $ip,
'dob' => $data['dob']
]);
$user->assignRole('user');

View File

@@ -1,88 +0,0 @@
<?php
/*
* Copyright © 2020 Miguel Nogueira
*
* This file is part of Raspberry Staff Manager.
*
* Raspberry Staff Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Raspberry Staff Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Raspberry Staff Manager. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers;
use App\Ban;
use App\Events\UserBannedEvent;
use App\Http\Requests\BanUserRequest;
use App\Services\AccountSuspensionService;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class BanController extends Controller
{
protected $suspensionService;
public function __construct(AccountSuspensionService $suspensionService)
{
// Inject the service via DI
$this->suspensionService = $suspensionService;
}
public function insert(BanUserRequest $request, User $user)
{
if (config('demo.is_enabled')) {
return redirect()
->back()
->with('error', __('This feature is disabled'));
}
$this->authorize('create', [Ban::class, $user]);
if (!$this->suspensionService->isSuspended($user)) {
$this->suspensionService->suspend($request->reason, $request->duration, $user, $request->suspensionType);
$request->session()->flash('success', __('Account suspended.'));
} else {
$request->session()->flash('error', __('Account already suspended!'));
}
return redirect()->back();
}
public function delete(Request $request, User $user)
{
if (config('demo.is_enabled')) {
return redirect()
->back()
->with('error', __('This feature is disabled'));
}
$this->authorize('delete', $user->bans);
if ($this->suspensionService->isSuspended($user)) {
$this->suspensionService->unsuspend($user);
$request->session()->flash('success', __('Account unsuspended successfully!'));
} else {
$request->session()->flash('error', __('This account isn\'t suspended!'));
}
return redirect()->back();
}
}

View File

@@ -1,64 +0,0 @@
<?php
/*
* Copyright © 2020 Miguel Nogueira
*
* This file is part of Raspberry Staff Manager.
*
* Raspberry Staff Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Raspberry Staff Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Raspberry Staff Manager. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers;
use App\Exceptions\FailedCaptchaException;
use App\Http\Requests\HomeContactRequest;
use App\Notifications\NewContact;
use App\Services\ContactService;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class ContactController extends Controller
{
protected $users;
private $contactService;
public function __construct(User $users, ContactService $contactService)
{
$this->contactService = $contactService;
$this->users = $users;
}
public function create(HomeContactRequest $request)
{
try {
$email = $request->email;
$msg = $request->msg;
$challenge = $request->input('captcha');
$this->contactService->sendMessage($request->ip(), $msg, $email, $challenge);
return redirect()
->back()
->with('success',__('Message sent successfully! We usually respond within 48 hours.'));
} catch (FailedCaptchaException $ex) {
return redirect()
->back()
->with('error', $ex->getMessage());
}
}
}

View File

@@ -24,10 +24,12 @@ namespace App\Http\Controllers;
use App\Application;
use App\Events\ApplicationApprovedEvent;
use App\Events\ApplicationDeniedEvent;
use App\Services\AbsenceService;
use App\Services\AccountSuspensionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class DevToolsController extends Controller
{
@@ -105,4 +107,16 @@ class DevToolsController extends Controller
->with('error', __('There were no expired suspensions (or no suspensions at all) to purge.'));
}
public function endAbsencesNow(AbsenceService $service)
{
$this->singleAuthorise();
$service->endExpired();
Log::alert('(absence cleaner) Forcefully started absence expiration check!');
return redirect()
->back()
->with('success', 'Cleaned up expired absences.');
}
}

View File

@@ -41,11 +41,4 @@ class HomeController extends Controller
return view('home')
->with('positions', $positions);
}
public function pageGiveaway()
{
return view('giveaway');
}
}

View File

@@ -21,8 +21,12 @@
namespace App\Http\Controllers;
use App\Exceptions\ProfileAlreadyExistsException;
use App\Exceptions\ProfileCreationFailedException;
use App\Exceptions\ProfileNotFoundException;
use App\Facades\IP;
use App\Http\Requests\ProfileSave;
use App\Services\AccountSuspensionService;
use App\Services\ProfileService;
use App\User;
use Carbon\Carbon;
@@ -32,18 +36,12 @@ use Spatie\Permission\Models\Role;
class ProfileController extends Controller
{
private $profileService;
private ProfileService $profileService;
public function __construct(ProfileService $profileService) {
$this->profileService = $profileService;
}
public function index()
{
return view('dashboard.user.directory')
->with('users', User::with('profile', 'bans')->paginate(9));
}
public function showProfile()
{
// TODO: Come up with cleaner social media solution, e.g. social media object
@@ -60,26 +58,23 @@ class ProfileController extends Controller
]);
}
public function showSingleProfile(User $user)
public function showSingleProfile(AccountSuspensionService $accountSuspensionService, User $user)
{
if (is_null($user->profile)) {
return redirect()
->back()
->with('error', "This user doesn't have a profile.");
}
$socialMediaProfiles = json_decode($user->profile->socialLinks, true);
$createdDate = Carbon::parse($user->created_at);
$systemRoles = Role::all()->pluck('name')->all();
$userRoles = $user->roles->pluck('name')->all();
$roleList = [];
foreach ($systemRoles as $role) {
if (in_array($role, $userRoles)) {
$roleList[$role] = true;
} else {
$roleList[$role] = false;
}
}
$suspensionInfo = null;
if ($user->isBanned())
if ($accountSuspensionService->isSuspended($user))
{
$suspensionInfo = [
@@ -98,8 +93,7 @@ class ProfileController extends Controller
'insta' => $socialMediaProfiles['links']['insta'] ?? 'UpdateMe',
'discord' => $socialMediaProfiles['links']['discord'] ?? 'UpdateMe#12345',
'since' => $createdDate->englishMonth.' '.$createdDate->year,
'ipInfo' => IP::lookup($user->originalIP),
'roles' => $roleList,
'ipInfo' => IP::lookup($user->currentIp),
'suspensionInfo' => $suspensionInfo
]);
} else {
@@ -114,4 +108,44 @@ class ProfileController extends Controller
->back()
->with('success', __('Profile updated.'));
}
public function createProfile(Request $request)
{
try {
$this->profileService->createProfile($request->user());
} catch (\Exception $e) {
return redirect()
->back()
->with('error', $e->getMessage());
}
return redirect()
->back()
->with('success', __('Your profile has been created.'));
}
public function deleteProfile(Request $request)
{
try {
$this->profileService->deleteProfile($request->user());
} catch (ProfileNotFoundException $e) {
return redirect()
->back()
->with('error', $e->getMessage());
}
return redirect()
->back()
->with('success', __('Profile deleted successfully.'));
}
}

View File

@@ -22,22 +22,36 @@
namespace App\Http\Controllers;
use App\Ban;
use App\Facades\IP;
use App\Facades\Options;
use App\Http\Requests\Add2FASecretRequest;
use App\Http\Requests\AddDobRequest;
use App\Http\Requests\BanUserRequest;
use App\Http\Requests\ChangeEmailRequest;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\DeleteUserRequest;
use App\Http\Requests\FlushSessionsRequest;
use App\Http\Requests\Remove2FASecretRequest;
use App\Http\Requests\Reset2FASecretRequest;
use App\Http\Requests\SearchPlayerRequest;
use App\Http\Requests\SetNewPasswordRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Notifications\ChangedPassword;
use App\Notifications\EmailChanged;
use App\Notifications\PasswordAdminResetNotification;
use App\Notifications\TwoFactorResetNotification;
use App\Services\AccountSuspensionService;
use App\Services\DiscordService;
use App\Traits\DisablesFeatures;
use App\Traits\HandlesAccountDeletion;
use App\Traits\ReceivesAccountTokens;
use App\User;
use Google2FA;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
@@ -45,13 +59,20 @@ use Spatie\Permission\Models\Role;
class UserController extends Controller
{
use HandlesAccountDeletion;
use HandlesAccountDeletion, DisablesFeatures;
/**
* Shows list of users
*
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function showUsers()
{
$this->authorize('viewPlayers', User::class);
return view('dashboard.administration.players')
return view('dashboard.administration.users')
->with([
'users' => User::with('roles')->paginate('6'),
'numUsers' => count(User::all()),
@@ -59,6 +80,15 @@ class UserController extends Controller
]);
}
/**
* Searches for a player with the given search query.
*
* @deprecated Until Algolia implementation
* @param SearchPlayerRequest $request
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function showPlayersLike(SearchPlayerRequest $request)
{
$this->authorize('viewPlayers', User::class);
@@ -72,7 +102,7 @@ class UserController extends Controller
if (! $matchingUsers->isEmpty()) {
$request->session()->flash('success', __('There were :usersCount user(s) matching your search.', ['usersCount' => $matchingUsers->count()]));
return view('dashboard.administration.players')
return view('dashboard.administration.users')
->with([
'users' => $matchingUsers,
'numUsers' => count(User::all()),
@@ -85,6 +115,16 @@ class UserController extends Controller
}
}
/**
* Shows the user account's settings page
*
* @param Request $request
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
*/
public function showAccount(Request $request)
{
$QRCode = null;
@@ -109,6 +149,58 @@ class UserController extends Controller
->with('twofaQRCode', $QRCode);
}
/**
* Show account management screen
*
* @param AccountSuspensionService $suspensionService
* @param Request $request
* @param User $user
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function showAcocuntManagement(AccountSuspensionService $suspensionService, Request $request, User $user)
{
$this->authorize('adminEdit', $user);
$systemRoles = Role::all()->pluck('name')->all();
$userRoles = $user->roles->pluck('name')->all();
$roleList = [];
foreach ($systemRoles as $role) {
if (in_array($role, $userRoles)) {
$roleList[$role] = true;
} else {
$roleList[$role] = false;
}
}
return view('dashboard.user.manage')
->with([
'user' => $user,
'roles' => $roleList,
'isVerified' => $user->isVerified(),
'isLocked' => $suspensionService->isLocked($user),
'isSuspended' => $suspensionService->isSuspended($user),
'hasDiscord' => $user->hasDiscordConnection(),
'hasPassword' => $user->hasPassword(),
'requireLicense' => Options::getOption('requireGameLicense'),
'suspensionReason' => $suspensionService->getSuspensionReason($user),
'suspensionDuration' => $suspensionService->getSuspensionDuration($user),
'has2FA' => $user->has2FA(),
'applications' => $user->applications()->get()
]);
}
/**
* Log out other sessions for the current user
*
* @param FlushSessionsRequest $request
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\AuthenticationException
*/
public function flushSessions(FlushSessionsRequest $request)
{
// TODO: Move all log calls to a listener, which binds to an event fired by each significant event, such as this one
@@ -127,6 +219,14 @@ class UserController extends Controller
return redirect()->back();
}
/**
* Change the current user's password
*
* @param ChangePasswordRequest $request
* @return \Illuminate\Http\RedirectResponse|void
*/
public function changePassword(ChangePasswordRequest $request)
{
if (config('demo.is_enabled')) {
@@ -155,13 +255,80 @@ class UserController extends Controller
}
}
/**
* Sets a new password for the user.
*
* @param SetNewPasswordRequest $request
* @return Application|RedirectResponse|Redirector
*/
public function setPassword(SetNewPasswordRequest $request) {
if (!Auth::user()->hasPassword()) {
Auth::user()->password = Hash::make($request->newpass);
Auth::user()->save();
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect(route('login'));
}
return redirect()
->back()
->with('error', __('Your account already has a password.'));
}
/**
* Sets a user's password and removes their discord information from storage
*
* @param User $user
* @param SetNewPasswordRequest $request
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function unlinkDiscordAccount(Request $request, DiscordService $discordService)
{
if ($request->user()->hasPassword()) {
try {
$discordService->revokeAccountTokens(Auth::user());
Log::warning('Revoking social account tokens, user initiated', [
'user' => Auth::user()->email
]);
} catch (RequestException $requestException) {
if ($requestException->getCode() == 401) {
return redirect(route('discordRedirect'));
}
Log::error('Error while trying to revoke Discord credentials', [$requestException->getMessage()]);
return redirect()
->back()
->with('error', __('An unknown error ocurred. Please try again later.'));
}
$request->session()->flash('success', __('Discord account unlinked successfully. Link it again by re-authorizing the app with the same account in the login screen, or through your account settings.'));
return redirect()->back();
}
return redirect()
->back()
->with('error', __('Please set a password for your account first before trying to unlink Discord.'));
}
/**
* Change the current user's email address
*
* @param ChangeEmailRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function changeEmail(ChangeEmailRequest $request)
{
if (config('demo.is_enabled')) {
return redirect()
->back()
->with('error', __('This feature is disabled'));
}
$this->disable();
$user = User::find(Auth::user()->id);
@@ -184,14 +351,69 @@ class UserController extends Controller
return redirect()->back();
}
public function delete(DeleteUserRequest $request, User $user)
{
if (config('demo.is_enabled')) {
/**
* Removes the user's password and notifies them.
*
* @param User $user The user to remove the password for
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function forcePasswordReset(User $user) {
$this->authorize('adminEdit', $user);
if ($user->hasPassword()) {
$user->notify(new PasswordAdminResetNotification());
$user->password = null;
$user->save();
Log::alert("Removed account password", [
'target' => $user,
'actor' => Auth::user()
]);
return redirect()
->back()
->with('error', _('This feature is disabled'));
->with('success', __('Account password removed.'));
}
return redirect()
->back()
->with('error', __('This user doesn\'t have a password to reset.'));
}
/**
* Adds a user's date of birth if they don't have one.
*
* @param AddDobRequest $request
* @return RedirectResponse
*/
public function addDob(AddDobRequest $request) {
Auth::user()->dob = $request->dob;
Auth::user()->save();
return redirect()
->back();
}
/**
* Delete the given user's account
*
* @param DeleteUserRequest $request
* @param User $user
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function delete(DeleteUserRequest $request, User $user)
{
$this->disable();
$this->authorize('delete', $user);
if ($request->confirmPrompt == 'DELETE ACCOUNT') {
@@ -204,14 +426,19 @@ class UserController extends Controller
return redirect()->route('registeredPlayerList');
}
/**
* Update a given user's details
*
* @param UpdateUserRequest $request
* @param User $user
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function update(UpdateUserRequest $request, User $user)
{
if (config('demo.is_enabled')) {
return redirect()
->back()
->with('error', __('This feature is disabled'));
}
$this->authorize('adminEdit', $user);
$this->disable();
// Mass update would not be possible here without extra code, making route model binding useless
$user->email = $request->email;
@@ -243,6 +470,16 @@ class UserController extends Controller
return redirect()->back();
}
/**
* Generate and add a 2FA secret for the current user
*
* @param Add2FASecretRequest $request
* @return \Illuminate\Http\RedirectResponse
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
*/
public function add2FASecret(Add2FASecretRequest $request)
{
if (config('demo.is_enabled')) {
@@ -285,6 +522,13 @@ class UserController extends Controller
return redirect()->back();
}
/**
* Remove the current user's two factor secret key
*
* @param Remove2FASecretRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function remove2FASecret(Remove2FASecretRequest $request)
{
Log::warning('SECURITY: Disabling two factor authentication (user initiated)', [
@@ -300,34 +544,94 @@ class UserController extends Controller
return redirect()->back();
}
public function terminate(Request $request, User $user)
{
$this->authorize('terminate', User::class);
if (config('demo.is_enabled')) {
/**
* Remove the given user's two factor secret key
*
* @param Reset2FASecretRequest $request
* @param User $user
* @return \Illuminate\Http\RedirectResponse
*/
public function reset2FASecret(Reset2FASecretRequest $request, User $user) {
// note: could invalidate other sessions for increased security
if ($user->has2FA()) {
Log::warning('SECURITY: Disabling two factor authentication (admin initiated)', [
'initiator' => $request->user()->email,
'target' => $user->email,
'ip' => $request->ip(),
]);
$user->twofa_secret = null;
$user->password = null;
$user->save();
$user->notify(new TwoFactorResetNotification());
return redirect()
->back()
->with('error', __('This feature is disabled'));
->with('success', __('Two factor removed & user notified.'));
}
// TODO: move logic to policy
if (! $user->isStaffMember() || $user->is(Auth::user())) {
$request->session()->flash('error', __('You cannot terminate this user.'));
return redirect()
->back()
->with('error', 'This user does not have two-factor authentication enabled.');
}
return redirect()->back();
/**
* Suspend the given user
*
* @param AccountSuspensionService $suspensionService
* @param BanUserRequest $request
* @param User $user
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function suspend(AccountSuspensionService $suspensionService, BanUserRequest $request, User $user)
{
$this->authorize('create', [Ban::class, $user]);
$this->disable();
if ($suspensionService->isSuspended($user))
{
return redirect()
->back()
->with('error', __('Account already suspended.'));
}
foreach ($user->roles as $role) {
if ($role->name == 'user') {
continue;
}
$user->removeRole($role->name);
if ($request->suspensionType = "on") {
$suspensionService->suspend($user, $request->reason, $request->duration);
}
else {
$suspensionService->suspend($user, $request->reason);
}
Log::info('User '.$user->name.' has just been demoted.');
$request->session()->flash('success', __('User terminated successfully.'));
//TODO: Dispatch event
return redirect()->back();
}
/**
* Unsuspend the given user
*
* @param AccountSuspensionService $suspensionService
* @param Request $request
* @param User $user
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function unsuspend(AccountSuspensionService $suspensionService, Request $request, User $user)
{
$this->authorize('delete', $user->bans);
$this->disable();
if ($suspensionService->isSuspended($user)) {
$suspensionService->unsuspend($user);
$request->session()->flash('success', __('Account unsuspended successfully!'));
} else {
$request->session()->flash('error', __('This account isn\'t suspended!'));
}
return redirect()->back();
}
}

View File

@@ -70,6 +70,8 @@ class VacancyController extends Controller
'discordRoleID' => $request->discordRole,
'vacancyFormID' => $request->vacancyFormID,
'vacancyCount' => $request->vacancyCount,
'requiresDiscord' => $request->requireDiscordAccount == 'on',
'requiredAge' => $request->requiredAge
]);
@@ -142,6 +144,8 @@ class VacancyController extends Controller
$vacancy->vacancyFullDescription = $request->vacancyFullDescription;
$vacancy->vacancyDescription = $request->vacancyDescription;
$vacancy->vacancyCount = $request->vacancyCount;
$vacancy->requiresDiscord = $request->requireDiscordAccount == 'on';
$vacancy->requiredAge = $request->requiredAge;
$vacancy->save();
@@ -153,10 +157,18 @@ class VacancyController extends Controller
public function delete(Request $request, Vacancy $vacancy)
{
$this->authorize('delete', $vacancy);
$vacancy->delete();
if ($vacancy->teams->isEmpty()) {
$vacancy->delete();
return redirect()
->back()
->with('success', __('Vacancy deleted. All applications associated with it are now gone too.'));
}
return redirect()
->back()
->with('success', __('Vacancy deleted. All applications associated with it are now gone too.'));
->with('error', __('Please detach any teams that may be using this vacancy first.'));
}
}

View File

@@ -89,10 +89,10 @@ class Kernel extends HttpKernel
'2fa' => \PragmaRX\Google2FALaravel\Middleware::class,
'passwordexpiration' => \App\Http\Middleware\PasswordExpirationMiddleware::class,
'passwordredirect' => \App\Http\Middleware\PasswordExpirationRedirectMiddleware::class,
'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class,
'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class,
'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class,
'localeCookieRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleCookieRedirect::class,
'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class,
'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class,
'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class,
'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class,
'localeCookieRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleCookieRedirect::class,
'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class,
];
}

View File

@@ -22,8 +22,11 @@
namespace App\Http\Middleware;
use App\Application;
use App\User;
use Carbon\Carbon;
use Closure;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\View;
@@ -33,34 +36,42 @@ class ApplicationEligibility
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @deprecated Deprecated in 0.9.0
* @see User::isEligible()
* @param Request $request
* @param Closure $next
* @return mixed
* @throws \Exception
* @throws Exception
*/
public function handle($request, Closure $next)
{
$curtime = new Carbon(now());
$eligible = false;
$daysRemaining = __('N/A');
if (Auth::check()) {
$applications = Application::where('applicantUserID', Auth::user()->id)->get();
$eligible = true;
$daysRemaining = 0;
$lastApplication = Application::where('applicantUserID', Auth::user()->id)->latest()->first();
if (! $applications->isEmpty()) {
foreach ($applications as $application) {
$appTime = Carbon::parse($application->created_at);
if ($appTime->isSameMonth($curtime)) {
Log::warning('Notice: Application ID '.$application->id.' was found to be in the same month as today\'s time, making the user '.Auth::user()->name.' ineligible for application');
$eligible = false;
}
}
if (is_null($lastApplication)) {
View::share('isEligibleForApplication', true);
View::share('eligibilityDaysRemaining', 0);
$allowedTime = Carbon::parse($applications->last()->created_at)->addMonth();
$daysRemaining = $allowedTime->diffInDays(now());
return $next($request);
}
$daysRemaining = $lastApplication->created_at->addMonth()->diffInDays(now());
if ($lastApplication->created_at->diffInMonths(now()) > 1 && in_array($lastApplication->applicationStatus, ['DENIED', 'APPROVED'])) {
$eligible = true;
}
Log::debug('Perfomed application eligibility check', [
'eligible' => $eligible,
'daysRemaining' => $daysRemaining,
'ipAddress' => Auth::user()->originalIP,
'checkUserID' => Auth::user()->id
]);
View::share('isEligibleForApplication', $eligible);
View::share('eligibilityDaysRemaining', $daysRemaining);
}

View File

@@ -21,12 +21,21 @@
namespace App\Http\Middleware;
use App\Services\AccountSuspensionService;
use Closure;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View;
class Bancheck
{
private $suspensionService;
public function __construct(AccountSuspensionService $suspensionService) {
$this->suspensionService = $suspensionService;
}
/**
* Handle an incoming request.
*
@@ -37,11 +46,11 @@ class Bancheck
public function handle($request, Closure $next)
{
$userIP = $request->ip();
$anonymousUser = User::where('ipAddress', $userIP)->get();
$anonymousUser = User::where('currentIp', $userIP)->get();
if (Auth::check() && Auth::user()->isBanned()) {
if (Auth::check() && $this->suspensionService->isSuspended($anonymousUser)) {
View::share('isBanned', true);
} elseif (! $anonymousUser->isEmpty() && User::find($anonymousUser->id)->isBanned()) {
} elseif (! $anonymousUser->isEmpty() && $this->suspensionService->isSuspended(User::find($anonymousUser->id))) {
View::share('isBanned', true);
} else {
View::share('isBanned', false);

View File

@@ -21,6 +21,7 @@
namespace App\Http\Middleware;
use App\Services\AccountSuspensionService;
use Closure;
use Illuminate\Support\Facades\Auth;
@@ -35,10 +36,10 @@ class ForceLogoutMiddleware
*/
public function handle($request, Closure $next)
{
if (Auth::user()->isBanned()) {
Auth::logout();
$request->session()->flash('error', __('Your account is suspended. You will not be able to login or register until the suspension is lifted.'));
if ((new AccountSuspensionService())->isSuspended(Auth::user())) {
Auth::logout();
$request->session()->flash('error', __('Your account is suspended. If you think this was a mistake, please contact an admin.'));
return redirect('/');
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class AddDobRequest extends FormRequest
{
public function rules(): array
{
return [
'dob' => 'required|string|date_format:Y-m-d|before:-13 years',
];
}
public function authorize(): bool
{
if (is_null(Auth::user()->dob)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class AdminPasswordResetRequest extends FormRequest
{
public function rules(): array
{
if (Auth::user()->has2FA()) {
return [
'currentPassword' => 'required|current_password:web',
'otp' => 'required|integer|max:6',
];
}
return [
'currentPassword' => 'required|current_password:web',
];
}
public function authorize(): bool
{
return true;
}
}

View File

@@ -44,7 +44,6 @@ class Remove2FASecretRequest extends FormRequest
{
return [
'currentPassword' => 'required|current_password',
'consent' => 'required|accepted',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class Reset2FASecretRequest extends FormRequest
{
public function rules(): array
{
return [
'currentPassword' => 'required|current_password',
];
}
public function authorize(): bool
{
return true;
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class HomeContactRequest extends FormRequest
class SetNewPasswordRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
@@ -13,7 +13,11 @@ class HomeContactRequest extends FormRequest
*/
public function authorize()
{
return true;
if (\Auth::user()->hasDiscordConnection()) {
return true;
}
return false;
}
/**
@@ -24,9 +28,7 @@ class HomeContactRequest extends FormRequest
public function rules()
{
return [
'email' => 'required|email',
'msg' => 'required|string',
'captcha' => 'required|string'
'newpass' => 'required|string|min:10|confirmed',
];
}
}

View File

@@ -46,7 +46,7 @@ class UpdateUserRequest extends FormRequest
return [
'email' => 'required|email',
'name' => 'required|string',
'uuid' => 'required|max:32|min:32',
'uuid' => 'nullable|max:32|min:32',
'roles' => 'required_without_all',
];
}

View File

@@ -47,6 +47,8 @@ class VacancyEditRequest extends FormRequest
'vacancyDescription' => 'required|string',
'vacancyFullDescription' => 'nullable|string',
'vacancyCount' => 'required|integer|min:1',
'requireDiscordAccount' => 'required|string',
'requiredAge' => 'required|integer|numeric|min:13|max:100'
];
}
}

View File

@@ -25,6 +25,8 @@ use Illuminate\Foundation\Http\FormRequest;
class VacancyRequest extends FormRequest
{
public mixed $requiresDiscordAccount;
/**
* Determine if the user is authorized to make this request.
*
@@ -46,10 +48,12 @@ class VacancyRequest extends FormRequest
'vacancyName' => 'required|string',
'vacancyDescription' => 'required|string',
'vacancyFullDescription' => 'nullable|string',
'permissionGroup' => 'required|string',
'discordRole' => 'required|string',
'permissionGroup' => 'nullable|string',
'discordRole' => 'nullable|string',
'vacancyCount' => 'required|integer',
'vacancyFormID' => 'required|integer',
'requireDiscordAccount' => 'required|string',
'requiredAge' => 'required|integer|numeric|min:13|max:100'
];
}
}

0
app/Jobs/ProcessAccountDelete.php Normal file → Executable file
View File

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Jobs;
use App\Services\AbsenceService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessExpiredAbsences implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle(AbsenceService $absenceService)
{
Log::info('(absence cleaner) Ending all expired absences.');
$absenceService->endExpired();
}
}

30
app/Listeners/NewUser.php Executable file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class NewUser
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle($event)
{
//
}
}

View File

@@ -49,10 +49,11 @@ class OnUserRegistration
// TODO: Send push notification to online admins via browser (w/ pusher)
Log::info('User '.$event->user->name.' has just registered for an account.');
foreach (User::all() as $user) {
if ($user->hasRole('admin')) {
$user->notify(new NewUser($event->user));
}
}
User::whereHas('roles', function ($q) {
$q->where('name', 'admin');
})->get()->each(function ($user, $key) use ($event) {
$user->notify(new NewUser($event->user));
});
}
}

View File

@@ -45,11 +45,15 @@ class PromoteUser
*/
public function handle(ApplicationApprovedEvent $event)
{
Log::info('User '.$event->application->user->name . 'has just been promoted (application approved)');
Log::info('User promoted automatically (application approved)', [
'user' => $event->application->user->name,
'vacancy' => $event->application->response->vacancy->vacancyName,
'role' => 'staff'
]);
$event->application->setStatus('APPROVED');
$event->application->response->vacancy->decrease();
$event->application->user->assignRole('reviewer');
$event->application->user->assignRole('staff');
$event->application->user->notify(new ApplicationApproved($event->application));
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Notifications;
use App\Absence;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AbsenceRequestApproved extends Notification implements ShouldQueue
{
use Queueable;
public Absence $absence;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Absence $absence)
{
$this->absence = $absence;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
return (new MailMessage)
->greeting('Hi ' . $notifiable->name . ',')
->from(config('notification.sender.address'), config('notification.sender.name'))
->subject(config('app.name').' - absence request approved')
->line("Your recent Leave of Absence request from {$this->absence->created_at} has just been approved by an admin.")
->line('Your inactivity during the period you selected won\'t be counted. You will receive another email notification when your request ends, or if you decide to cancel it.')
->action('View your request', url(route('absences.show', ['absence' => $this->absence->id])))
->salutation('The team at ' . config('app.name'));
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
//
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Notifications;
use App\Absence;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AbsenceRequestCancelled extends Notification implements ShouldQueue
{
use Queueable;
public Absence $absence;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Absence $absence)
{
$this->absence = $absence;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
return (new MailMessage)
->greeting('Hi ' . $notifiable->name . ',')
->from(config('notification.sender.address'), config('notification.sender.name'))
->subject(config('app.name').' - absence request cancelled')
->line("This notification confirms that your recent Leave of Absence from {$this->absence->created_at} has just been cancelled by you.")
->line('Please note that any inactivity will be counted in our activity metrics. You may also make a new request if you wish.')
->action('Send new request', url(route('absences.create')))
->salutation('The team at ' . config('app.name'));
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
//
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Notifications;
use App\Absence;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AbsenceRequestDeclined extends Notification implements ShouldQueue
{
use Queueable;
public Absence $absence;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Absence $absence)
{
$this->absence = $absence;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
return (new MailMessage)
->greeting('Hi ' . $notifiable->name . ',')
->from(config('notification.sender.address'), config('notification.sender.name'))
->subject(config('app.name').' - absence request declined')
->line("Your recent Leave of Absence request from {$this->absence->created_at} has just been declined by an admin.")
->line('Please note that any inactivity will be counted in our activity metrics. You may make a new request, but we recommend you ask your team lead regarding your declined request.')
->action('Send new request', url(route('absences.create')))
->salutation('The team at ' . config('app.name'));
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
//
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Notifications;
use App\Absence;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AbsenceRequestEnded extends Notification implements ShouldQueue
{
use Queueable;
public Absence $absence;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Absence $absence)
{
$this->absence = $absence;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
return (new MailMessage)
->greeting('Hi ' . $notifiable->name . ',')
->from(config('notification.sender.address'), config('notification.sender.name'))
->subject(config('app.name').' - absence request expired')
->line("Your Leave of Absence request from {$this->absence->created_at} (until {$this->absence->predicted_end}) has expired today.")
->line('Please note that any inactivity will be counted in our activity metrics. You may now make a new request if you still need more time.')
->action('Send new request', url(route('absences.create')))
->salutation('The team at ' . config('app.name'));
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
//
];
}
}

0
app/Notifications/AccountDeleted.php Normal file → Executable file
View File

0
app/Notifications/AccountLocked.php Normal file → Executable file
View File

0
app/Notifications/AccountUnlocked.php Normal file → Executable file
View File

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Notifications;
use App\Absence;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewAbsenceRequest extends Notification implements ShouldQueue
{
use Queueable;
public Absence $absence;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Absence $absence)
{
$this->absence = $absence;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
return (new MailMessage)
->greeting('Hi ' . $notifiable->name . ',')
->from(config('notification.sender.address'), config('notification.sender.name'))
->subject(config('app.name').' - new absence request pending review')
->line("A new absence request has just been submitted, scheduled to end {$this->absence->predicted_end}. Please review this request and take the appropriate action(s). The requester will be notified of your decision by email.")
->line("You are receiving this email because you're a site admin.")
->action('Review request', url(route('absences.show', ['absence' => $this->absence->id])))
->salutation('The team at ' . config('app.name'));
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
//
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PasswordAdminResetNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct()
{
}
public function via($notifiable): array
{
return ['mail'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage)
->from(config('notification.sender.address'), config('notification.sender.name'))
->subject(config('app.name').' - account password invalidated')
->markdown('mail.adminreset', ['name' => $notifiable->name]);
}
public function toArray($notifiable): array
{
return [];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TwoFactorResetNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct()
{
}
public function via($notifiable): array
{
return ['mail'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage)
->from(config('notification.sender.address'), config('notification.sender.name'))
->subject(config('app.name').' - your second factor has been reset')
->markdown('mail.two-factor-reset', ['name' => $notifiable->name]);
}
public function toArray($notifiable): array
{
return [];
}
}

0
app/Notifications/UserDeletedAccount.php Normal file → Executable file
View File

View File

@@ -21,7 +21,10 @@
namespace App\Observers;
use App\Exceptions\ProfileAlreadyExistsException;
use App\Exceptions\ProfileCreationFailedException;
use App\Profile;
use App\Services\ProfileService;
use App\User;
use Illuminate\Support\Facades\Log;
@@ -40,15 +43,24 @@ class UserObserver
*/
public function created(User $user)
{
// TODO: Make sure that the profile is created, throw an exception if otherwise, or try to recreate the profile.
Profile::create([
$profileService = new ProfileService();
'profileShortBio' => 'Write a one-liner about you here!',
'profileAboutMe' => 'Tell us a bit about you.',
'socialLinks' => '{}',
'userID' => $user->id,
]);
try
{
$profileService->createProfile($user);
}
catch (ProfileAlreadyExistsException $exception)
{
Log::error('Attempting to create profile that already exists!', [
'trace' => $exception->getTrace()
]);
}
catch (ProfileCreationFailedException $e)
{
Log::error('Failed creating a new profile!', [
'trace' => $e->getTrace()
]);
}
}
/**

View File

@@ -47,6 +47,7 @@ use App\Vote;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
@@ -90,5 +91,17 @@ class AuthServiceProvider extends ServiceProvider
->salutation('The team at ' . config('app.name'));
});
Gate::define('viewLogViewer', function (?User $user){
return $user->hasPermissionTo('admin.developertools.use');
});
Gate::define('downloadLogFile', function (User $user){
return $user->hasPermissionTo('admin.developertools.use');
});
Gate::define('deleteLogFile', function (User $user){
return $user->hasPermissionTo('admin.developertools.use');
});
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Providers;
use App\Helpers\Discord;
use Illuminate\Support\ServiceProvider;
class DiscordOuthProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
\App::bind('discordServiceFacade', function () {
return new Discord();
});
}
}

View File

@@ -70,6 +70,7 @@ class EventServiceProvider extends ServiceProvider
'App\Events\UserBannedEvent' => [
'App\Listeners\OnUserBanned',
],
];
/**

View File

@@ -31,7 +31,7 @@ class RouteServiceProvider extends ServiceProvider
*
* @var string
*/
public const HOME = '/home';
public const HOME = '/dashboard';
/**
* Define your route model bindings, pattern filters, etc.

183
app/Services/AbsenceService.php Executable file
View File

@@ -0,0 +1,183 @@
<?php
namespace App\Services;
use App\Absence;
use App\Exceptions\AbsenceNotActionableException;
use App\Notifications\AbsenceRequestApproved;
use App\Notifications\AbsenceRequestCancelled;
use App\Notifications\AbsenceRequestDeclined;
use App\Notifications\AbsenceRequestEnded;
use App\Notifications\NewAbsenceRequest;
use App\User;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Spatie\Permission\Models\Role;
class AbsenceService
{
/**
* Determines whether someone already has an active leave of absence request
*
* @param User $user The user to check
* @return bool Their status
*/
public function hasActiveRequest(Authenticatable $user): bool {
$absences = Absence::where('requesterID', $user->id)->get();
foreach ($absences as $absence) {
// Or we could adjust the query (using a model scope) to only return valid absences;
// If there are any, refuse to store more, but this approach also works
// A model scope that only returns cancelled, declined and ended absences could also be implemented for future use
if (in_array($absence->getRawOriginal('status'), ['PENDING', 'APPROVED']))
{
return true;
}
}
return false;
}
public function createAbsence(Authenticatable $requester, Request $request)
{
$absence = Absence::create([
'requesterID' => $requester->id,
'start' => $request->start_date,
'predicted_end' => $request->predicted_end,
'available_assist' => $request->available_assist == "on",
'reason' => $request->reason,
'status' => 'PENDING',
]);
foreach(User::role('admin')->get() as $admin) {
$admin->notify(new NewAbsenceRequest($absence));
}
Log::info('Processing new leave of absence request.', [
'requesting_user' => $requester->email,
'absenceid' => $absence->id,
'reason' => $request->reason
]);
return $absence;
}
/**
* Sets an absence as Approved.
*
* @param Absence $absence The absence to approve.
* @return Absence The approved absence.
* @throws AbsenceNotActionableException
*/
public function approveAbsence(Absence $absence)
{
Log::info('An absence request has just been approved.', [
'absenceid' => $absence->id,
'reviewing_admim' => Auth::user()->email,
'new_status' => 'APPROVED'
]);
return $absence
->setApproved()
->requester->notify(new AbsenceRequestApproved($absence));
}
/**
* Sets an absence as Declined.
*
* @param Absence $absence The absence to decline.
* @return Absence The declined absence.
* @throws AbsenceNotActionableException
*/
public function declineAbsence(Absence $absence)
{
Log::warning('An absence request has just been declined.', [
'absenceid' => $absence->id,
'reviewing_admim' => Auth::user()->email,
'new_status' => 'DECLINED'
]);
return $absence
->setDeclined()
->requester->notify(new AbsenceRequestDeclined($absence));
}
/**
* Sets an absence as Cancelled.
*
* @param Absence $absence The absence to cancel.
* @return Absence The cancelled absence.
* @throws AbsenceNotActionableException
*/
public function cancelAbsence(Absence $absence)
{
Log::warning('An absence request has just been cancelled (only cancellable by requester).', [
'absenceid' => $absence->id,
'new_status' => 'CANCELLED'
]);
return $absence
->setCancelled()
->requester->notify(new AbsenceRequestCancelled($absence));
}
/**
* Sets an absence as Ended.
*
* @param Absence $absence
* @return bool
*/
public function endAbsence(Absence $absence)
{
Log::info('An absence request has just expired.', [
'absenceid' => $absence->id,
'new_status' => 'ENDED'
]);
return $absence
->setEnded()
->requester->notify(new AbsenceRequestEnded($absence));
}
/**
* Removes an absence
*
* @param Absence $absence The absence to remove.
* @return bool Whether the absence was removed.
*/
public function removeAbsence(Absence $absence): bool
{
Log::alert('An absence request has just been removed.', [
'absence_details' => $absence,
'reviewing_admim' => Auth::user()->email,
]);
return $absence->delete();
}
public function endExpired()
{
foreach (Absence::all() as $absence)
{
if (!Carbon::parse($absence->predicted_end)->isFuture()) {
$this->endAbsence($absence);
}
}
}
}

View File

@@ -16,18 +16,14 @@ class AccountSuspensionService
/**
* Suspends a user account, with given $reason.
* Permanent if no duration given.
*
* This method will take the target user and add a suspension to the database,
* effectively banning the user from the app. Suspensions may be temporary or permanent.
* Suspensions also block registration attempts.
*
* @param string $reason Suspension reason.
* @param string $duration Duration. This is a timestamp.
* @param User $target Who to suspend.
* @param string $type Permanent or temporary?
* @param string $reason Suspension reason.
* @param int|null $duration Duration in days
* @return Ban The ban itself
*/
public function suspend($reason, $duration, User $target, $type = "on"): Ban {
public function suspend(User $target, string $reason, int $duration = null): Ban {
Log::alert("An user account has just been suspended.", [
'taget_email' => $target->email,
@@ -35,19 +31,17 @@ class AccountSuspensionService
'reason' => $reason
]);
if ($type == "on") {
if ($duration > 0) {
$expiryDate = now()->addDays($duration);
}
$ban = Ban::create([
return Ban::create([
'userID' => $target->id,
'reason' => $reason,
'bannedUntil' => ($type == "on") ? $expiryDate->format('Y-m-d H:i:s') : null,
'bannedUntil' => ($duration > 0) ? $expiryDate->format('Y-m-d H:i:s') : null,
'authorUserID' => Auth::user()->id,
'isPermanent' => ($type == "off") ? true : false
'isPermanent' => ($duration == 0) ? true : false
]);
return $ban;
}
/**
@@ -64,6 +58,16 @@ class AccountSuspensionService
$user->bans->delete();
}
/**
* Checks whether a user is suspended
*
* @param User $user The user to check
* @return bool Whether the mentioned user is suspended
*/
public function isSuspended(User $user): bool {
return !is_null($user->bans);
}
@@ -107,19 +111,6 @@ class AccountSuspensionService
return $user->save();
}
/**
* Checks whether a user is suspended
*
* @param User $user The user to check
* @return bool Whether the mentioned user is suspended
*/
public function isSuspended(User $user): bool {
return !is_null($user->bans);
}
/**
* Checks whether an account is locked
*
@@ -131,21 +122,21 @@ class AccountSuspensionService
}
/**
* Takes a suspension directly and makes it permanent.
* Retrieves the reason for the user's suspension.
*
* @param Ban $ban The suspension to make permanent
* @param User $user The user account to check
* @return string|bool Reason for the suspension, false if not suspended
*/
public function makePermanent(Ban $ban): void {
public function getSuspensionReason(User $user): string|bool {
return ($this->isSuspended($user)) ? $user->bans->reason : false;
}
Log::alert('A suspension has just been made permanent.', [
'target_email' => $ban->user->email
]);
$ban->bannedUntil = null;
$ban->isPermanent = true;
$ban->save();
public function getSuspensionDuration(User $user): string|null {
if ($this->isSuspended($user) && !is_null($user->bans->bannedUntil)) {
return $user->bans->bannedUntil->diffForHumans();
}
return null;
}
/**

View File

@@ -3,7 +3,11 @@
namespace App\Services;
use App\Exceptions\DiscordAccountRequiredException;
use App\Exceptions\IncompatibleAgeException;
use App\Exceptions\InvalidAgeException;
use App\Notifications\ApplicationConfirmed;
use Carbon\Carbon;
use ContextAwareValidator;
use App\Application;
use App\Events\ApplicationDeniedEvent;
@@ -22,12 +26,27 @@ use Illuminate\Support\Facades\Log;
class ApplicationService
{
/**
* @throws DiscordAccountRequiredException
* @throws IncompatibleAgeException
* @throws InvalidAgeException
*/
public function renderForm($vacancySlug)
{
$vacancyWithForm = Vacancy::with('forms')->where('vacancySlug', $vacancySlug)->get();
$firstVacancy = $vacancyWithForm->first();
if (is_null(Auth::user()->dob)) {
throw new InvalidAgeException("User must have added their age to apply for this vacancy.");
} elseif(Carbon::parse(Auth::user()->dob)->age < $firstVacancy->requiredAge) {
throw new IncompatibleAgeException("Sorry, you must be {$firstVacancy->requiredAge} or older to apply to {$firstVacancy->vacancyName}.");
}
if ($firstVacancy->requiresDiscord && !Auth::user()->hasDiscordConnection()) {
throw new DiscordAccountRequiredException('A discord account is required beyond this point.');
}
if (!$vacancyWithForm->isEmpty() && $firstVacancy->vacancyCount !== 0 && $firstVacancy->vacancyStatus == 'OPEN') {
return view('dashboard.application-rendering.apply')
->with([
@@ -36,7 +55,7 @@ class ApplicationService
]);
} else {
throw new ApplicationNotFoundException('The application you\'re looking for could not be found or it is currently unavailable.', 404);
throw new ApplicationNotFoundException(__('The application you\'re looking for could not be found or it is currently unavailable.'), 404);
}
}
@@ -94,11 +113,12 @@ class ApplicationService
'applicant' => $applicant->name
]);
foreach (User::all() as $user) {
if ($user->hasRole('admin')) {
$user->notify((new NewApplicant($application, $vacancy->first())));
}
}
User::whereHas('roles', function ($q) {
$q->where('name', 'admin');
})->get()->each(function ($user, $key) use ($application, $vacancy) {
$user->notify((new NewApplicant($application, $vacancy->first())));
});
$application->user->notify(new ApplicationConfirmed($application));
return true;

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\User;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
class DiscordService
{
/**
* Sends a token revocation request to Discord to invalidate a specific $user's tokens.
* Please ensure you have the user set a password for their account after this, or request new tokens.
*
* @see https://www.rfc-editor.org/rfc/rfc7009
* @param User $user
* @return bool
* @throws RequestException
*/
public function revokeAccountTokens(User $user): bool
{
$req = Http::asForm()->post(config('services.discord.base_url') . '/oauth2/token/revoke', [
'client_id' => config('services.discord.client_id'),
'client_secret' => config('services.discord.client_secret'),
'token' => $user->discord_token,
])->throw();
$user->discord_token = null;
$user->discord_user_id = null;
$user->discord_refresh_token = null;
$user->discord_pfp = null;
$user->save();
return $req->ok();
}
}

View File

@@ -39,7 +39,7 @@ class FormManagementService
$deletable = true;
if (! is_null($form) && ! is_null($form->vacancies) && $form->vacancies->count() !== 0 || ! is_null($form->responses)) {
if (! is_null($form->vacancies) && $form->vacancies->count() !== 0 || ! is_null($form->responses)) {
$deletable = false;
}

View File

@@ -4,15 +4,56 @@
namespace App\Services;
use App\Exceptions\ProfileAlreadyExistsException;
use App\Exceptions\ProfileCreationFailedException;
use App\Exceptions\ProfileNotFoundException;
use App\Profile;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class ProfileService
{
/**
* Creates a new profile for the specified $targetUser.
*
* @param User $targetUser The user to create the profile for.
* @return bool
* @throws ProfileAlreadyExistsException
* @throws ProfileCreationFailedException
*/
public function createProfile(User $targetUser): Profile {
if (is_null($targetUser->profile)) {
$profile = Profile::create([
'profileShortBio' => 'Write a one-liner about you here!',
'profileAboutMe' => 'Tell us a bit about you.',
'socialLinks' => '{}',
'userID' => $targetUser->id,
]);
if (is_null($profile)) {
throw new ProfileCreationFailedException(__('Could not create profile! Please try again later.'));
}
Log::info('Created profile for new user', [
'userid' => $targetUser->id
]);
return $profile;
}
throw new ProfileAlreadyExistsException(__('Profile already exists!'));
}
/**
* Updates the user's profile.
*
* @throws ProfileNotFoundException
*/
public function updateProfile($userID, Request $request) {
@@ -47,4 +88,26 @@ class ProfileService
throw new ProfileNotFoundException("This profile does not exist.");
}
/**
* Delete specified user's profile
*
* @param User $targetUser
* @return bool
* @throws ProfileNotFoundException
*/
public function deleteProfile(User $targetUser): bool
{
if (!is_null($targetUser->profile)) {
Log::alert('Deleted user profile', [
'userid' => $targetUser->id
]);
return $targetUser->profile->delete();
}
throw new ProfileNotFoundException(__('Attempting to delete non-existant profile!'));
}
}

25
app/Traits/DisablesFeatures.php Executable file
View File

@@ -0,0 +1,25 @@
<?php
namespace App\Traits;
use Illuminate\Http\RedirectResponse;
trait DisablesFeatures
{
/**
* Checks if demo mode is active. If so, it stops any more logic from running.
*
* @return RedirectResponse|null
*/
protected function disable(): RedirectResponse|null
{
if (config('demo.is_enabled')) {
return redirect()
->back()
->with('error', __('This feature is disabled'));
}
return null;
}
}

View File

@@ -24,15 +24,21 @@ namespace App;
use App\Services\AccountSuspensionService;
use App\Traits\HandlesAccountTokens;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\View;
use Mpociot\Teamwork\Traits\UserHasTeams;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements MustVerifyEmail
{
use UserHasTeams, Notifiable, HasRoles;
use UserHasTeams, Notifiable, HasRoles, HasFactory;
/**
* The attributes that are mass assignable.
@@ -40,7 +46,19 @@ class User extends Authenticatable implements MustVerifyEmail
* @var array
*/
protected $fillable = [
'name', 'email', 'password', 'originalIP', 'username', 'uuid', 'dob',
'name',
'email',
'password',
'originalIP',
'registrationIp',
'username',
'uuid',
'dob',
'email_verified_at',
'currentIp',
'discord_user_id',
'discord_token',
'discord_refresh_token'
];
/**
@@ -49,7 +67,7 @@ class User extends Authenticatable implements MustVerifyEmail
* @var array
*/
protected $hidden = [
'password', 'remember_token',
'password', 'remember_token', 'discord_token', 'discord_refresh_token'
];
/**
@@ -59,6 +77,8 @@ class User extends Authenticatable implements MustVerifyEmail
*/
protected $casts = [
'email_verified_at' => 'datetime',
'discord_token' => 'encrypted',
'discord_refresh_token' => 'encrypted'
];
// RELATIONSHIPS
@@ -71,6 +91,7 @@ class User extends Authenticatable implements MustVerifyEmail
public function votes()
{
return $this->hasMany('App\Vote', 'userID', 'id');
}
public function profile()
@@ -99,32 +120,52 @@ class User extends Authenticatable implements MustVerifyEmail
}
public function isEligible(): bool {
$lastApplication = Application::where('applicantUserID', $this->getAttribute('id'))->latest()->first();
// UTILITY LOGIC
if (is_null($lastApplication)) {
return true;
}
/**
* Checks if a user is banned.
*
* @deprecated This method is obsolete, as it has been replaced by the suspension service.
* @see AccountSuspensionService::isSuspended()
*
* @return bool Whether the user is banned
*/
public function isBanned(): bool
{
return ! $this->bans()->get()->isEmpty();
if ($lastApplication->created_at->diffInMonths(now()) > 1 && in_array($lastApplication->applicationStatus, ['DENIED', 'APPROVED'])) {
return true;
}
return false;
}
public function isStaffMember()
public function isVerified(): bool {
return !is_null($this->email_verified_at);
}
/**
* Checks if user is staff
*
* @deprecated This method is being replaced by a better way of checking permissions, rather than checking for group name.
* @return bool
*/
public function isStaffMember(): bool
{
return $this->hasAnyRole('reviewer', 'admin', 'hiringManager');
}
public function has2FA()
/**
* Checks if user has 2fa enabled
*
* @return bool
*/
public function has2FA(): bool
{
return ! is_null($this->twofa_secret);
}
/**
* Checks if user has team
*
* @param $team
* @return bool
*/
public function hasTeam($team): bool
{
if ($team instanceof Team || is_int($team))
@@ -140,9 +181,23 @@ class User extends Authenticatable implements MustVerifyEmail
}
}
public function routeNotificationForSlack($notification)
{
return config('slack.webhook.integrationURL');
/**
* Check if user linked their Discord account
*
* @return bool
*/
public function hasDiscordConnection(): bool {
return !is_null($this->discord_token) && !is_null($this->discord_refresh_token);
}
/**
* Check if user has a password
*
* @return bool
*/
public function hasPassword(): bool {
return !is_null($this->password);
}
}

View File

@@ -44,6 +44,8 @@ class Vacancy extends Model
'vacancyStatus',
'vacancySlug',
'team_id',
'requiresDiscord',
'requiredAge'
];

View File

@@ -7,16 +7,27 @@ use Illuminate\View\Component;
class AccountStatus extends Component
{
public $user;
public bool
$isVerified,
$isSuspended,
$isLocked,
$has2FA,
$hasDiscord,
$hasPassword;
/**
* Create a new component instance.
*
* @return void
*/
public function __construct($userId)
public function __construct($isVerified, $isSuspended, $isLocked, $has2FA, $hasDiscord, $hasPassword)
{
$this->user = User::findOrFail($userId);
$this->isVerified = $isVerified;
$this->isSuspended = $isSuspended;
$this->isLocked = $isLocked;
$this->has2FA = $has2FA;
$this->hasDiscord = $hasDiscord;
$this->hasPassword = $hasPassword;
}
/**

View File

@@ -0,0 +1,14 @@
<?php
namespace App\View\Components;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class ConfirmPassword extends Component
{
public function render(): View
{
return view('components.confirm-password');
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\View\Components;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class ConfirmSecondFactor extends Component
{
public function render(): View
{
return view('components.confirm-second-factor');
}
}