Merge remote-tracking branch 'upstream/develop' into impl/results-per-page

This commit is contained in:
IsWaffle 2024-10-01 08:47:39 -04:00
commit de63309649
38 changed files with 1381 additions and 103 deletions

View File

@ -92,3 +92,8 @@ GITEA_TOKEN=
# API key for Scribe documentation.
SCRIBE_AUTH_KEY=
# Discord OAuth Credentials
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=${APP_URL}/login/discord/callback

View File

@ -45,3 +45,8 @@ MAIL_FROM_NAME="${APP_NAME}"
# API key for Scribe documentation.
SCRIBE_AUTH_KEY=
# Discord OAuth Credentials
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=${APP_URL}/login/discord/callback

4
.gitignore vendored
View File

@ -16,6 +16,10 @@ public/build
public/hot
public/storage
storage/*.key
storage/app/livewire-tmp
storage/app/public
!storage/app/public/cover-photos/.gitkeep
!storage/app/public/profile-photos/.gitkeep
vendor
auth.json
frankenphp

View File

@ -20,7 +20,7 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'name' => ['required', 'string', 'max:36', 'unique:users'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',

View File

@ -16,7 +16,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'name' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
'cover' => ['nullable', 'mimes:jpg,jpeg,png', 'max:2048'],

View File

@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers;
use App\Models\OAuthConnection;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as ProviderUser;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
class SocialiteController extends Controller
{
/**
* The providers that are supported.
*/
protected array $providers = ['discord'];
/**
* Redirect the user to the provider's authentication page.
*/
public function redirect(string $provider): SymfonyRedirectResponse
{
if (! in_array($provider, $this->providers)) {
return redirect()->route('login')->withErrors(__('Unsupported OAuth provider.'));
}
$socialiteProvider = Socialite::driver($provider);
if (method_exists($socialiteProvider, 'scopes')) {
return $socialiteProvider->scopes(['identify', 'email'])->redirect();
}
return $socialiteProvider->redirect();
}
/**
* Obtain the user information from the provider.
*/
public function callback(string $provider): RedirectResponse
{
if (! in_array($provider, $this->providers)) {
return redirect()->route('login')->withErrors(__('Unsupported OAuth provider.'));
}
try {
$providerUser = Socialite::driver($provider)->user();
} catch (\Exception $e) {
return redirect()->route('login')->withErrors('Unable to login using '.$provider.'. Please try again.');
}
$user = $this->findOrCreateUser($provider, $providerUser);
Auth::login($user, remember: true);
return redirect()->route('dashboard');
}
protected function findOrCreateUser(string $provider, ProviderUser $providerUser): User
{
$oauthConnection = OAuthConnection::whereProvider($provider)
->whereProviderId($providerUser->getId())
->first();
if ($oauthConnection) {
$oauthConnection->update([
'token' => $providerUser->token ?? '',
'refresh_token' => $providerUser->refreshToken ?? '',
'nickname' => $providerUser->getNickname() ?? '',
'name' => $providerUser->getName() ?? '',
'email' => $providerUser->getEmail() ?? '',
'avatar' => $providerUser->getAvatar() ?? '',
]);
return $oauthConnection->user;
}
// If the username already exists in the database, append a random string to it to ensure uniqueness.
$username = $providerUser->getName() ?? $providerUser->getNickname();
$random = '';
while (User::whereName($username.$random)->exists()) {
$random = '-'.Str::random(5);
}
$username .= $random;
// The user has not connected their account with this OAuth provider before, so a new connection needs to be
// established. Check if the user has an account with the same email address that's passed in from the provider.
// If one exists, connect that account. Otherwise, create a new one.
return DB::transaction(function () use ($providerUser, $provider, $username) {
$user = User::firstOrCreate(['email' => $providerUser->getEmail()], [
'name' => $username,
'password' => null,
]);
$connection = $user->oAuthConnections()->create([
'provider' => $provider,
'provider_id' => $providerUser->getId(),
'token' => $providerUser->token ?? '',
'refresh_token' => $providerUser->refreshToken ?? '',
'nickname' => $providerUser->getNickname() ?? '',
'name' => $providerUser->getName() ?? '',
'email' => $providerUser->getEmail() ?? '',
'avatar' => $providerUser->getAvatar() ?? '',
]);
$this->updateAvatar($user, $connection->avatar);
return $user;
});
}
private function updateAvatar(User $user, string $avatarUrl): void
{
// Determine the disk to use based on the environment.
$disk = match (config('app.env')) {
'production' => 'r2', // Cloudflare R2 Storage
default => 'public', // Local
};
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_URL, $avatarUrl);
$image = curl_exec($curl);
if ($image === false) {
Log::error('There was an error attempting to download the image. cURL error: '.curl_error($curl));
return;
}
// Generate a random path for the image and ensure that it doesn't already exist.
do {
$relativePath = User::profilePhotoStoragePath().'/'.Str::random(40).'.webp';
} while (Storage::disk($disk)->exists($relativePath));
// Store the image on the disk.
Storage::disk($disk)->put($relativePath, $image);
// Update the user's profile photo path.
$user->forceFill([
'profile_photo_path' => $relativePath,
])->save();
}
}

View File

@ -17,6 +17,11 @@ class UserController extends Controller
->firstOrFail();
$mods = $user->mods()
->with([
'users',
'latestVersion',
'latestVersion.latestSptVersion',
])
->orderByDesc('created_at')
->paginate(10)
->fragment('mods');

View File

@ -392,7 +392,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
$hashShort = substr($avatar->fileHash, 0, 2);
$fileName = $avatar->fileHash.'.'.$avatar->avatarExtension;
$hubUrl = 'https://hub.sp-tarkov.com/images/avatars/'.$hashShort.'/'.$avatar->avatarID.'-'.$fileName;
$relativePath = 'user-avatars/'.$fileName;
$relativePath = User::profilePhotoStoragePath().'/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Livewire\Profile;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
class ManageOAuthConnections extends Component
{
use AuthorizesRequests;
/**
* Store the current user.
*/
#[Locked]
public $user;
/**
* Controls the confirmation modal visibility.
*/
public $confirmingConnectionDeletion = false;
/**
* Stores the ID of the connection to be deleted.
*/
#[Locked]
public $selectedConnectionId;
/**
* Initializes the component by loading the user's OAuth connections.
*/
public function mount(): void
{
$this->setName('profile.manage-oauth-connections');
$this->user = auth()->user();
}
/**
* Sets up the deletion confirmation.
*/
public function confirmConnectionDeletion($connectionId): void
{
$this->confirmingConnectionDeletion = true;
$this->selectedConnectionId = $connectionId;
}
/**
* Deletes the selected OAuth connection.
*/
public function deleteConnection(): void
{
$connection = $this->user->oauthConnections()->find($this->selectedConnectionId);
// Ensure the user is authorized to delete the connection.
$this->authorize('delete', $connection);
// The user must have a password set before removing an OAuth connection.
if ($this->user->password === null) {
$this->addError('password_required', __('You must set a password before removing an OAuth connection.'));
$this->confirmingConnectionDeletion = false;
return;
}
if ($connection) {
$connection->delete();
$this->user->refresh();
$this->confirmingConnectionDeletion = false;
$this->selectedConnectionId = null;
session()->flash('status', __('OAuth connection removed successfully.'));
} else {
session()->flash('error', __('OAuth connection not found.'));
}
}
/**
* Refreshes the user instance.
*/
#[On('saved')]
public function refreshUser(): void
{
$this->user->refresh();
}
/**
* Renders the component view.
*/
public function render(): View
{
return view('livewire.profile.manage-oauth-connections');
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Profile;
use App\Actions\Fortify\PasswordValidationRules;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
use Laravel\Jetstream\Http\Livewire\UpdatePasswordForm as JetstreamUpdatePasswordForm;
use Override;
class UpdatePasswordForm extends JetstreamUpdatePasswordForm
{
use PasswordValidationRules;
/**
* Update the user's password.
*
* This method has been overwritten to allow a user that has a null password to set a password for their account
* without needing to provide their current password. This is useful for users that have been created using OAuth.
*/
#[Override]
public function updatePassword(UpdatesUserPasswords $updater): void
{
$this->resetErrorBag();
$user = Auth::user();
if ($user->password !== null) {
parent::updatePassword($updater);
} else {
// User has a null password. Allow them to set a new password without their current password.
Validator::make($this->state, [
'password' => $this->passwordRules(),
])->validateWithBag('updatePassword');
auth()->user()->forceFill([
'password' => Hash::make($this->state['password']),
])->save();
$this->state = [
'current_password' => '',
'password' => '',
'password_confirmation' => '',
];
$this->dispatch('saved');
}
}
}

View File

@ -3,7 +3,6 @@
namespace App\Livewire\User;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
@ -40,12 +39,16 @@ class FollowCard extends Component
public string $dialogTitle;
/**
* The users to display in the card.
*
* @var Collection<User>
* The user data to display in the card.
*/
#[Locked]
public Collection $followUsers;
public array $display = [];
/**
* The limited user data to display in the card.
*/
#[Locked]
public array $displayLimit = [];
/**
* The maximum number of users to display on the card.
@ -64,6 +67,12 @@ class FollowCard extends Component
#[Locked]
public User $profileUser;
/**
* The number of users being displayed.
*/
#[Locked]
public int $followUsersCount;
/**
* Called when the component is initialized.
*/
@ -130,18 +139,31 @@ class FollowCard extends Component
public function populateFollowUsers(): void
{
// Fetch IDs of all users the authenticated user is following.
$followingIds = auth()->user()->following()->pluck('following_id');
$followingIds = collect();
$authUser = auth()->user();
if ($authUser) {
$followingIds = $authUser->following()->pluck('following_id');
}
// Load the profile user's followers (or following) and map the follow status.
$this->followUsers = $this->profileUser->{$this->relationship}
// Load the profile user's followers (or following).
$users = $this->profileUser->{$this->relationship}()->with([])->get();
// Count the number of users.
$this->followUsersCount = $users->count();
// Load the users to display and whether the authenticated user is following each user.
$this->display = $users
->map(function (User $user) use ($followingIds) {
// Add the follow status based on the preloaded IDs.
$user->follows = $followingIds->contains($user->id);
return [
'user' => $user,
'isFollowing' => $followingIds->contains($user->id),
];
})->toArray();
// TODO: The above follows property doesn't exist on the User model. What was I smoking?
return $user;
});
// Store limited users for the main view.
$this->displayLimit = collect($this->display)
->take($this->limit)
->toArray();
}
/**

View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OAuthConnection extends Model
{
use HasFactory;
protected $table = 'oauth_connections';
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
@ -43,6 +44,14 @@ class User extends Authenticatable implements MustVerifyEmail
'profile_photo_url',
];
/**
* Get the storage path for profile photos.
*/
public static function profilePhotoStoragePath(): string
{
return 'profile-photos';
}
/**
* The relationship between a user and their mods.
*
@ -211,7 +220,15 @@ class User extends Authenticatable implements MustVerifyEmail
}
/**
* Handle the about default value if empty. Thanks MySQL!
* The relationship between a user and their OAuth providers.
*/
public function oAuthConnections(): HasMany
{
return $this->hasMany(OAuthConnection::class);
}
/**
* Handle the about default value if empty. Thanks, MySQL!
*/
protected function about(): Attribute
{

View File

@ -20,6 +20,8 @@ class ModObserver
*/
public function saved(Mod $mod): void
{
$mod->load('versions.sptVersions');
foreach ($mod->versions as $modVersion) {
$this->dependencyVersionService->resolve($modVersion);
}
@ -44,6 +46,8 @@ class ModObserver
*/
public function deleted(Mod $mod): void
{
$mod->load('versions.sptVersions');
$this->updateRelatedSptVersions($mod);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Policies;
use App\Models\OAuthConnection;
use App\Models\User;
class OAuthConnectionPolicy
{
/**
* Determine whether the user can view the model.
*/
public function view(User $user, OAuthConnection $oauthConnection): bool
{
return $user->id === $oauthConnection->user_id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, OAuthConnection $oauthConnection): bool
{
return $user->id === $oauthConnection->user_id && $user->password !== null;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Livewire\Profile\UpdatePasswordForm;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
@ -13,9 +14,13 @@ use App\Observers\ModVersionObserver;
use App\Observers\SptVersionObserver;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Number;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use SocialiteProviders\Discord\Provider;
use SocialiteProviders\Manager\SocialiteWasCalled;
class AppServiceProvider extends ServiceProvider
{
@ -35,15 +40,28 @@ class AppServiceProvider extends ServiceProvider
// Allow mass assignment for all models. Be careful!
Model::unguard();
// Disable lazy loading in non-production environments.
Model::preventLazyLoading(! app()->isProduction());
// Register model observers.
$this->registerObservers();
// Register custom macros.
$this->registerNumberMacros();
$this->registerCarbonMacros();
// Register Livewire component overrides.
$this->registerLivewireOverrides();
// This gate determines who can access the Pulse dashboard.
Gate::define('viewPulse', function (User $user) {
return $user->isAdmin();
});
// Register the Discord socialite provider.
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite('discord', Provider::class);
});
}
/**
@ -90,4 +108,12 @@ class AppServiceProvider extends ServiceProvider
return $date->format('M jS, g:i A');
});
}
/**
* Register Livewire component overrides.
*/
private function registerLivewireOverrides(): void
{
Livewire::component('profile.update-password-form', UpdatePasswordForm::class);
}
}

View File

@ -6,4 +6,5 @@ return [
App\Providers\HorizonServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
\SocialiteProviders\Manager\ServiceProvider::class,
];

View File

@ -19,12 +19,14 @@
"laravel/pulse": "^1.2",
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.10",
"laravel/socialite": "^5.16",
"laravel/tinker": "^2.9",
"league/flysystem-aws-s3-v3": "^3.28",
"league/html-to-markdown": "^5.1",
"livewire/livewire": "^3.5",
"mchev/banhammer": "^2.3",
"meilisearch/meilisearch-php": "^1.8",
"socialiteproviders/discord": "^4.2",
"stevebauman/purify": "^6.2"
},
"require-dev": {

497
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "645a73e7a66339c2396753a2f35c5eea",
"content-hash": "2e603ffae8f6f8a4c834c9bff55cc30f",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@ -1867,6 +1867,69 @@
},
"time": "2024-09-23T14:10:16+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v6.10.1",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "500501c2ce893c824c801da135d02661199f60c5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5",
"reference": "500501c2ce893c824c801da135d02661199f60c5",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v6.10.1"
},
"time": "2024-05-18T18:05:11+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.3.0",
@ -3473,6 +3536,78 @@
},
"time": "2024-08-02T07:48:17+00:00"
},
{
"name": "laravel/socialite",
"version": "v5.16.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
"reference": "40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/socialite/zipball/40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf",
"reference": "40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf",
"shasum": ""
},
"require": {
"ext-json": "*",
"firebase/php-jwt": "^6.4",
"guzzlehttp/guzzle": "^6.0|^7.0",
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"league/oauth1-client": "^1.10.1",
"php": "^7.2|^8.0",
"phpseclib/phpseclib": "^3.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8.0|^9.3|^10.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.x-dev"
},
"laravel": {
"providers": [
"Laravel\\Socialite\\SocialiteServiceProvider"
],
"aliases": {
"Socialite": "Laravel\\Socialite\\Facades\\Socialite"
}
}
},
"autoload": {
"psr-4": {
"Laravel\\Socialite\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.",
"homepage": "https://laravel.com",
"keywords": [
"laravel",
"oauth"
],
"support": {
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
"time": "2024-09-03T09:46:57+00:00"
},
{
"name": "laravel/tinker",
"version": "v2.9.0",
@ -4147,6 +4282,82 @@
],
"time": "2024-09-21T08:32:55+00:00"
},
{
"name": "league/oauth1-client",
"version": "v1.10.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth1-client.git",
"reference": "d6365b901b5c287dd41f143033315e2f777e1167"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/d6365b901b5c287dd41f143033315e2f777e1167",
"reference": "d6365b901b5c287dd41f143033315e2f777e1167",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
"guzzlehttp/psr7": "^1.7|^2.0",
"php": ">=7.1||>=8.0"
},
"require-dev": {
"ext-simplexml": "*",
"friendsofphp/php-cs-fixer": "^2.17",
"mockery/mockery": "^1.3.3",
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5||9.5"
},
"suggest": {
"ext-simplexml": "For decoding XML-based responses."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev",
"dev-develop": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"League\\OAuth1\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ben Corlett",
"email": "bencorlett@me.com",
"homepage": "http://www.webcomm.com.au",
"role": "Developer"
}
],
"description": "OAuth 1.0 Client Library",
"keywords": [
"Authentication",
"SSO",
"authorization",
"bitbucket",
"identity",
"idp",
"oauth",
"oauth1",
"single sign on",
"trello",
"tumblr",
"twitter"
],
"support": {
"issues": "https://github.com/thephpleague/oauth1-client/issues",
"source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.1"
},
"time": "2022-04-15T14:02:14+00:00"
},
{
"name": "league/uri",
"version": "7.4.1",
@ -5390,6 +5601,56 @@
},
"time": "2024-05-08T12:36:18+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "php-http/discovery",
"version": "1.19.4",
@ -5544,6 +5805,116 @@
],
"time": "2024-07-20T21:41:07+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "3.0.42",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db92f1b1987b12b13f248fe76c3a52cadb67bb98",
"reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.42"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2024-09-16T03:06:04+00:00"
},
{
"name": "pragmarx/google2fa",
"version": "v8.0.3",
@ -6439,6 +6810,130 @@
],
"time": "2024-02-26T18:08:49+00:00"
},
{
"name": "socialiteproviders/discord",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Discord.git",
"reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/c71c379acfdca5ba4aa65a3db5ae5222852a919c",
"reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.4 || ^8.0",
"socialiteproviders/manager": "~4.0"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Discord\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christopher Eklund",
"email": "eklundchristopher@gmail.com"
}
],
"description": "Discord OAuth2 Provider for Laravel Socialite",
"keywords": [
"discord",
"laravel",
"oauth",
"provider",
"socialite"
],
"support": {
"docs": "https://socialiteproviders.com/discord",
"issues": "https://github.com/socialiteproviders/providers/issues",
"source": "https://github.com/socialiteproviders/providers"
},
"time": "2023-07-24T23:28:47+00:00"
},
{
"name": "socialiteproviders/manager",
"version": "v4.6.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Manager.git",
"reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/dea5190981c31b89e52259da9ab1ca4e2b258b21",
"reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21",
"shasum": ""
},
"require": {
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0",
"laravel/socialite": "^5.5",
"php": "^8.0"
},
"require-dev": {
"mockery/mockery": "^1.2",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"SocialiteProviders\\Manager\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SocialiteProviders\\Manager\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andy Wendt",
"email": "andy@awendt.com"
},
{
"name": "Anton Komarev",
"email": "a.komarev@cybercog.su"
},
{
"name": "Miguel Piedrafita",
"email": "soy@miguelpiedrafita.com"
},
{
"name": "atymic",
"email": "atymicq@gmail.com",
"homepage": "https://atymic.dev"
}
],
"description": "Easily add new or override built-in providers in Laravel Socialite.",
"homepage": "https://socialiteproviders.com",
"keywords": [
"laravel",
"manager",
"oauth",
"providers",
"socialite"
],
"support": {
"issues": "https://github.com/socialiteproviders/manager/issues",
"source": "https://github.com/socialiteproviders/manager"
},
"time": "2024-05-04T07:57:39+00:00"
},
{
"name": "spatie/color",
"version": "1.6.0",

View File

@ -40,4 +40,12 @@ return [
'token' => env('GITEA_TOKEN', ''),
],
'discord' => [
'client_id' => env('DISCORD_CLIENT_ID'),
'client_secret' => env('DISCORD_CLIENT_SECRET'),
'redirect' => env('DISCORD_REDIRECT_URI'),
'allow_gif_avatars' => (bool) env('DISCORD_AVATAR_GIF', true),
'avatar_default_extension' => env('DISCORD_EXTENSION_DEFAULT', 'webp'),
],
];

View File

@ -5,7 +5,6 @@ namespace Database\Factories;
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use App\Support\Version;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
@ -18,20 +17,9 @@ class ModVersionFactory extends Factory
public function definition(): array
{
$versionString = $this->faker->numerify('#.#.#');
try {
$version = new Version($versionString);
} catch (\Exception $e) {
$version = new Version('0.0.0');
}
return [
'mod_id' => Mod::factory(),
'version' => $versionString,
'version_major' => $version->getMajor(),
'version_minor' => $version->getMinor(),
'version_patch' => $version->getPatch(),
'version_pre_release' => $version->getPreRelease(),
'version' => $this->faker->numerify('#.#.#'),
'description' => fake()->text(),
'link' => fake()->url(),

View File

@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\OAuthConnection;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class OAuthConnectionFactory extends Factory
{
protected $model = OAuthConnection::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'provider_name' => $this->faker->randomElement(['discord', 'google', 'facebook']),
'provider_id' => (string) $this->faker->unique()->numberBetween(100000, 999999),
'token' => Str::random(40),
'refresh_token' => Str::random(40),
'created_at' => now(),
'updated_at' => now(),
];
}
}

View File

@ -3,7 +3,6 @@
namespace Database\Factories;
use App\Models\SptVersion;
use App\Support\Version;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
@ -16,19 +15,8 @@ class SptVersionFactory extends Factory
public function definition(): array
{
$versionString = $this->faker->numerify('#.#.#');
try {
$version = new Version($versionString);
} catch (\Exception $e) {
$version = new Version('0.0.0');
}
return [
'version' => $versionString,
'version_major' => $version->getMajor(),
'version_minor' => $version->getMinor(),
'version_patch' => $version->getPatch(),
'version_pre_release' => $version->getPreRelease(),
'version' => $this->faker->numerify('#.#.#'),
'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']),
'link' => $this->faker->url,
'created_at' => Carbon::now(),

View File

@ -14,14 +14,12 @@ return new class extends Migration
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->bigInteger('hub_id')
->nullable()
->default(null)
->unique();
$table->bigInteger('hub_id')->nullable()->default(null)->unique();
$table->unsignedBigInteger('discord_id')->nullable()->default(null)->unique();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('password')->nullable();
$table->longText('about')->nullable()->default(null);
$table->foreignIdFor(UserRole::class)
->nullable()

View File

@ -0,0 +1,42 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_connections', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)
->constrained('users')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->string('provider');
$table->string('provider_id');
$table->string('token')->default('');
$table->string('refresh_token')->default('');
$table->string('nickname')->default('');
$table->string('name')->default('');
$table->string('email')->default('');
$table->string('avatar')->default('');
$table->timestamps();
$table->unique(['provider', 'provider_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_providers');
}
};

View File

@ -44,5 +44,23 @@
</x-button>
</div>
</form>
@if (config('services.discord.client_id') && config('services.discord.client_secret'))
<div class="relative my-6">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-300 dark:border-gray-700"></div>
</div>
<div class="relative flex justify-center">
<span class="bg-white dark:bg-black px-2 text-sm text-gray-500 dark:text-gray-500">OR</span>
</div>
</div>
<a href="{{ route('login.socialite', ['provider' => 'discord']) }}" class="w-full text-white bg-[#5865F2] hover:bg-[#5865F2]/90 focus:ring-4 focus:outline-none focus:ring-[#5865F2]/50 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center dark:focus:ring-[#5865F2]/55 me-2 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="w-4 h-4 mr-2" viewBox="0 0 16 16">
<path d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612"/>
</svg>
{{ __('Login with Discord') }}
</a>
@endif
</x-authentication-card>
</x-guest-layout>

View File

@ -0,0 +1,95 @@
<x-action-section>
<x-slot name="title">
{{ __('Connected Accounts') }}
</x-slot>
<x-slot name="description">
{{ __('Manage your connected OAuth accounts.') }}
</x-slot>
<x-slot name="content">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __('You can manage your OAuth connections here') }}
</h3>
@if ($user->password === null)
<div class="mt-3 max-w-xl text-sm text-gray-600 dark:text-gray-400">
<p>{{ __('Before you can remove a connection you must have an account password set.') }}</p>
</div>
@endif
@if (session()->has('status'))
<div class="mt-3 font-medium text-sm text-green-600 dark:text-green-400">
{{ session('status') }}
</div>
@endif
@if (session()->has('error'))
<div class="mt-3 font-medium text-sm text-red-600 dark:text-red-400">
{{ session('error') }}
</div>
@endif
<div class="mt-5 space-y-6">
@forelse ($user->oauthConnections as $connection)
<div class="flex items-center text-gray-600 dark:text-gray-400">
<div>
@switch ($connection->provider)
@case ('discord')
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="w-4 h-4" viewBox="0 0 16 16">
<path d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612"/>
</svg>
@break
@default
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
@endswitch
</div>
<div class="ms-3">
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ ucfirst($connection->provider) }} - {{ $connection->name }} - {{ $connection->email }}
</div>
<div class="text-xs text-gray-500">
{{ __('Connected') }} {{ $connection->created_at->format('M d, Y') }}
</div>
</div>
<div class="ms-auto">
@can('delete', $connection)
<x-danger-button wire:click="confirmConnectionDeletion({{ $connection->id }})" wire:loading.attr="disabled">
{{ __('Remove') }}
</x-danger-button>
@endcan
</div>
</div>
@empty
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ __('You have no connected accounts.') }}
</div>
@endforelse
</div>
<!-- Confirmation Modal -->
<x-dialog-modal wire:model="confirmingConnectionDeletion">
<x-slot name="title">
{{ __('Remove Connected Account') }}
</x-slot>
<x-slot name="content">
{{ __('Are you sure you want to remove this connected account? This action cannot be undone.') }}
</x-slot>
<x-slot name="footer">
<x-secondary-button wire:click="$toggle('confirmingConnectionDeletion')" wire:loading.attr="disabled">
{{ __('Cancel') }}
</x-secondary-button>
<x-danger-button class="ms-3" wire:click="deleteConnection" wire:loading.attr="disabled">
{{ __('Remove') }}
</x-danger-button>
</x-slot>
</x-dialog-modal>
</x-slot>
</x-action-section>

View File

@ -4,37 +4,37 @@
<h2 class="text-2xl">{{ $title }}</h2>
</div>
@if (! $followUsers->count())
@if ($followUsersCount === 0)
<div class="flex justify-center text-sm pt-2">
{{ $emptyMessage }}
</div>
@else
<div class="flex ml-6 py-2 justify-center items-center">
@foreach ($followUsers->slice(0, $limit) as $user)
@foreach ($displayLimit as $data)
{{-- User Badge --}}
<div class="relative group">
<a href="{{ $user->profileUrl() }}" class="rounded-full -ml-6 z-20 bg-[#ebf4ff] h-16 w-16 flex justify-center items-center border">
<img src="{{ $user->profile_photo_url }}" alt="{{ $user->name }}" class="h-full w-full rounded-full" />
<a href="{{ $data['user']->profileUrl() }}" class="rounded-full -ml-6 z-20 bg-[#ebf4ff] h-16 w-16 flex justify-center items-center border">
<img src="{{ $data['user']->profile_photo_url }}" alt="{{ $data['user']->name }}" class="h-full w-full rounded-full" />
</a>
<div class="absolute bottom-full -ml-3 left-1/2 transform -translate-x-1/2 mb-2 w-max px-2 py-1 text-sm text-white bg-gray-700 rounded shadow-lg opacity-0 group-hover:opacity-100">
{{ $user->name }}
{{ $data['user']->name }}
</div>
</div>
@endforeach
@if ($followUsers->count() > $limit)
@if ($followUsersCount > $limit)
{{-- Count Badge --}}
<div class="relative group">
<button wire:click="toggleFollowDialog" class="rounded-full -ml-6 z-20 bg-cyan-500 dark:bg-cyan-700 h-16 w-16 flex justify-center items-center border text-white">+{{ $followUsers->count() - $limit }}</button>
<button wire:click="toggleFollowDialog" class="rounded-full -ml-6 z-20 bg-cyan-500 dark:bg-cyan-700 h-16 w-16 flex justify-center items-center border text-white">+{{ $followUsersCount - $limit }}</button>
<div class="absolute bottom-full -ml-3 left-1/2 transform -translate-x-1/2 mb-2 w-max px-2 py-1 text-sm text-white bg-gray-700 rounded shadow-lg opacity-0 group-hover:opacity-100">
{{ $followUsers->count() }} total
{{ $followUsersCount }} total
</div>
</div>
@endif
</div>
@endif
@if ($followUsers->count() > $limit)
@if ($followUsersCount > $limit)
{{-- View All Button --}}
<div class="flex justify-center items-center">
<button wire:click="toggleFollowDialog" class="hover:underline active:underline">View All</button>
@ -49,28 +49,27 @@
</x-slot>
<x-slot name="content">
<div class="h-96 overflow-y-auto">
@foreach ($followUsers as $user)
@foreach ($display as $data)
<div class="flex group/item dark:hover:bg-gray-950 items-center p-2 pr-3 rounded-md">
<a href="{{ $user->profileUrl() }}" class="flex-shrink-0 w-16 h-16 items-center">
<img src="{{ $user->profile_photo_url }}" alt="{{ $user->name }}" class="block w-full h-full rounded-full" />
<a href="{{ $data['user']->profileUrl() }}" class="flex-shrink-0 w-16 h-16 items-center">
<img src="{{ $data['user']->profile_photo_url }}" alt="{{ $data['user']->name }}" class="block w-full h-full rounded-full" />
</a>
<div class="flex flex-col w-full pl-3">
<a href="{{ $user->profileUrl() }}" class="text-2xl group-hover/item:underline group-hover/item:text-white">{{ $user->name }}</a>
<a href="{{ $data['user']->profileUrl() }}" class="text-2xl group-hover/item:underline group-hover/item:text-white">{{ $data['user']->name }}</a>
<span>
{{ __("Member Since") }}
<x-time :datetime="$user->created_at" />
<x-time :datetime="$data['user']->created_at" />
</span>
</div>
@if (auth()->check() && auth()->user()->id !== $user->id)
<livewire:user.follow-buttons :profile-user-id="$user->id" :is-following="$user->follows" />
@if (auth()->check() && auth()->user()->id !== $data['user']->id)
<livewire:user.follow-buttons :profile-user-id="$data['user']->id" :is-following="$data['isFollowing']" />
@endif
</div>
@endforeach
</div>
</x-slot>
<x-slot name="footer">
<x-button x-on:click="show = false">
{{ __('Close') }}

View File

@ -29,6 +29,12 @@
<x-section-border />
@endif
{{-- OAuth Management --}}
<div class="mt-10 sm:mt-0">
@livewire('profile.manage-oauth-connections')
</div>
<x-section-border />
@if (config('session.driver') === 'database')
<div class="mt-10 sm:mt-0">
@livewire('profile.logout-other-browser-sessions-form')

View File

@ -8,11 +8,18 @@
</x-slot>
<x-slot name="form">
<div class="col-span-6 sm:col-span-4">
<x-label for="current_password" value="{{ __('Current Password') }}" />
<x-input id="current_password" type="password" class="mt-1 block w-full" wire:model="state.current_password" autocomplete="current-password" />
<x-input-error for="current_password" class="mt-2" />
</div>
@if (auth()->user()->password === null)
<div class="col-span-6 sm:col-span-4 mt-3 max-w-xl text-sm text-gray-600 dark:text-gray-400">
<p>{{ __('Your account does not have a password set. We recommend setting a password so that you can recover your account if you need to.') }}</p>
</div>
@else
<div class="col-span-6 sm:col-span-4">
<x-label for="current_password" value="{{ __('Current Password') }}" />
<x-input id="current_password" type="password" class="mt-1 block w-full" wire:model="state.current_password" autocomplete="current-password" />
<x-input-error for="current_password" class="mt-2" />
</div>
@endif
<div class="col-span-6 sm:col-span-4">
<x-label for="password" value="{{ __('New Password') }}" />

View File

@ -2,11 +2,17 @@
use App\Http\Controllers\ModController;
use App\Http\Controllers\ModVersionController;
use App\Http\Controllers\SocialiteController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth.banned'])->group(function () {
Route::controller(SocialiteController::class)->group(function () {
Route::get('/login/{provider}/redirect', 'redirect')->name('login.socialite');
Route::get('/login/{provider}/callback', 'callback');
});
Route::get('/', function () {
return view('home');
})->name('home');
@ -16,12 +22,10 @@ Route::middleware(['auth.banned'])->group(function () {
Route::get('/mod/{mod}/{slug}', 'show')->where(['mod' => '[0-9]+'])->name('mod.show');
});
// Download Link
Route::controller(ModVersionController::class)->group(function () {
Route::get('/mod/download/{mod}/{slug}/{version}', 'show')
->where([
'mod' => '[0-9]+',
'slug' => '[a-z0-9-]+',
])
->where(['mod' => '[0-9]+', 'slug' => '[a-z0-9-]+'])
->name('mod.version.download');
});

View File

@ -1,3 +0,0 @@
*
!public/
!.gitignore

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

View File

@ -2,28 +2,6 @@
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('displays homepage mod cards with the latest supported spt version number', function () {
$sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']);
$sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']);
$sptVersion3 = SptVersion::factory()->create(['version' => '3.0.0']);
$mod1 = Mod::factory()->create();
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion1->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion1->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion2->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion2->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion3->version]);
ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion3->version]);
$response = $this->get(route('home'));
$response->assertSeeInOrder(explode(' ', "$mod1->name $sptVersion3->version_formatted"));
});
it('displays the latest version on the mod detail page', function () {
$versions = [

View File

@ -30,3 +30,22 @@ test('users cannot authenticate with invalid password', function () {
$this->assertGuest();
});
test('users can authenticate using Discord', function () {
$response = $this->get(route('login.socialite', ['provider' => 'discord']));
$response->assertStatus(302);
$response->assertRedirect();
});
test('user can not authenticate using a null password', function () {
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => null,
]);
$this->assertGuest();
$response->assertSessionHasErrors('password');
});

View File

@ -0,0 +1,172 @@
<?php
use App\Livewire\Profile\UpdatePasswordForm;
use App\Models\User;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
it('creates a new user and attaches the OAuth provider when logging in via OAuth', function () {
// Mock the Socialite user
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('provider-user-id');
$socialiteUser->shouldReceive('getEmail')->andReturn('newuser@example.com');
$socialiteUser->shouldReceive('getName')->andReturn('New User');
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
$socialiteUser->shouldReceive('getAvatar')->andReturn('avatar-url');
$socialiteUser->token = 'access-token';
$socialiteUser->refreshToken = 'refresh-token';
// Mock Socialite facade
Socialite::shouldReceive('driver->user')->andReturn($socialiteUser);
// Hit the callback route
$response = $this->get('/login/discord/callback');
// Assert that the user was created
$user = User::where('email', 'newuser@example.com')->first();
expect($user)->not->toBeNull()
->and($user->name)->toBe('New User');
// Assert that the OAuth provider was attached
$oAuthConnection = $user->oAuthConnections()->whereProvider('discord')->first();
expect($oAuthConnection)->not->toBeNull()
->and($oAuthConnection->provider_id)->toBe('provider-user-id');
// Assert the user is authenticated
$this->assertAuthenticatedAs($user);
// Assert redirect to dashboard
$response->assertRedirect(route('dashboard'));
});
it('attaches a new OAuth provider to an existing user when logging in via OAuth', function () {
// Create an existing user
$user = User::factory()->create([
'email' => 'existinguser@example.com',
'name' => 'Existing User',
'password' => Hash::make('password123'),
]);
// Mock the Socialite user
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('new-provider-user-id');
$socialiteUser->shouldReceive('getEmail')->andReturn('existinguser@example.com');
$socialiteUser->shouldReceive('getName')->andReturn('Existing User Updated');
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
$socialiteUser->shouldReceive('getAvatar')->andReturn('new-avatar-url');
$socialiteUser->token = 'new-access-token';
$socialiteUser->refreshToken = 'new-refresh-token';
// Mock Socialite facade
Socialite::shouldReceive('driver->user')->andReturn($socialiteUser);
// Hit the callback route
$response = $this->get('/login/discord/callback');
// Refresh user data
$user->refresh();
// Assert that the username was not updated
expect($user->name)->toBe('Existing User')
->and($user->name)->not->toBe('Existing User Updated');
// Assert that the new OAuth provider was attached
$oauthConnection = $user->oAuthConnections()->whereProvider('discord')->first();
expect($oauthConnection)->not->toBeNull()
->and($oauthConnection->provider_id)->toBe('new-provider-user-id');
// Assert the user is authenticated
$this->assertAuthenticatedAs($user);
// Assert redirect to dashboard
$response->assertRedirect(route('dashboard'));
});
it('hides the current password field when the user has no password', function () {
// Create a user with no password
$user = User::factory()->create([
'password' => null,
]);
$this->actingAs($user);
// Visit the profile page
$response = $this->get('/user/profile');
$response->assertStatus(200);
// Assert that the current password field is not displayed
$response->assertDontSee(__('Current Password'));
});
it('shows the current password field when the user has a password', function () {
// Create a user with a password
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);
$this->actingAs($user);
// Visit the profile page
$response = $this->get('/user/profile');
$response->assertStatus(200);
// Assert that the current password field is displayed
$response->assertSee(__('Current Password'));
});
it('allows a user without a password to set a new password without entering the current password', function () {
// Create a user with a NULL password
$user = User::factory()->create([
'password' => null,
]);
$this->actingAs($user);
// Test the Livewire component
Livewire::test(UpdatePasswordForm::class)
->set('state.password', 'newpassword123')
->set('state.password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasNoErrors();
// Refresh user data
$user->refresh();
// Assert that the password is now set
expect(Hash::check('newpassword123', $user->password))->toBeTrue();
});
it('requires a user with a password to enter the current password to set a new password', function () {
$user = User::factory()->create([
'password' => Hash::make('oldpassword'),
]);
$this->actingAs($user);
// Without current password
Livewire::test(UpdatePasswordForm::class)
->set('state.password', 'newpassword123')
->set('state.password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasErrors(['current_password' => 'required']);
// With incorrect current password
Livewire::test(UpdatePasswordForm::class)
->set('state.current_password', 'wrongpassword')
->set('state.password', 'newpassword123')
->set('state.password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasErrors(['current_password']);
// With correct current password
Livewire::test(UpdatePasswordForm::class)
->set('state.current_password', 'oldpassword')
->set('state.password', 'newpassword123')
->set('state.password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasNoErrors();
$user->refresh();
expect(Hash::check('newpassword123', $user->password))->toBeTrue();
});