Replace Auth System #3

Merged
w33b merged 19 commits from auth-redo into main 2026-01-09 15:11:37 +00:00
94 changed files with 2755 additions and 1224 deletions

View File

@@ -30,9 +30,9 @@ class AutoStats extends Command
*/
public function handle()
{
PopularDaily::where('created_at', '<=', Carbon::now()->subMinutes(1440))->forceDelete();
PopularWeekly::where('created_at', '<=', Carbon::now()->subMinutes(10080))->forceDelete();
PopularMonthly::where('created_at', '<=', Carbon::now()->subMinutes(43200))->forceDelete();
PopularDaily::where('created_at', '<=', Carbon::now()->subMinutes(1440))->delete();
PopularWeekly::where('created_at', '<=', Carbon::now()->subMinutes(10080))->delete();
PopularMonthly::where('created_at', '<=', Carbon::now()->subMinutes(43200))->delete();
$this->comment('Automated Purge Stats Complete');
}

View File

@@ -35,6 +35,6 @@ class ResetUserDownloads extends Command
// Clear old downloads which have expired
UserDownload::where('created_at', '<=', Carbon::now()->subHour(6))
->forceDelete();
->delete();
}
}

View File

@@ -41,7 +41,7 @@ class AlertController extends Controller
*/
public function delete(int $alert_id): \Illuminate\Http\RedirectResponse
{
Alert::where('id', $alert_id)->forceDelete();
Alert::where('id', $alert_id)->delete();
cache()->forget('alerts');

View File

@@ -105,7 +105,7 @@ class SiteBackgroundController extends Controller
DB::beginTransaction();
$bg = SiteBackground::where('id', $id)->firstOrFail();
$bg->forceDelete();
$bg->delete();
$resolutions = [1440, 1080, 720, 640];
try {

View File

@@ -38,7 +38,7 @@ class SubtitleController extends Controller
// Clear everything
foreach($episode->subtitles as $sub) {
$sub->forceDelete();
$sub->delete();
}
if (! $request->input('subtitles')) {

View File

@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -29,7 +28,7 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);
return redirect()->intended(route('home.index', absolute: false));
}
/**

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -36,6 +35,6 @@ class ConfirmablePasswordController extends Controller
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(RouteServiceProvider::HOME);
return redirect()->intended(route('home.index', absolute: false));
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Laravel\Socialite\Facades\Socialite;
class DiscordAuthController extends Controller
{
/**
* Redirect to Discord
*/
public function redirect(): RedirectResponse
{
return Socialite::driver('discord')->redirect();
}
/**
* Callback received from Discord
*/
public function callback(): RedirectResponse
{
$discordUser = Socialite::driver('discord')->user();
$user = User::where('discord_id', $discordUser->id)->first();
if (!$user) {
// link by email if it already exists
$user = User::where('email', $discordUser->email)->first();
if ($user) {
$user->update([
'discord_id' => $discordUser->id,
'discord_avatar' => $discordUser->avatar,
]);
} else {
// Create new user
$user = User::create([
'name' => $discordUser->name,
'email' => $discordUser->email,
'discord_id' => $discordUser->id,
'discord_avatar' => $discordUser->avatar,
'password' => null,
]);
}
}
$this->checkDiscordAvatar($discordUser, $user);
$this->checkDiscordRoles($user);
Auth::login($user, true);
return redirect()->route('home.index');
}
/**
* Check if discord avatar changed
*/
private function checkDiscordAvatar(\Laravel\Socialite\Contracts\User $socialiteUser, User $user): void
{
if ($socialiteUser->avatar != $user->discord_avatar) {
$user->update(['discord_avatar' => $socialiteUser->avatar]);
}
}
/**
* Check Discord Roles if user is Patreon member
*/
private function checkDiscordRoles(User $user): void
{
// Should not ever happen
if (!$user->discord_id) {
return;
}
$guildId = config('discord.guild_id');
$response = Http::withToken(config('discord.discord_bot_token'), 'Bot')
->timeout(5)
->get("https://discord.com/api/v10/guilds/{$guildId}/members/{$user->discord_id}");
// User is not in the guild
if ($response->status() === 404) {
$user->update([
'is_patreon' => false,
]);
return;
}
// Something else failed
if ($response->failed()) {
Log::warning('Discord role check failed', [
'user_id' => $user->id,
'discord_id' => $user->discord_id,
'status' => $response->status(),
'body' => $response->body(),
]);
return;
}
$discordRoles = $response->json('roles', []);
$patreonRoles = config('discord.patreon_roles', []);
$isPatreon = false;
foreach($patreonRoles as $patreonRole)
{
if (in_array($patreonRole, $discordRoles, true)) {
$isPatreon = true;
break;
}
}
// Only update if something actually changed
if ($user->is_patreon !== $isPatreon) {
$user->update([
'is_patreon' => $isPatreon,
]);
}
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -15,7 +14,7 @@ class EmailVerificationNotificationController extends Controller
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME);
return redirect()->intended(route('home.index', absolute: false));
}
$request->user()->sendEmailVerificationNotification();

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -16,7 +15,7 @@ class EmailVerificationPromptController extends Controller
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(RouteServiceProvider::HOME)
? redirect()->intended(route('home.index', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -40,7 +41,7 @@ class NewPasswordController extends Controller
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
@@ -56,6 +57,6 @@ class NewPasswordController extends Controller
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
->withErrors(['email' => __($status)]);
}
}

View File

@@ -15,6 +15,20 @@ class PasswordController extends Controller
*/
public function update(Request $request): RedirectResponse
{
// If user logged in with Discord and has not yet a password, allow to set password
if ($request->user()->discord_id && is_null($request->user()->password))
{
$validated = $request->validateWithBag('updatePassword', [
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],

View File

@@ -39,6 +39,6 @@ class PasswordResetLinkController extends Controller
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
->withErrors(['email' => __($status)]);
}
}

View File

@@ -4,25 +4,15 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
@@ -32,7 +22,7 @@ class RegisteredUserController extends Controller
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
@@ -46,6 +36,6 @@ class RegisteredUserController extends Controller
Auth::login($user);
return redirect(RouteServiceProvider::HOME);
return redirect(route('home.index', absolute: false));
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
@@ -16,13 +15,13 @@ class VerifyEmailController extends Controller
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
return redirect()->intended(route('home.index', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
return redirect()->intended(route('home.index', absolute: false).'?verified=1');
}
}

View File

@@ -32,7 +32,7 @@ class NotificationController extends Controller
->where('id', $request->input('id'))
->firstOrFail();
$notification->forceDelete();
$notification->delete();
return redirect()->back();
}

View File

@@ -105,13 +105,11 @@ class PlaylistController extends Controller
$user = $request->user();
$playlist = Playlist::where('user_id', $user->id)->where('id', $playlist_id)->firstOrFail();
$playlist = Playlist::where('user_id', $user->id)
->where('id', $playlist_id)
->firstOrFail();
// Delete Playlist Episodes
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
// Delete Playlist
$playlist->forceDelete();
$playlist->delete();
return to_route('profile.playlists');
}
@@ -128,8 +126,14 @@ class PlaylistController extends Controller
], 404);
}
$playlist = Playlist::where('user_id', $request->user()->id)->where('id', (int) $request->input('playlist'))->firstOrFail();
PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', (int) $request->input('episode'))->forceDelete();
$playlist = Playlist::where('user_id', $request->user()->id)
->where('id', (int) $request->input('playlist'))
->firstOrFail();
PlaylistEpisode::where('playlist_id', $playlist->id)
->where('episode_id', (int) $request->input('episode'))
->delete();
$this->playlistService->reorderPositions($playlist);
return response()->json([

View File

@@ -3,12 +3,20 @@
namespace App\Http\Controllers;
use App\Models\Episode;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Models\User;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Intervention\Image\Laravel\Facades\Image;
use Conner\Tagging\Model\Tag;
@@ -17,7 +25,7 @@ class ProfileController extends Controller
/**
* Display the user page.
*/
public function index(Request $request): \Illuminate\View\View
public function index(Request $request): View
{
return view('profile.index', [
'user' => $request->user(),
@@ -27,7 +35,7 @@ class ProfileController extends Controller
/**
* Display the user's settings form.
*/
public function settings(Request $request): \Illuminate\View\View
public function settings(Request $request): View
{
$example = Episode::where('title', 'Succubus Yondara Gibo ga Kita!?')->first();
@@ -37,10 +45,33 @@ class ProfileController extends Controller
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$user = $request->user();
// Fill everything except the image
$user->fill($request->safe()->except('image'));
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
if ($request->hasFile('image')) {
$this->storeAvatar($request->file('image'), $user);
}
$user->save();
return Redirect::route('profile.settings')->with('status', 'profile-updated');
}
/**
* Display the user's watched page.
*/
public function watched(Request $request): \Illuminate\View\View
public function watched(Request $request): View
{
return view('profile.watched', [
'user' => $request->user(),
@@ -50,7 +81,7 @@ class ProfileController extends Controller
/**
* Display the user's comments page.
*/
public function comments(Request $request): \Illuminate\View\View
public function comments(Request $request): View
{
return view('profile.comments', [
'user' => $request->user(),
@@ -60,7 +91,7 @@ class ProfileController extends Controller
/**
* Display the user's likes page.
*/
public function likes(Request $request): \Illuminate\View\View
public function likes(Request $request): View
{
return view('profile.likes', [
'user' => $request->user(),
@@ -70,7 +101,7 @@ class ProfileController extends Controller
/**
* Update user settings.
*/
public function saveSettings(Request $request): \Illuminate\Http\RedirectResponse
public function saveSettings(Request $request): RedirectResponse
{
$user = $request->user();
$user->search_design = $request->input('searchDesign') == 'thumbnail';
@@ -84,7 +115,7 @@ class ProfileController extends Controller
/**
* Update user tag blacklist.
*/
public function saveBlacklist(Request $request): \Illuminate\Http\RedirectResponse
public function saveBlacklist(Request $request): RedirectResponse
{
$user = $request->user();
$tags = json_decode($request->input('tags'));
@@ -112,19 +143,60 @@ class ProfileController extends Controller
*/
public function destroy(Request $request): \Illuminate\Http\RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
// Verify password if user has password
if (!is_null($user->password)) {
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $user->id)->update(['commenter_id' => 1]);
// Delete Profile Picture
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
cache()->flush();
return Redirect::to('/');
}
/**
* Store custom user avatar.
*/
protected function storeAvatar(\Illuminate\Http\UploadedFile $file, User $user): void
{
// Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/avatars")) {
Storage::disk('public')->makeDirectory("/images/avatars");
}
// Delete old avatar if it exists
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
$filename = "images/avatars/{$user->id}.webp";
$image = Image::read($file->getRealPath())
->cover(128, 128)
->toWebp(quality: 85);
Storage::disk('public')->put($filename, $image);
$user->avatar = $filename;
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Models\Watched;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class UserController extends Controller
{
/**
* Display User Page.
*/
public function index(string $username): \Illuminate\View\View
{
$user = User::where('username', $username)
->select('id', 'username', 'global_name', 'avatar', 'created_at', 'is_patreon')
->firstOrFail();
return view('user.index', [
'user' => $user,
]);
}
/**
* Delete User.
*/
public function delete(Request $request): \Illuminate\Http\RedirectResponse
{
$user = User::where('id', $request->user()->id)->firstOrFail();
// Delete Playlist
$playlists = Playlist::where('user_id', $user->id)->get();
foreach($playlists as $playlist) {
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
$playlist->forceDelete();
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $user->id)->update(['commenter_id' => 1]);
$user->forceDelete();
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
cache()->flush();
return redirect('/');
}
}

View File

@@ -22,7 +22,7 @@ class LoginRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -80,6 +80,6 @@ class LoginRequest extends FormRequest
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip());
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -11,13 +11,26 @@ class ProfileUpdateRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['string', 'max:255'],
'email' => ['email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
'name' => ['required', 'string', 'max:255'],
'image' => [
'nullable',
'image',
'mimes:jpg,png,jpeg,webp,gif',
'max:8192'
],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@@ -28,9 +28,9 @@ class AdminCommentSearch extends Component
{
$comments = DB::table('comments')
->join('users', 'comments.commenter_id', '=', 'users.id')
->select('comments.*', 'users.username')
->select('comments.*', 'users.name')
->when($this->search !== '', fn ($query) => $query->where('comment', 'LIKE', "%$this->search%"))
->when($this->userSearch !== '', fn ($query) => $query->where('username', 'LIKE', "%$this->userSearch%"))
->when($this->userSearch !== '', fn ($query) => $query->where('name', 'LIKE', "%$this->userSearch%"))
->paginate(12);
return view('livewire.admin-comment-search', [

View File

@@ -43,10 +43,7 @@ class AdminUserSearch extends Component
$users = User::when($this->filtered !== [], fn ($query) => $query->where('id', '>=', 10000))
->when($this->patreon !== [], fn ($query) => $query->where('is_patreon', 1))
->when($this->banned !== [], fn ($query) => $query->where('is_banned', 1))
->when($this->search !== '', fn ($query) => $query->where(function($query) {
$query->where('username', 'like', '%'.$this->search.'%')
->orWhere('global_name', 'like', '%'.$this->search.'%');
}))
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->paginate(20);
return view('livewire.admin-user-search', [

View File

@@ -10,7 +10,6 @@ use Livewire\Component;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class DownloadsFree extends Component
{
@@ -51,7 +50,7 @@ class DownloadsFree extends Component
// Check timestamp
if (Carbon::parse($alreadyDownloaded->created_at)->addHours(6) <= Carbon::now()) {
// Already expired
$alreadyDownloaded->forceDelete();
$alreadyDownloaded->delete();
return;
}

View File

@@ -2,20 +2,20 @@
namespace App\Models;
//use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Jakyeru\Larascord\Traits\InteractsWithDiscord;
use Laravelista\Comments\Commenter;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Support\Facades\DB;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable, InteractsWithDiscord, Commenter;
use HasFactory, Notifiable, Commenter;
/**
* The attributes that are mass assignable.
@@ -23,22 +23,14 @@ class User extends Authenticatable
* @var string[]
*/
protected $fillable = [
'id',
'username',
'global_name',
'discriminator',
'name',
'email',
'avatar',
'verified',
'banner',
'banner_color',
'accent_color',
'password',
'locale',
'mfa_enabled',
'premium_type',
'public_flags',
'roles',
'is_banned',
// Discord
'discord_id',
'discord_avatar',
];
/**
@@ -47,6 +39,7 @@ class User extends Authenticatable
* @var array
*/
protected $hidden = [
'password',
'remember_token',
];
@@ -56,24 +49,21 @@ class User extends Authenticatable
* @var array
*/
protected $casts = [
'id' => 'integer',
'username' => 'string',
'global_name' => 'string',
'discriminator' => 'string',
// Laravel defaults
'email_verified_at' => 'datetime',
'password' => 'hashed',
// Other
'name' => 'string',
'email' => 'string',
'avatar' => 'string',
'verified' => 'boolean',
'banner' => 'string',
'banner_color' => 'string',
'accent_color' => 'string',
'locale' => 'string',
'mfa_enabled' => 'boolean',
'premium_type' => 'integer',
'public_flags' => 'integer',
'roles' => 'json',
'tag_blacklist' => 'array',
// Discord
'discord_id' => 'integer',
'discord_avatar' => 'string',
];
/**
* Has Many Playlists.
*/
@@ -105,4 +95,22 @@ class User extends Authenticatable
{
return DB::table('comments')->where('commenter_id', $this->id)->count();
}
/**
* Returns the user avatar image url.
*/
public function getAvatar(): string
{
if ($this->discord_id && $this->discord_avatar && !$this->avatar)
{
return "https://external-content.duckduckgo.com/iu/?u={$this->discord_avatar}";
}
if ($this->avatar)
{
return Storage::url($this->avatar);
}
return asset('images/default-avatar.webp');
}
}

View File

@@ -91,7 +91,7 @@ class CommentService
if (Config::get('comments.soft_deletes') == true) {
$comment->delete();
} else {
$comment->forceDelete();
$comment->delete();
}
}
@@ -122,12 +122,11 @@ class CommentService
$url = '/hentai/' . $episode->slug . '#comment-' . $reply->id;
$user = Auth::user();
$username = $user->global_name ?? $user->username;
$parentCommentUser = User::where('id', $comment->commenter_id)->firstOrFail();
$parentCommentUser->notify(
new CommentNotification(
"{$username} replied to your comment.",
"{$user->name} replied to your comment.",
Str::limit($reply->comment, 50),
$url
)

View File

@@ -1,155 +0,0 @@
<?php
namespace Jakyeru\Larascord\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Jakyeru\Larascord\Http\Requests\StoreUserRequest;
use Jakyeru\Larascord\Services\DiscordService;
use RealRashid\SweetAlert\Facades\Alert;
class DiscordController extends Controller
{
/**
* Handles the Discord OAuth2 login.
*/
public function handle(StoreUserRequest $request): RedirectResponse | JsonResponse
{
// Making sure the "guilds" scope was added to .env if there are any guilds specified in "larascord.guilds".
if (count(config('larascord.guilds'))) {
if (!in_array('guilds', explode('&', config('larascord.scopes')))) {
return $this->throwError('missing_guilds_scope');
}
}
// Getting the accessToken from the Discord API.
try {
$accessToken = (new DiscordService())->getAccessTokenFromCode($request->get('code'));
} catch (\Exception $e) {
return $this->throwError('invalid_code', $e);
}
// Get the user from the Discord API.
try {
$user = (new DiscordService())->getCurrentUser($accessToken);
$user->setAccessToken($accessToken);
} catch (\Exception $e) {
return $this->throwError('authorization_failed', $e);
}
// Making sure the user has an email if the email scope is set.
if (in_array('email', explode('&', config('larascord.scopes')))) {
if (empty($user->email)) {
return $this->throwError('missing_email');
}
}
if (auth()->check()) {
// Making sure the current logged-in user's ID is matching the ID retrieved from the Discord API.
if (auth()->id() !== (int)$user->id) {
auth()->logout();
return $this->throwError('invalid_user');
}
// Confirming the session in case the user was redirected from the password.confirm middleware.
$request->session()->put('auth.password_confirmed_at', time());
}
// Trying to create or update the user in the database.
// Initiating a database transaction in case something goes wrong.
DB::beginTransaction();
try {
$user = (new DiscordService())->createOrUpdateUser($user);
$user->accessToken()->updateOrCreate([], $accessToken->toArray());
} catch (\Exception $e) {
DB::rollBack();
return $this->throwError('database_error', $e);
}
// Verifying if the user is soft-deleted.
if (Schema::hasColumn('users', 'deleted_at')) {
if ($user->trashed()) {
DB::rollBack();
return $this->throwError('user_deleted');
}
}
// Patreon check
try {
if (!$accessToken->hasScopes(['guilds', 'guilds.members.read'])) {
DB::rollBack();
return $this->throwError('missing_guilds_members_read_scope');
}
$guildMember = (new DiscordService())->getGuildMember($accessToken, config('discord.guild_id'));
$patreonroles = config('discord.patreon_roles');
$user->is_patreon = false;
if ((new DiscordService())->hasRoleInGuild($guildMember, $patreonroles)) {
$user->is_patreon = true;
}
$user->save();
} catch (\Exception $e) {
// Clearly not a patreon
$user->is_patreon = false;
$user->save();
}
// Committing the database transaction.
DB::commit();
// Authenticating the user if the user is not logged in.
if (!auth()->check()) {
auth()->login($user, config('larascord.remember_me', false));
}
// Redirecting the user to the intended page or to the home page.
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Handles the throwing of an error.
*/
private function throwError(string $message, \Exception $exception = NULL): RedirectResponse | JsonResponse
{
if (app()->hasDebugModeEnabled()) {
return response()->json([
'larascord_message' => config('larascord.error_messages.' . $message),
'message' => $exception?->getMessage(),
'code' => $exception?->getCode()
]);
} else {
if (config('larascord.error_messages.' . $message . '.redirect')) {
Alert::error('Error', config('larascord.error_messages.' . $message . '.message', 'An error occurred while trying to log you in.'));
return redirect(config('larascord.error_messages.' . $message . '.redirect'))->with('error', config('larascord.error_messages.' . $message . '.message', 'An error occurred while trying to log you in.'));
} else {
return redirect('/')->with('error', config('larascord.error_messages.' . $message, 'An error occurred while trying to log you in.'));
}
}
}
/**
* Handles the deletion of the user.
*/
public function destroy(): RedirectResponse | JsonResponse
{
// Revoking the OAuth2 access token.
try {
(new DiscordService())->revokeAccessToken(auth()->user()->accessToken()->first()->refresh_token);
} catch (\Exception $e) {
return $this->throwError('revoke_token_failed', $e);
}
// Deleting the user from the database.
auth()->user()->delete();
// Showing the success message.
if (config('larascord.success_messages.user_deleted.redirect')) {
return redirect(config('larascord.success_messages.user_deleted.redirect'))->with('success', config('larascord.success_messages.user_deleted.message', 'Your account has been deleted.'));
} else {
return redirect('/')->with('success', config('larascord.success_messages.user_deleted', 'Your account has been deleted.'));
}
}
}

View File

@@ -1,273 +0,0 @@
<?php
namespace Jakyeru\Larascord\Services;
use App\Models\User;
use App\Models\OldUser;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use Exception;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Jakyeru\Larascord\Types\AccessToken;
use Jakyeru\Larascord\Types\GuildMember;
use Illuminate\Support\Facades\DB;
class DiscordService
{
/**
* The Discord OAuth2 token URL.
*/
protected string $tokenURL = "https://discord.com/api/oauth2/token";
/**
* The Discord API base URL.
*/
protected string $baseApi = "https://discord.com/api";
/**
* The required data for the token request.
*/
protected array $tokenData = [
"client_id" => NULL,
"client_secret" => NULL,
"grant_type" => "authorization_code",
"code" => NULL,
"redirect_uri" => NULL,
"scope" => null
];
/**
* UserService constructor.
*/
public function __construct()
{
$this->tokenData['client_id'] = config('larascord.client_id');
$this->tokenData['client_secret'] = config('larascord.client_secret');
$this->tokenData['grant_type'] = config('larascord.grant_type');
$this->tokenData['redirect_uri'] = config('larascord.redirect_uri');
$this->tokenData['scope'] = config('larascord.scopes');
}
/**
* Handles the Discord OAuth2 callback and returns the access token.
*
* @throws RequestException
*/
public function getAccessTokenFromCode(string $code): AccessToken
{
$this->tokenData['code'] = $code;
$response = Http::asForm()->post($this->tokenURL, $this->tokenData);
$response->throw();
return new AccessToken(json_decode($response->body()));
}
/**
* Get access token from refresh token.
*
* @throws RequestException
*/
public function refreshAccessToken(string $refreshToken): AccessToken
{
$response = Http::asForm()->post($this->tokenURL, [
'client_id' => config('larascord.client_id'),
'client_secret' => config('larascord.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]);
$response->throw();
return new AccessToken(json_decode($response->body()));
}
/**
* Authenticates the user with the access token and returns the user data.
*
* @throws RequestException
*/
public function getCurrentUser(AccessToken $accessToken): \Jakyeru\Larascord\Types\User
{
$response = Http::withToken($accessToken->access_token)->get($this->baseApi . '/users/@me');
$response->throw();
return new \Jakyeru\Larascord\Types\User(json_decode($response->body()));
}
/**
* Get the user's guilds.
*
* @throws RequestException
* @throws Exception
*/
public function getCurrentUserGuilds(AccessToken $accessToken, bool $withCounts = false): array
{
if (!$accessToken->hasScope('guilds')) throw new Exception(config('larascord.error_messages.missing_guilds_scope.message'));
$endpoint = '/users/@me/guilds';
if ($withCounts) {
$endpoint .= '?with_counts=true';
}
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . $endpoint);
$response->throw();
return array_map(function ($guild) {
return new \Jakyeru\Larascord\Types\Guild($guild);
}, json_decode($response->body()));
}
/**
* Get the Guild Member object for a user.
*
* @throws RequestException
* @throws Exception
*/
public function getGuildMember(AccessToken $accessToken, string $guildId): GuildMember
{
if (!$accessToken->hasScopes(['guilds', 'guilds.members.read'])) throw new Exception(config('larascord.error_messages.missing_guilds_members_read_scope.message'));
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . '/users/@me/guilds/' . $guildId . '/member');
$response->throw();
return new GuildMember(json_decode($response->body()));
}
/**
* Get the User's connections.
*
* @throws RequestException
* @throws Exception
*/
public function getCurrentUserConnections(AccessToken $accessToken): array
{
if (!$accessToken->hasScope('connections')) throw new Exception('The "connections" scope is required.');
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . '/users/@me/connections');
$response->throw();
return array_map(function ($connection) {
return new \Jakyeru\Larascord\Types\Connection($connection);
}, json_decode($response->body()));
}
/**
* Join a guild.
*
* @throws RequestException
* @throws Exception
*/
public function joinGuild(AccessToken $accessToken, User $user, string $guildId, array $options = []): GuildMember
{
if (!config('larascord.access_token')) throw new Exception(config('larascord.error_messages.missing_access_token.message'));
if (!$accessToken->hasScope('guilds.join')) throw new Exception('The "guilds" and "guilds.join" scopes are required.');
$response = Http::withToken(config('larascord.access_token'), 'Bot')->put($this->baseApi . '/guilds/' . $guildId . '/members/' . $user->id, array_merge([
'access_token' => $accessToken->access_token,
], $options));
$response->throw();
if ($response->status() === 204) return throw new Exception('User is already in the guild.');
return new GuildMember(json_decode($response->body()));
}
/**
* Create or update a user in the database.
*
* @throws Exception
*/
public function createOrUpdateUser(\Jakyeru\Larascord\Types\User $user): User
{
if (!$user->getAccessToken()) {
throw new Exception('User access token is missing.');
}
$forgottenUser = User::where('email', '=', $user->email)->where('id', '!=', $user->id)->first();
if ($forgottenUser) {
// This case should never happen (TM) - The discord id changed
// The user probably re-created their discord account with the same email
// Delete Playlist
$playlists = Playlist::where('user_id', $forgottenUser->id)->get();
foreach($playlists as $playlist) {
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
$playlist->forceDelete();
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $forgottenUser->id)->update(['commenter_id' => 1]);
$forgottenUser->forceDelete();
}
return User::updateOrCreate(
[
'id' => $user->id,
],
$user->toArray(),
);
}
/**
* Verify if the user is in the specified guild(s).
*/
public function isUserInGuilds(array $guilds): bool
{
// Verify if the user is in all the specified guilds if strict mode is enabled.
if (config('larascord.guilds_strict')) {
return empty(array_diff(config('larascord.guilds'), array_column($guilds, 'id')));
}
// Verify if the user is in any of the specified guilds if strict mode is disabled.
return !empty(array_intersect(config('larascord.guilds'), array_column($guilds, 'id')));
}
/**
* Verify if the user has the specified role(s) in the specified guild.
*/
public function hasRoleInGuild(GuildMember $guildMember, array $roles): bool
{
// Verify if the user has any of the specified roles.
return !empty(array_intersect($roles, $guildMember->roles));
}
/**
* Updates the user's roles in the database.
*/
public function updateUserRoles(User $user, GuildMember $guildMember, int $guildId): void
{
// Updating the user's roles in the database.
$updatedRoles = $user->roles;
$updatedRoles[$guildId] = $guildMember->roles;
$user->roles = $updatedRoles;
$user->save();
}
/**
* Revoke the user's access token.
*
* @throws RequestException
*/
public function revokeAccessToken(string $accessToken): object
{
$response = Http::asForm()->post($this->tokenURL . '/revoke', [
'token' => $accessToken,
'client_id' => config('larascord.client_id'),
'client_secret' => config('larascord.client_secret'),
]);
$response->throw();
return json_decode($response->body());
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +20,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('discord', \SocialiteProviders\Discord\Provider::class);
});
}
}

View File

@@ -74,7 +74,7 @@ class GalleryService
foreach ($oldGallery as $oldImage) {
Storage::disk('public')->delete($oldImage->image_url);
Storage::disk('public')->delete($oldImage->thumbnail_url);
$oldImage->forceDelete();
$oldImage->delete();
}
}
}

View File

@@ -14,10 +14,10 @@
"http-interop/http-factory-guzzle": "^1.2",
"intervention/image": "^3.9",
"intervention/image-laravel": "^1.3",
"jakyeru/larascord": "^6.0",
"laravel/framework": "^11.0",
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.20",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10",
"laravelista/comments": "dev-l11-compatibility",
"livewire/livewire": "^3.6.4",
@@ -27,6 +27,7 @@
"predis/predis": "^2.2",
"realrashid/sweet-alert": "^7.2",
"rtconner/laravel-tagging": "^4.1",
"socialiteproviders/discord": "^4.2",
"spatie/laravel-discord-alerts": "^1.5",
"spatie/laravel-sitemap": "^7.3",
"vluzrmos/language-detector": "^2.3"
@@ -34,6 +35,7 @@
"require-dev": {
"barryvdh/laravel-debugbar": "^3.14.7",
"fakerphp/faker": "^1.24.0",
"laravel/breeze": "^2.3",
"laravel/pint": "^1.18",
"laravel/sail": "^1.38",
"mockery/mockery": "^1.4.4",
@@ -49,8 +51,6 @@
],
"autoload": {
"exclude-from-classmap": [
"vendor/jakyeru/larascord/src/Http/Services/DiscordService.php",
"vendor/jakyeru/larascord/src/Http/Controllers/DiscordController.php",
"vendor/laravelista/comments/src/CommentPolicy.php",
"vendor/laravelista/comments/src/CommentService.php"
],
@@ -60,8 +60,6 @@
"Database\\Seeders\\": "database/seeders/"
},
"files": [
"app/Override/Discord/Services/DiscordService.php",
"app/Override/Discord/DiscordController.php",
"app/Override/Comments/CommentPolicy.php",
"app/Override/Comments/CommentService.php"
]

741
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": "484d21a7c10b1609a22d642e71a71cc3",
"content-hash": "749225dc4ea2aca06f1639bef889cc59",
"packages": [
{
"name": "brick/math",
@@ -631,6 +631,69 @@
},
"time": "2019-12-30T22:54:17+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v7.0.2",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"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/v7.0.2"
},
"time": "2025-12-16T22:17:28+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.3.0",
@@ -1533,59 +1596,6 @@
],
"time": "2025-04-04T15:09:55+00:00"
},
{
"name": "jakyeru/larascord",
"version": "v6.0.3",
"source": {
"type": "git",
"url": "https://github.com/JakyeRU/Larascord.git",
"reference": "d1099d1418022eda970fec4f13634ee5fbee93b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/JakyeRU/Larascord/zipball/d1099d1418022eda970fec4f13634ee5fbee93b3",
"reference": "d1099d1418022eda970fec4f13634ee5fbee93b3",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.5",
"laravel/breeze": "^v2.0",
"laravel/framework": "^11",
"php": "^8.2|^8.3"
},
"require-dev": {
"orchestra/testbench": "^9"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Jakyeru\\Larascord\\LarascordServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Jakyeru\\Larascord\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jakye",
"email": "jakyeru@gmail.com"
}
],
"description": "Larascord is a package that allows you to authenticate users in your Laravel application using Discord.",
"support": {
"issues": "https://github.com/JakyeRU/Larascord/issues",
"source": "https://github.com/JakyeRU/Larascord/tree/v6.0.3"
},
"time": "2025-06-18T18:41:42+00:00"
},
{
"name": "jaybizzle/crawler-detect",
"version": "v1.3.6",
@@ -1638,67 +1648,6 @@
},
"time": "2025-09-30T16:22:43+00:00"
},
{
"name": "laravel/breeze",
"version": "v2.3.8",
"source": {
"type": "git",
"url": "https://github.com/laravel/breeze.git",
"reference": "1a29c5792818bd4cddf70b5f743a227e02fbcfcd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/breeze/zipball/1a29c5792818bd4cddf70b5f743a227e02fbcfcd",
"reference": "1a29c5792818bd4cddf70b5f743a227e02fbcfcd",
"shasum": ""
},
"require": {
"illuminate/console": "^11.0|^12.0",
"illuminate/filesystem": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0",
"illuminate/validation": "^11.0|^12.0",
"php": "^8.2.0",
"symfony/console": "^7.0"
},
"require-dev": {
"laravel/framework": "^11.0|^12.0",
"orchestra/testbench-core": "^9.0|^10.0",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Breeze\\BreezeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Breeze\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.",
"keywords": [
"auth",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/breeze/issues",
"source": "https://github.com/laravel/breeze"
},
"time": "2025-07-18T18:49:59+00:00"
},
{
"name": "laravel/framework",
"version": "v11.46.1",
@@ -2179,6 +2128,78 @@
},
"time": "2025-09-22T17:29:40+00:00"
},
{
"name": "laravel/socialite",
"version": "v5.24.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
"reference": "25e28c14d55404886777af1d77cf030e0f633142"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/socialite/zipball/25e28c14d55404886777af1d77cf030e0f633142",
"reference": "25e28c14d55404886777af1d77cf030e0f633142",
"shasum": ""
},
"require": {
"ext-json": "*",
"firebase/php-jwt": "^6.4|^7.0",
"guzzlehttp/guzzle": "^6.0|^7.0",
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"league/oauth1-client": "^1.11",
"php": "^7.2|^8.0",
"phpseclib/phpseclib": "^3.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8",
"phpstan/phpstan": "^1.12.23",
"phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Socialite": "Laravel\\Socialite\\Facades\\Socialite"
},
"providers": [
"Laravel\\Socialite\\SocialiteServiceProvider"
]
},
"branch-alias": {
"dev-master": "5.x-dev"
}
},
"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": "2026-01-01T02:57:21+00:00"
},
{
"name": "laravel/tinker",
"version": "v2.10.1",
@@ -2686,6 +2707,82 @@
],
"time": "2024-09-21T08:32:55+00:00"
},
{
"name": "league/oauth1-client",
"version": "v1.11.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth1-client.git",
"reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055",
"reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055",
"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.11.0"
},
"time": "2024-12-10T19:59:05+00:00"
},
{
"name": "league/pipeline",
"version": "1.1.0",
@@ -4038,6 +4135,125 @@
],
"time": "2025-05-08T08:14:37+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+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.20.0",
@@ -4192,6 +4408,116 @@
],
"time": "2025-08-21T11:53:16+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "3.0.48",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "64065a5679c50acb886e82c07aa139b0f757bb89"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89",
"reference": "64065a5679c50acb886e82c07aa139b0f757bb89",
"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.48"
},
"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": "2025-12-15T11:51:42+00:00"
},
{
"name": "predis/predis",
"version": "v2.4.0",
@@ -5147,6 +5473,130 @@
},
"time": "2022-04-25T22:18:50+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.8.1",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Manager.git",
"reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4",
"reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4",
"shasum": ""
},
"require": {
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
"laravel/socialite": "^5.5",
"php": "^8.1"
},
"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": "2025-02-24T19:33:30+00:00"
},
{
"name": "spatie/browsershot",
"version": "5.0.11",
@@ -8835,6 +9285,67 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "laravel/breeze",
"version": "v2.3.8",
"source": {
"type": "git",
"url": "https://github.com/laravel/breeze.git",
"reference": "1a29c5792818bd4cddf70b5f743a227e02fbcfcd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/breeze/zipball/1a29c5792818bd4cddf70b5f743a227e02fbcfcd",
"reference": "1a29c5792818bd4cddf70b5f743a227e02fbcfcd",
"shasum": ""
},
"require": {
"illuminate/console": "^11.0|^12.0",
"illuminate/filesystem": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0",
"illuminate/validation": "^11.0|^12.0",
"php": "^8.2.0",
"symfony/console": "^7.0"
},
"require-dev": {
"laravel/framework": "^11.0|^12.0",
"orchestra/testbench-core": "^9.0|^10.0",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Breeze\\BreezeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Breeze\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.",
"keywords": [
"auth",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/breeze/issues",
"source": "https://github.com/laravel/breeze"
},
"time": "2025-07-18T18:49:59+00:00"
},
{
"name": "laravel/pint",
"version": "v1.25.1",

View File

@@ -1,10 +1,19 @@
<?php
return [
'invite_link' => 'https://discord.gg/yAqgVKNgG5',
'guild_id' => 802233383710228550,
'patreon_roles' => [841798154999169054, 803329707650187364, 803327903659196416, 803325441942356059, 803322725576736858, 802270568912519198, 802234830384267315],
'patreon_roles' => [
841798154999169054, // ????
803329707650187364, // Tier-5
803327903659196416, // ????
803325441942356059, // Tier-3
803322725576736858, // Tier-2
802270568912519198, // Tier-1
802234830384267315 // admin
],
'discord_bot_token' => env('DISCORD_BOT_TOKEN'),
];

View File

@@ -1,247 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application ID
|--------------------------------------------------------------------------
|
| This is the ID of your Discord application.
|
*/
'client_id' => env('LARASCORD_CLIENT_ID', null),
/*
|--------------------------------------------------------------------------
| Application Secret
|--------------------------------------------------------------------------
|
| This is the secret of your Discord application.
|
*/
'client_secret' => env('LARASCORD_CLIENT_SECRET', null),
/*
|--------------------------------------------------------------------------
| Application Access Token
|--------------------------------------------------------------------------
|
| This is the access token of your Discord application.
|
*/
'access_token' => env('LARASCORD_ACCESS_TOKEN', null),
/*
|--------------------------------------------------------------------------
| Grant Type
|--------------------------------------------------------------------------
|
| This is the grant type of your Discord application. It must be set to
| "authorization_code".
|
*/
'grant_type' => env('LARASCORD_GRANT_TYPE', 'authorization_code'),
/*
|--------------------------------------------------------------------------
| Redirect URI
|--------------------------------------------------------------------------
|
| This is the URI that Discord will redirect to after the user authorizes
| your application.
|
*/
'redirect_uri' => env('APP_URL', 'http://localhost:8000') . '/' . env('LARASCORD_PREFIX', 'larascord') . '/callback',
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
|
| These are the OAuth2 scopes of your Discord application.
|
*/
'scopes' => env('LARASCORD_SCOPE', 'identify&email&guilds&guilds.members.read'),
/*
|--------------------------------------------------------------------------
| Route Prefix
|--------------------------------------------------------------------------
|
| This is the prefix that Larascord will use for its routes. For example,
| the prefix "larascord" will result in the route
| "https://domain.com/larascord/login".
|
*/
'route_prefix' => env('LARASCORD_PREFIX', 'larascord'),
/*
|--------------------------------------------------------------------------
| OAuth2 Prompt - "none" or "consent"
|--------------------------------------------------------------------------
|
| The prompt controls how the authorization flow handles existing authorizations.
| If a user has previously authorized your application with the requested scopes
| and prompt is set to consent,it will request them to re-approve their
| authorization. If set to none, it will skip the authorization screen
| and redirect them back to your redirect URI without requesting
| their authorization.
|
*/
'prompt' => 'none',
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Guilds
|--------------------------------------------------------------------------
|
| This option restricts access to the application to users who are members
| of specific Discord guilds. Users who are not members of the specified
| guilds will not be able to use the application.
|
*/
'guilds' => [],
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Guilds - Strict Mode
|--------------------------------------------------------------------------
|
| Enabling this option will require the user to be a member of ALL the
| aforementioned guilds. If this option is disabled, the user will
| only need to be a member of at least ONE of the guilds.
|
*/
'guilds_strict' => false,
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Roles
|--------------------------------------------------------------------------
|
| When this option is enabled, the user will only be able to use the
| application if they have at least one of the specified roles.
|
*/
// WARNING: This feature makes one request to the Discord API for each guild you specify. (Because you need to fetch the roles for each guild)
// At the moment the database is not checked for roles when the user logs in. It will always fetch the roles from the Discord API.
// Currently, the roles are only updated in the database when the user logs in. The roles from the database can be used in a middleware.
// I'm working on a better way to do this, but for now, this will work.
'guild_roles' => [
// 'guild_id' => [
// 'role_id',
// 'role_id',
// ],
],
/*
|--------------------------------------------------------------------------
| Remember Me
|--------------------------------------------------------------------------
|
| Whether or not to remember the user after they log in.
|
*/
'remember_me' => true,
/*
|--------------------------------------------------------------------------
| Error Messages
|--------------------------------------------------------------------------
|
| These are the error messages that will be displayed to the user if there
| is an error.
|
*/
'error_messages' => [
'missing_code' => [
'message' => 'The authorization code is missing.',
'redirect' => '/'
],
'invalid_code' => [
'message' => 'The authorization code is invalid.',
'redirect' => '/'
],
'authorization_failed' => [
'message' => 'The authorization failed.',
'redirect' => '/'
],
'missing_email' => [
'message' => 'Couldn\'t get your e-mail address. Please add an e-mail address to your Discord account!',
'redirect' => '/'
],
'invalid_user' => [
'message' => 'The user ID doesn\'t match the logged-in user.',
'redirect' => '/'
],
'database_error' => [
'message' => 'There was an error with the database. Please try again later.',
'redirect' => '/'
],
'missing_guilds_scope' => [
'message' => 'The "guilds" scope is required.',
'redirect' => '/'
],
'missing_guilds_members_read_scope' => [
'message' => 'The "guilds" and "guilds.members.read" scopes are required.',
'redirect' => '/'
],
'authorization_failed_guilds' => [
'message' => 'Couldn\'t get the servers you\'re in.',
'redirect' => '/'
],
'not_member_guild_only' => [
'message' => 'You are not a member of the required guilds.',
'redirect' => '/'
],
'missing_access_token' => [
'message' => 'The access token is missing.',
'redirect' => '/'
],
'authorization_failed_roles' => [
'message' => 'Couldn\'t get the roles you have.',
'redirect' => '/'
],
'missing_role' => [
'message' => 'You don\'t have the required roles.',
'redirect' => '/'
],
'revoke_token_failed' => [
'message' => 'An error occurred while trying to revoke your access token.',
'redirect' => '/'
],
],
/*
|--------------------------------------------------------------------------
| Success Messages
|--------------------------------------------------------------------------
|
| These are the success messages that will be displayed to the user if there
| is no error.
|
*/
'success_messages' => [
'user_deleted' => [
'message' => 'Your account has been deleted.',
'redirect' => '/'
],
],
];

View File

@@ -31,4 +31,21 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
/*
|--------------------------------------------------------------------------
| Socialite Providers
|--------------------------------------------------------------------------
*/
'discord' => [
'client_id' => env('DISCORD_CLIENT_ID'),
'client_secret' => env('DISCORD_CLIENT_SECRET'),
'redirect' => '/auth/discord/callback',
// optional
'allow_gif_avatars' => (bool) env('DISCORD_AVATAR_GIF', true),
'avatar_default_extension' => env('DISCORD_EXTENSION_DEFAULT', 'webp'), // only pick from jpg, png, webp
],
];

View File

@@ -5,6 +5,7 @@ use App\Models\Downloads;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
@@ -14,7 +15,7 @@ return new class extends Migration
public function up(): void
{
# Delete entries with "#" as URL
Downloads::where('url', '#')->forceDelete();
Downloads::where('url', '#')->delete();
# Remove duplicate entries
$duplicates = DB::table('downloads')

View File

@@ -0,0 +1,65 @@
<?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
{
// Remove tables from larascord
Schema::dropIfExists('discord_access_tokens');
Schema::dropIfExists('personal_access_tokens');
// Drop columns from larascord
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('discriminator');
$table->dropColumn('remember_token');
$table->dropColumn('banner');
$table->dropColumn('banner_color');
$table->dropColumn('accent_color');
$table->dropColumn('premium_type');
$table->dropColumn('public_flags');
$table->dropColumn('verified');
$table->dropColumn('mfa_enabled');
$table->dropColumn('global_name');
$table->dropColumn('locale');
});
// Change & Add Columns
Schema::table('users', function (Blueprint $table) {
// Rename
$table->renameColumn('username', 'name');
$table->renameColumn('avatar', 'discord_avatar');
$table->string('avatar')->nullable()->after('email');
// Re-Add Email verification
$table->timestamp('email_verified_at')->nullable()->after('email');
// Re-Add Password Auth
$table->string('password')->nullable()->after('email_verified_at');
$table->rememberToken()->after('password');
});
/**
* --------------------------------------------------------------------
* Fix Discord Profile Pictures
* --------------------------------------------------------------------
* The oauth package by socialite now returns a full url of the avatar.
* Meaning all the old entries have to be fixed.
*/
foreach (User::whereNotNull('discord_avatar')->get() as $user)
{
$isGif = preg_match('/a_.+/m', $user->discord_avatar) === 1;
$extension = $isGif ? 'gif' : 'webp';
$user->discord_avatar = sprintf('https://cdn.discordapp.com/avatars/%s/%s.%s', $user->id, $user->discord_avatar, $extension);
$user->save();
}
}
};

View File

@@ -0,0 +1,207 @@
<?php
use App\Models\Playlist;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. Create new column discord_id
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('discord_id')->nullable()->after('id');
});
// 2. Migrate Discord Users IDs
DB::table('users')
->where('id', '>', 10000)
->update(['discord_id' => DB::raw('id')]);
// 3. Temporary new auto increment column
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('new_id')->first();
});
// 3.5 Count
DB::statement('
UPDATE users u
JOIN (
SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS rn
FROM users
) t ON u.id = t.id
SET u.new_id = t.rn
');
// 4. Drop foreign keys
$this->dropForeignKeys();
// 5. Fix ID's in other tables
$this->updateUserIDsInOtherTables();
// 6. Remove old ID
Schema::table('users', function (Blueprint $table) {
$table->bigInteger('id')->unsigned()->change();
$table->dropPrimary('id');
$table->dropColumn('id');
});
// 7. Rename new_id to id
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('new_id', 'id');
});
// 8. Change new ID to auto increment and set as primary key
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('id')->autoIncrement()->primary()->change();
});
// 9. Remove data that would conflict with constraints
$this->deleteUnreferencedData();
// 9. Recreate foreign key constraints
$this->addForeignKeys();
}
/**
* Drop Foreign Keys referencing the user id
*/
private function dropForeignKeys(): void
{
Schema::table('markable_likes', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
Schema::table('watched', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
// Our Schema does include a foreign key, for whatever reason it doesn't exist in the first palce
// Schema::table('user_downloads', function (Blueprint $table) {
// $table->dropForeign(['user_id']);
// });
}
/**
* Tables to fix the IDs:
* - comments ['commenter_id']
* - markable_likes ['user_id']
* - notifications ['notifiable_id']
* - playlists ['user_id']
* - user_downloads ['user_id']
* - watched ['user_id']
*/
private function updateUserIDsInOtherTables(): void
{
DB::statement('
UPDATE comments c
JOIN users u ON c.commenter_id = u.id
SET c.commenter_id = u.new_id
');
DB::statement('
UPDATE watched w
JOIN users u ON w.user_id = u.id
SET w.user_id = u.new_id
');
DB::statement('
UPDATE markable_likes ml
JOIN users u ON ml.user_id = u.id
SET ml.user_id = u.new_id
');
DB::statement('
UPDATE notifications n
JOIN users u ON n.notifiable_id = u.id
SET n.notifiable_id = u.new_id
');
DB::statement('
UPDATE playlists p
JOIN users u ON p.user_id = u.id
SET p.user_id = u.new_id
');
DB::statement('
UPDATE user_downloads ud
JOIN users u ON ud.user_id = u.id
SET ud.user_id = u.new_id
');
}
/**
* Due to incorrect handling of user deletes,
* we have unreferenced data
*/
private function deleteUnreferencedData(): void
{
// User Downloads Table
DB::table('user_downloads')
->where('user_id', '>', 1_000_000)
->delete();
// User Playlists Table
$playlists = Playlist::where('user_id', '>', 1_000_000)
->get();
foreach($playlists as $playlist) {
DB::table('playlist_episodes')
->where('playlist_id', '=', $playlist->id)
->delete();
$playlist->delete();
}
}
/**
* Re-Add Foreign Keys to tables which we dropped previously
*/
private function addForeignKeys(): void
{
Schema::table('markable_likes', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('watched', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('user_downloads', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('playlist_episodes', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('playlist_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('playlist_id')->references('id')->on('playlists')->onDelete('cascade');
});
Schema::table('playlists', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
};

36
package-lock.json generated
View File

@@ -16,12 +16,13 @@
"vidstack": "^1.12.13"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.18",
"@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"axios": "^1.6.8",
"laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^7.1.6",
"vite-plugin-static-copy": "^3.0.1"
}
@@ -974,6 +975,23 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true,
"license": "MIT"
},
"node_modules/@yaireo/tagify": {
"version": "4.35.4",
"resolved": "https://registry.npmjs.org/@yaireo/tagify/-/tagify-4.35.4.tgz",
@@ -1001,6 +1019,16 @@
"node": ">=0.4.0"
}
},
"node_modules/alpinejs": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.3.tgz",
"integrity": "sha512-fSI6F5213FdpMC4IWaup92KhuH3jBX0VVqajRJ6cOTCy1cL6888KyXdGO+seAAkn+g6fnrxBqQEx6gRpQ5EZoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",

View File

@@ -6,12 +6,13 @@
"build": "vite build"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.18",
"@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"axios": "^1.6.8",
"laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^7.1.6",
"vite-plugin-static-copy": "^3.0.1"
},

View File

@@ -1,8 +1,5 @@
import './bootstrap';
// import { Alpine } from '../../vendor/livewire/livewire/dist/livewire.esm';
// Alpine.start();
import 'hammerjs';
import {
Collapse,
Carousel,
@@ -15,6 +12,10 @@ import {
initTE,
} from "tw-elements";
initTE({ Collapse, Carousel, Clipboard, Modal, Tab, Lightbox, Tooltip, Ripple });
import Alpine from 'alpinejs';
import 'hammerjs';
window.Alpine = Alpine;
Alpine.start();
initTE({ Collapse, Carousel, Clipboard, Modal, Tab, Lightbox, Tooltip, Ripple });

View File

@@ -1,20 +1,29 @@
<x-guest-layout>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('This is a secure area of the application. Please confirm your session before continuing.') }}
</div>
<form method="POST" action="{{ route('larascord.refresh_token') }}">
@csrf
<div class="flex justify-end mt-4">
<x-primary-button>
<svg style="margin-right: 10px;" width="30px" height="30px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#ffff" fill-rule="nonzero"></path>
</g>
</svg>
{{ __('Confirm') }}
</x-primary-button>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
</div>
</form>
</x-guest-layout>
<form method="POST" action="{{ route('password.confirm') }}">
@csrf
<!-- Password -->
<div>
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="flex justify-end mt-4">
<x-primary-button>
{{ __('Confirm') }}
</x-primary-button>
</div>
</form>
</div>
</x-guest-layout>

View File

@@ -0,0 +1,27 @@
<x-guest-layout>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
</div>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}">
@csrf
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Send Password Reset Link') }}
</x-primary-button>
</div>
</form>
</div>
</x-guest-layout>

View File

@@ -0,0 +1,138 @@
<x-guest-layout>
<!-- Tabs -->
<div class="w-full sm:max-w-md mt-6">
<ul class="flex list-none flex-row flex-wrap border-b-0 pl-0 relative " role="tablist" data-te-nav-ref>
<li role="presentation" class="flex-auto text-center">
<a href="#tabs-login" class="rounded-l-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
data-te-toggle="pill" data-te-target="#tabs-login" data-te-nav-active role="tab" aria-controls="tabs-login" aria-selected="true">
{{ __('Login') }}
</a>
</li>
<li role="presentation" class="flex-auto text-center">
<a href="#tabs-register" class="rounded-r-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
data-te-toggle="pill" data-te-target="#tabs-register" role="tab" aria-controls="tabs-register" aria-selected="false">
{{ __('Register') }}
</a>
</li>
</ul>
</div>
<!-- Login -->
<div class="w-full sm:max-w-md hidden opacity-100 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-login" role="tabpanel" aria-labelledby="tabs-login" data-te-tab-active>
<div class="px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<div class="w-full text-center text-white mb-3">
<a href="{{ route('discord.login') }}">
<div
class="relative bg-blue-700 hover:bg-blue-600 text-white font-bold px-4 h-10 rounded text-center p-[10px] mb-4">
<i class="fa-brands fa-discord"></i> {{ __('Use Discord Account') }}
</div>
</a>
<!-- Or -->
<div class="grid grid-cols-3">
<hr class="self-center border-neutral-600">
<p>OR</p>
<hr class="self-center border-neutral-600">
</div>
</div>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('login') }}">
@csrf
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Remember Me -->
<div class="block mt-4">
<label for="remember_me" class="inline-flex items-center">
<input id="remember_me" type="checkbox" class="rounded dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 text-rose-600 shadow-sm focus:ring-rose-500 dark:focus:ring-rose-600 dark:focus:ring-offset-neutral-800" name="remember">
<span class="ms-2 text-sm text-neutral-600 dark:text-neutral-400">{{ __('Remember me') }}</span>
</label>
</div>
<div class="flex items-center justify-end mt-4">
@if (Route::has('password.request'))
<a class="underline text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 dark:focus:ring-offset-neutral-800" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }}
</a>
@endif
<x-primary-button class="ms-3">
{{ __('Log in') }}
</x-primary-button>
</div>
</form>
</div>
</div>
<!-- Register -->
<div class="w-full sm:max-w-md hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-register" role="tabpanel" aria-labelledby="tabs-register">
<div class="px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<form method="POST" action="{{ route('register') }}">
@csrf
<!-- Name -->
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button class="ms-4">
{{ __('Register') }}
</x-primary-button>
</div>
</form>
</div>
</div>
</x-guest-layout>

View File

@@ -0,0 +1,41 @@
<x-guest-layout>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<form method="POST" action="{{ route('password.store') }}">
@csrf
<!-- Password Reset Token -->
<input type="hidden" name="token" value="{{ $request->route('token') }}">
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Reset Password') }}
</x-primary-button>
</div>
</form>
</div>
</x-guest-layout>

View File

@@ -0,0 +1,33 @@
<x-guest-layout>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
</div>
@if (session('status') == 'verification-link-sent')
<div class="mb-4 font-medium text-sm text-green-600 dark:text-green-400">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</div>
@endif
<div class="mt-4 flex items-center justify-between">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<div>
<x-primary-button>
{{ __('Resend Verification Email') }}
</x-primary-button>
</div>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
{{ __('Log Out') }}
</button>
</form>
</div>
</div>
</x-guest-layout>

View File

@@ -1 +1 @@
<img class="h-10" src="/images/hs_banner.png">
<img class="h-16" src="/images/hs_banner.png">

View File

@@ -1 +1 @@
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-neutral-100 dark:hover:bg-neutral-900 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-neutral-100 dark:hover:bg-neutral-900 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>

View File

@@ -1,24 +1,16 @@
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white dark:bg-neutral-800'])
@php
switch ($align) {
case 'left':
$alignmentClasses = 'origin-top-left left-0';
break;
case 'top':
$alignmentClasses = 'origin-top';
break;
case 'right':
default:
$alignmentClasses = 'origin-top-right right-0';
break;
}
$alignmentClasses = match ($align) {
'left' => 'ltr:origin-top-left rtl:origin-top-right start-0',
'top' => 'origin-top',
default => 'ltr:origin-top-right rtl:origin-top-left end-0',
};
switch ($width) {
case '48':
$width = 'w-48';
break;
}
$width = match ($width) {
'48' => 'w-48',
default => $width,
};
@endphp
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
@@ -28,11 +20,11 @@ switch ($width) {
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
style="display: none;"
@click="open = false">

View File

@@ -40,12 +40,13 @@ $maxWidth = [
}
})"
x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
x-on:close.stop="show = false"
x-on:keydown.escape.window="show = false"
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
x-show="show"
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
class="fixed inset-0 overflow-y-auto px-4 py-12 sm:px-0 z-50"
style="display: {{ $show ? 'block' : 'none' }};"
>
<div
@@ -59,12 +60,12 @@ $maxWidth = [
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div class="absolute inset-0 bg-gray-500 dark:bg-gray-900 opacity-75"></div>
<div class="absolute inset-0 bg-neutral-500 dark:bg-neutral-900 opacity-75"></div>
</div>
<div
x-show="show"
class="mb-6 bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
class="mb-6 bg-white dark:bg-neutral-800 rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"

View File

@@ -1,10 +1,9 @@
@props(['active'])
@php
$classes =
$active ?? false
? 'block w-full pl-3 pr-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-left text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
: 'block w-full pl-3 pr-4 py-2 border-l-4 border-transparent text-left text-base font-medium text-neutral-600 dark:text-neutral-200 hover:text-neutral-800 dark:hover:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 focus:outline-none focus:text-neutral-800 dark:focus:text-neutral-200 focus:bg-neutral-50 dark:focus:bg-neutral-700 focus:border-neutral-300 dark:focus:border-neutral-600 transition duration-150 ease-in-out';
$classes = ($active ?? false)
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 focus:outline-none focus:text-neutral-800 dark:focus:text-neutral-200 focus:bg-neutral-50 dark:focus:bg-neutral-700 focus:border-neutral-300 dark:focus:border-neutral-600 transition duration-150 ease-in-out';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>

View File

@@ -1,3 +1,3 @@
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-500 rounded-md font-semibold text-xs text-gray-700 dark:text-gray-300 uppercase tracking-widest shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150']) }}>
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-500 rounded-md font-semibold text-xs text-neutral-700 dark:text-neutral-300 uppercase tracking-widest shadow-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-800 disabled:opacity-25 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>

View File

@@ -1,3 +1,3 @@
@props(['disabled' => false])
<input {{ $disabled ? 'disabled' : '' }} {!! $attributes->merge(['class' => 'border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm']) !!}>
<input @disabled($disabled) {{ $attributes->merge(['class' => 'border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm']) }}>

View File

@@ -3,17 +3,15 @@
@include('partials.head')
<body class="font-sans text-gray-900 antialiased">
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
<body class="font-sans antialiased">
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-neutral-900">
<div>
<a href="/">
<x-application-logo class="w-20 h-20 fill-current text-gray-500" />
<x-application-logo class="w-24 h-24 fill-current text-gray-500" />
</a>
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
{{ $slot }}
</div>
{{ $slot }}
</div>
</body>
</html>

View File

@@ -94,11 +94,9 @@
<button
class="inline-flex items-center px-3 py-2 border text-sm leading-4 font-medium rounded-md text-gray-500 border-neutral-300/50 dark:text-gray-400 bg-white/20 dark:bg-neutral-950/20 dark:border-neutral-800/50 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150">
@auth
@if (Auth::user()->avatar)
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ Auth::user()->id }}/{{ Auth::user()->avatar }}.webp"
alt="{{ Auth::user()->getTagAttribute() }}" />
@endif
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="{{ Auth::user()->getAvatar() }}"
alt="{{ Auth::user()->name }}" />
@else
<img class="h-8 w-8 rounded-full object-cover mr-2" src="/images/default-avatar.webp"
alt="Guest" />
@@ -106,7 +104,7 @@
@auth
<div style="display: flex; flex-direction: row; align-items: flex-start;">
{{ Auth::user()->getTagAttribute() }}
{{ Auth::user()->name }}
@if ($notAvailable)
<i class="fa-solid fa-bell text-rose-600"></i>
@endif
@@ -188,8 +186,8 @@
@guest
<x-dropdown-link :href="route('login')">
<div
class="relative bg-blue-700 hover:bg-blue-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]">
<i class="fa-brands fa-discord"></i> {{ __('nav.login') }}
class="relative bg-rose-700 hover:bg-rose-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]">
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }}
</div>
</x-dropdown-link>
@endguest
@@ -231,15 +229,11 @@
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600 dark:bg-neutral-900/30">
<div class="flex justify-center">
@if (Auth::user()->avatar)
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ Auth::user()->id }}/{{ Auth::user()->avatar }}.webp"
alt="{{ Auth::user()->getTagAttribute() }}" />
@else
<img class="h-8 w-8 rounded-full object-cover mr-2" src="/images/default-avatar.webp"
alt="Guest" />
@endif
<span class="font-medium text-base text-gray-800 dark:text-neutral-200">{{ Auth::user()->username }}
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="{{ Auth::user()->getAvatar() }}"
alt="{{ Auth::user()->name }}" />
<span class="font-medium text-base text-gray-800 dark:text-neutral-200">
{{ Auth::user()->name }}
</span>
</div>

View File

@@ -34,7 +34,7 @@
@foreach($comments as $comment)
<tr wire:key="comment-{{ $comment->id }}" class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700">
<td class="px-6 py-4">
<a href="{{ route('user.index', ['username' => $comment->username]) }}">{{ $comment->username }}</a>
{{ $comment->name }}
</td>
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
{{ $comment->comment }}

View File

@@ -60,7 +60,7 @@
{{ $user->id }}
</th>
<td class="px-6 py-4">
{{ $user->global_name ?? $user->username }}
{{ $user->name }}
</td>
<td class="px-6 py-4">
{{ $user->is_patreon ? 'Yes' : 'No' }}

View File

@@ -4,20 +4,14 @@
<!-- Header -->
<div class="flex text-sm font-light bg-neutral-950/50 backdrop-blur-lg rounded-lg p-10 gap-2">
<a href="{{ route('user.index', ['username' => $playlist->user->username]) }}">
@if ($playlist->user->avatar)
<img class="relative w-24 h-24 flex-none rounded-full shadow-lg"
src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ $playlist->user->id }}/{{ $playlist->user->avatar }}.webp">
@else
<img class="relative w-24 h-24 flex-none rounded-full shadow-lg" src="/images/default-avatar.webp">
@endif
</a>
<div>
<img class="relative w-24 h-24 flex-none rounded-full shadow-lg"
src="{{ $playlist->user->getAvatar() }}">
</div>
<div class="flex flex-col justify-center flex-1 pl-4">
<h1 class="font-bold text-3xl">{{ $playlist->name }}</h1>
<p class="font-light text-lg text-neutral-200">Episodes: {{ count($playlistEpisodes) }}</p>
<p class="font-light text-lg text-neutral-200">Creator: <a
href="{{ route('user.index', ['username' => $playlist->user->username]) }}">{{ $playlist->user->username }}</a>
</p>
<p class="font-light text-lg text-neutral-200">Creator: {{ $playlist->user->name }}</p>
</div>
<div class="flex flex-col justify-center pl-4">
<div class="flex justify-end">

View File

@@ -5,21 +5,17 @@
@endphp
<div id="comment-{{ $comment->id }}" class="flex rounded-lg bg-white p-1 mb-2 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-900">
@php $user = cache()->rememberForever('commentUser'.$comment->commenter_id, fn () => \App\Models\User::where('id', $comment->commenter_id)->first()); @endphp
<a class="contents" href="{{ route('user.index', ['username' => $user->username]) }}">
@if($user->avatar)
<img class="w-16 h-16 rounded-full m-2" src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ $comment->commenter_id }}/{{ $user->avatar }}.webp" alt="{{ $user->global_name ?? $user->username }} Avatar">
@else
<img class="w-16 h-16 rounded-full m-2" src="/images/default-avatar.webp" alt="{{ $user->global_name ?? $user->username }} Avatar">
@endif
</a>
<div>
<img class="w-16 h-16 rounded-full m-2" src="{{ $user->getAvatar() }}" alt="{{ $user->name }} Avatar">
</div>
<div class="text-gray-800 dark:text-gray-200">
<a href="{{ route('user.index', ['username' => $user->username]) }}">
<div>
@if($user->is_patreon)
<h5 class="text-gray-800 dark:text-gray-400">{{ $user->global_name ?? $user->username }} <a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a> <small class="text-muted">- {{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }}</small></h5>
<h5 class="text-gray-800 dark:text-gray-400">{{ $user->name }} <a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a> <small class="text-muted">- {{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }}</small></h5>
@else
<h5 class="text-gray-800 dark:text-gray-400">{{ $user->global_name ?? $user->username }} <small class="text-muted">- {{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }}</small></h5>
<h5 class="text-gray-800 dark:text-gray-400">{{ $user->name }} <small class="text-muted">- {{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }}</small></h5>
@endif
</a>
</div>
<div style="white-space: pre-wrap;">{!! $markdown->line($comment->comment) !!}</div>
<br />
</div>

View File

@@ -1,78 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('Profile') }}
</h2>
</x-slot>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="flex flex-row gap-4 flex-wrap">
<!-- Profile Image -->
<div class="p-2 bg-white dark:bg-neutral-800 shadow rounded-lg flex-none">
@if($user->avatar)
<img class="w-28 h-28 rounded-lg m-2" src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ $user->id }}/{{ $user->avatar }}.webp" alt="{{ $user->global_name ?? $user->username }} Avatar">
@else
<img class="w-24 h-24 rounded-lg m-2" src="/images/default-avatar.webp" alt="{{ $user->global_name ?? $user->username }} Avatar">
@endif
</div>
<!-- Joined -->
<div class="p-4 sm:p-8 bg-white dark:bg-neutral-800 shadow rounded-lg content-center text-center grow">
<div class="inline-block rounded-md text-sky-500">
<i class="fa-solid fa-clock text-3xl"></i>
</div>
<h5 class="font-medium dark:text-neutral-300">
Joined {{ $user->created_at->format('Y-m') }}
</h5>
</div>
<!-- View Count -->
<div class="p-4 sm:p-8 bg-white dark:bg-neutral-800 shadow rounded-lg content-center text-center grow">
<a href="{{ route('user.watched') }}">
<div class="inline-block rounded-md text-sky-500">
<i class="fa-solid fa-eye text-3xl"></i>
</div>
<h5 class="font-medium dark:text-neutral-300">
{{ number_format($user->watched->count()) }} views
</h5>
</a>
</div>
<!-- Comment Count -->
<div class="p-4 sm:p-8 bg-white dark:bg-neutral-800 shadow rounded-lg content-center text-center grow">
<a href="{{ route('profile.comments') }}">
<div class="inline-block rounded-md text-sky-500">
<i class="fa-solid fa-comment text-3xl"></i>
</div>
<h5 class="font-medium dark:text-neutral-300">
{{ number_format($user->commentCount()) }} comments
</h5>
</a>
</div>
<!-- Likes -->
<div class="p-4 sm:p-8 bg-white dark:bg-neutral-800 shadow rounded-lg content-center text-center grow">
<a href="{{ route('profile.likes') }}">
<div class="inline-block rounded-md text-sky-500">
<i class="fa-solid fa-heart text-3xl"></i>
</div>
<h5 class="font-medium dark:text-neutral-300">
{{ number_format($user->likes()) }} likes
</h5>
</a>
</div>
<!-- Playlists -->
<div class="p-4 sm:p-8 bg-white dark:bg-neutral-800 shadow rounded-lg content-center text-center grow">
<a href="{{ route('profile.playlists') }}">
<div class="inline-block rounded-md text-sky-500">
<i class="fa-solid fa-rectangle-list text-3xl"></i>
</div>
<h5 class="font-medium dark:text-neutral-300">
{{ number_format($user->playlists->count()) }} playlists
</h5>
</a>
</div>
</div>
</div>
</x-app-layout>

View File

@@ -10,32 +10,8 @@
</header>
<x-danger-button
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
>{{ __('Delete Account') }}</x-danger-button>
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
<form method="POST" action="{{ route('profile.delete') }}" class="p-6">
@csrf
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted.') }}
</p>
<div class="mt-6 flex justify-end">
<x-secondary-button x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-danger-button class="ml-3">
{{ __('Delete Account') }}
</x-danger-button>
</div>
</form>
</x-modal>
</section>

View File

@@ -0,0 +1,46 @@
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
<form method="POST" action="{{ route('profile.delete') }}" class="p-6">
@csrf
@method('delete')
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __('Are you sure you want to delete your account?') }}
</h2>
@if (is_null($user->password))
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted.') }}
</p>
@else
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
@endif
@if (!is_null($user->password))
<div class="mt-6">
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
<x-text-input
id="password"
name="password"
type="password"
class="mt-1 block w-3/4"
placeholder="{{ __('Password') }}"
/>
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
</div>
@endif
<div class="mt-6 flex justify-end">
<x-secondary-button x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-danger-button class="ms-3">
{{ __('Delete Account') }}
</x-danger-button>
</div>
</form>
</x-modal>

View File

@@ -7,27 +7,43 @@
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{{ __('Ensure your account is using a long, random password to stay secure.') }}
</p>
@if ($user->discord_id && is_null($user->password))
<div class="p-2 rounded-lg bg-rose-600/80 mt-4">
<p class="p-2 text-sm dark:text-gray-200 text-white">
{{ __('You currently don\'t have a password set, as you use Discord authentication. You can set a password to be able to login with email & password.') }}
</p>
</div>
@elseif ($user->discord_id && !is_null($user->password))
<div class="p-2 rounded-lg bg-green-600/80 mt-4">
<p class="p-2 text-sm dark:text-gray-200 text-white">
{{ __('Both Discord and email login are enabled.') }}
</p>
</div>
@endif
</header>
<form method="post" action="{{ route('password.update') }}" class="mt-6 space-y-6">
@csrf
@method('put')
@if (!(is_null($user->password) && $user->discord_id))
<div>
<x-input-label for="current_password" :value="__('Current Password')" />
<x-text-input id="current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
<x-text-input id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
<x-input-error :messages="$errors->updatePassword->get('current_password')" class="mt-2" />
</div>
@endif
<div>
<x-input-label for="password" :value="__('New Password')" />
<x-text-input id="password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-label for="update_password_password" :value="__('New Password')" />
<x-text-input id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password')" class="mt-2" />
</div>
<div>
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password_confirmation')" class="mt-2" />
</div>

View File

@@ -1,44 +1,100 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
<i class="fa-brands fa-discord"></i> {{ __('Profile Information') }}
{{ __('Profile Information') }}
</h2>
@if ($user->discord_id)
<div class="p-2 rounded-lg bg-rose-600/80 mt-4">
<p class="p-2 text-sm dark:text-gray-200 text-white">
{{ __('Changing your name or email will not affect Discord authentication, as your Discord ID has been stored.') }}
</p>
</div>
@else
<div class="p-2 rounded-lg bg-green-600/80 mt-4">
<p class="p-2 text-sm dark:text-gray-200 text-white">
{{ __('If you want to use Discord authentication, ensure the email addresses match for initial login. After login with Discord, email can be changed.') }}
</p>
</div>
@endif
</header>
<div class="mt-6 space-y-6">
@if (Auth::user()->global_name)
<div>
<x-input-label for="global_name" :value="__('Display Name')" />
<x-text-input id="global_name" name="global_name" type="text" class="mt-1 block w-full" :value="old('global_name', $user->global_name)" required autocomplete="global_name" disabled />
<x-input-error class="mt-2" :messages="$errors->get('global_name')" />
</div>
@endif
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
@csrf
</form>
<form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6" enctype="multipart/form-data">
@csrf
@method('patch')
<div>
<x-input-label for="username" :value="__('Username')" />
<x-text-input id="username" name="username" type="text" class="mt-1 block w-full" :value="old('username', $user->username)" required autocomplete="username" disabled />
<x-input-error class="mt-2" :messages="$errors->get('username')" />
<x-input-label for="image" :value="__('Avatar')" />
<div class="mt-2 flex items-center gap-4">
<img
src="{{ $user->getAvatar() }}"
alt="{{ $user->name }}"
class="h-16 w-16 rounded-full object-cover"
>
<input
id="image"
name="image"
type="file"
accept="image/*"
class="block w-full text-sm text-gray-900 dark:text-gray-100
file:mr-4 file:rounded-md file:border-0
file:bg-rose-600 file:px-4 file:py-2
file:text-sm file:font-semibold file:text-white
hover:file:bg-rose-500"
/>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
JPG, PNG, WebP or GIF. Max 8MB. Will be cropped to 128×128.
</p>
<x-input-error class="mt-2" :messages="$errors->get('image')" />
</div>
@if (!Auth::user()->global_name)
<div>
<x-input-label for="discriminator" :value="__('Discriminator')" />
<x-text-input id="discriminator" name="discriminator" type="text" class="mt-1 block w-full" :value="old('discriminator', $user->discriminator)" required autocomplete="discriminator" disabled />
<x-input-error class="mt-2" :messages="$errors->get('discriminator')" />
</div>
@endif
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
<x-input-error class="mt-2" :messages="$errors->get('name')" />
</div>
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email ?? __('Unknown'))" required autocomplete="email" disabled />
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email)" required autocomplete="email" />
<x-input-error class="mt-2" :messages="$errors->get('email')" />
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->verified)
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
<div>
<p class="text-sm mt-2 text-gray-800 dark:text-gray-200">
{{ __('Your email address is unverified.') }}
<button form="send-verification" class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 font-medium text-sm text-green-600 dark:text-green-400">
{{ __('A new verification link has been sent to your email address.') }}
</p>
@endif
</div>
@endif
</div>
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'profile-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600 dark:text-gray-400"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>

View File

@@ -8,13 +8,16 @@
<!-- Page Content -->
<main>
@include('user.partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-20 mt-[65px] flex flex-row">
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-20 mt-[65px] mb-14 flex flex-row">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mt-8 md:mt-0 space-y-6">
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
@include('profile.partials.update-profile-information-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
@include('profile.partials.update-password-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
@include('profile.partials.update-blacklist-form')
</div>
@@ -24,6 +27,7 @@
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
@include('profile.partials.delete-user-form')
</div>
@include('profile.partials.delete-user-modal')
</div>
@vite(['resources/js/user-blacklist.js'])
</div>

View File

@@ -22,7 +22,7 @@
}
@endphp
<p class="text-neutral-800 dark:text-neutral-300">
{{ $playlist->user->global_name ?? $playlist->user->username }} {{ $currentIndex + 1 }}/{{ $episodeCount }} Episodes
{{ $playlist->user->name }} {{ $currentIndex + 1 }}/{{ $episodeCount }} Episodes
</p>
</div>

View File

@@ -1,14 +1,9 @@
<div
class="overflow-hidden relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl flex items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
@if($user->avatar)
<img class="absolute -left-6 w-24 h-24 rounded-full shadow-lg"
src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ $user->id }}/{{ $user->avatar }}.webp">
@else
<img class="absolute -left-6 w-24 h-24 rounded-full shadow-lg" src="/images/default-avatar.webp">
@endif
<img class="absolute -left-6 w-24 h-24 rounded-full shadow-lg" src="{{ $user->getAvatar() }}">
<div class="flex flex-col py-5 pl-24">
<strong class="text-slate-900 text-xl font-bold dark:text-slate-200">
{{ $user->global_name ?? $user->username }}
{{ $user->name }}
@if ($user->is_patreon)
<a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i
class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a>

View File

@@ -7,22 +7,18 @@
<div id="comment-{{ $comment->getKey() }}" class="flex rounded-lg p-1 mb-2 ">
<a class="contents" href="{{ route('user.index', ['username' => $comment->commenter->username]) }}">
@if($comment->commenter->avatar)
<img class="w-12 h-12 rounded-lg m-2" src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ $comment->commenter->id }}/{{ $comment->commenter->avatar }}.webp" alt="{{ $comment->commenter->global_name ?? $comment->commenter->username }} Avatar">
@else
<img class="w-12 h-12 rounded-lg m-2" src="/images/default-avatar.webp" alt="{{ $comment->commenter->global_name ?? $comment->commenter->username }} Avatar">
@endif
</a>
<div class="contents">
<img class="w-12 h-12 rounded-lg m-2" src="{{ $comment->commenter->getAvatar() }}" alt="{{ $comment->commenter->name }} Avatar">
</div>
<div class="text-gray-800 dark:text-gray-200">
<a href="{{ route('user.index', ['username' => $comment->commenter->username]) }}">
<div>
@if($comment->commenter->is_patreon)
<h5 class="text-gray-800 dark:text-gray-400">{{ $comment->commenter->global_name ?? $comment->commenter->username }} <a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a> <small class="text-muted">- {{ $comment->created_at->diffForHumans() }}</small></h5>
<h5 class="text-gray-800 dark:text-gray-400">{{ $comment->commenter->name }} <a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a> <small class="text-muted">- {{ $comment->created_at->diffForHumans() }}</small></h5>
@else
<h5 class="text-gray-800 dark:text-gray-400">{{ $comment->commenter->global_name ?? $comment->commenter->username }} <small class="text-muted">- {{ $comment->created_at->diffForHumans() }}</small></h5>
<h5 class="text-gray-800 dark:text-gray-400">{{ $comment->commenter->name }} <small class="text-muted">- {{ $comment->created_at->diffForHumans() }}</small></h5>
@endif
</a>
</div>
<div style="white-space: pre-wrap;">{!! $markdown->line($comment->comment) !!}</div>
@if (! Illuminate\Support\Facades\Route::is('profile.comments'))

View File

@@ -0,0 +1,24 @@
@props([
'url',
'color' => 'primary',
'align' => 'center',
])
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{!! $slot !!}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@@ -0,0 +1,11 @@
<tr>
<td>
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>

View File

@@ -0,0 +1,12 @@
@props(['url'])
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
@if (trim($slot) === 'Laravel')
<img src="https://laravel.com/img/notification-logo.png" class="logo" alt="Laravel Logo">
@else
{!! $slot !!}
@endif
</a>
</td>
</tr>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{{ config('app.name') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<style>
@media only screen and (max-width: 600px) {
.inner-body {
width: 100% !important;
}
.footer {
width: 100% !important;
}
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
}
}
</style>
{!! $head ?? '' !!}
</head>
<body>
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
{!! $header ?? '' !!}
<!-- Email Body -->
<tr>
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
{!! Illuminate\Mail\Markdown::parse($slot) !!}
{!! $subcopy ?? '' !!}
</td>
</tr>
</table>
</td>
</tr>
{!! $footer ?? '' !!}
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
<img src="{{ url('/images/hs_banner.png') }}" class="logo" alt="{{ config('app.name') }} Banner">
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{!! $slot !!}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{!! $subcopy !!}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights not reserved.') }}
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View File

@@ -0,0 +1,14 @@
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-content">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-item">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@@ -0,0 +1,7 @@
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>

View File

@@ -0,0 +1,3 @@
<div class="table">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</div>

View File

@@ -0,0 +1,295 @@
/* Base */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: #000;
color: #fff;
height: 100%;
line-height: 1.4;
margin: 0;
padding: 0;
width: 100% !important;
}
p,
ul,
ol,
blockquote {
line-height: 1.4;
text-align: left;
}
a {
color: #3869d4;
}
a img {
border: none;
}
/* Typography */
h1 {
color: #fff;
font-size: 18px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h2 {
font-size: 16px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h3 {
font-size: 14px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
p {
font-size: 16px;
line-height: 1.5em;
margin-top: 0;
text-align: left;
}
p.sub {
font-size: 12px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #000;
margin: 0;
padding: 0;
width: 100%;
}
.content {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 0;
padding: 0;
width: 100%;
}
/* Header */
.header {
padding: 25px 0;
text-align: center;
}
.header a {
color: #fff;
font-size: 19px;
font-weight: bold;
text-decoration: none;
}
/* Logo */
.logo {
height: 75px;
max-height: 75px;
width: 267px;
}
/* Body */
.body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #000;
border-bottom: 1px solid #edf2f7;
border-top: 1px solid #edf2f7;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
background-color: #212121;
border-color: #e8e5ef;
border-radius: 12px;
border-width: 1px;
box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015);
margin: 0 auto;
padding: 0;
width: 570px;
}
.inner-body a {
word-break: break-all;
}
/* Subcopy */
.subcopy {
border-top: 1px solid #e8e5ef;
margin-top: 25px;
padding-top: 25px;
}
.subcopy p {
font-size: 14px;
}
/* Footer */
.footer {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
}
.footer p {
color: #b0adc5;
font-size: 12px;
text-align: center;
}
.footer a {
color: #b0adc5;
text-decoration: underline;
}
/* Tables */
.table table {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
width: 100%;
}
.table th {
border-bottom: 1px solid #edeff2;
margin: 0;
padding-bottom: 8px;
}
.table td {
color: #74787e;
font-size: 15px;
line-height: 18px;
margin: 0;
padding: 10px 0;
}
.content-cell {
max-width: 100vw;
padding: 32px;
}
/* Buttons */
.action {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
width: 100%;
float: unset;
}
.button {
-webkit-text-size-adjust: none;
border-radius: 12px;
color: #fff;
display: inline-block;
overflow: hidden;
text-decoration: none;
}
.button-blue,
.button-primary {
background-color: #be123c;
border-bottom: 8px solid #be123c;
border-left: 18px solid #be123c;
border-right: 18px solid #be123c;
border-top: 8px solid #be123c;
}
.button-green,
.button-success {
background-color: #48bb78;
border-bottom: 8px solid #48bb78;
border-left: 18px solid #48bb78;
border-right: 18px solid #48bb78;
border-top: 8px solid #48bb78;
}
.button-red,
.button-error {
background-color: #e53e3e;
border-bottom: 8px solid #e53e3e;
border-left: 18px solid #e53e3e;
border-right: 18px solid #e53e3e;
border-top: 8px solid #e53e3e;
}
/* Panels */
.panel {
border-left: #2d3748 solid 4px;
margin: 21px 0;
}
.panel-content {
background-color: #edf2f7;
color: #718096;
padding: 16px;
}
.panel-content p {
color: #718096;
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Utilities */
.break-all {
word-break: break-all;
}

View File

@@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@@ -0,0 +1 @@
{{ $slot }}

View File

@@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@@ -0,0 +1,9 @@
{!! strip_tags($header ?? '') !!}
{!! strip_tags($slot) !!}
@isset($subcopy)
{!! strip_tags($subcopy) !!}
@endisset
{!! strip_tags($footer ?? '') !!}

View File

@@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{{ $slot }}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View File

@@ -0,0 +1 @@
{{ $slot }}

View File

@@ -0,0 +1 @@
{{ $slot }}

View File

@@ -0,0 +1 @@
{{ $slot }}

64
routes/auth.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\ConfirmablePasswordController;
use App\Http\Controllers\Auth\DiscordAuthController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\EmailVerificationPromptController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::post('register', [RegisteredUserController::class, 'store'])
->name('register');
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']);
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
->name('password.request');
Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
->name('password.email');
Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('reset-password', [NewPasswordController::class, 'store'])
->name('password.store');
// Discord OAuth (Socialite)
Route::get('/auth/discord', [DiscordAuthController::class, 'redirect'])
->name('discord.login');
Route::get('/auth/discord/callback', [DiscordAuthController::class, 'callback']);
});
Route::middleware('auth')->group(function () {
Route::get('verify-email', EmailVerificationPromptController::class)
->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware('throttle:6,1')
->name('verification.send');
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
->name('password.confirm');
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
Route::put('password', [PasswordController::class, 'update'])->name('password.update');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->name('logout');
});

View File

@@ -63,13 +63,14 @@ Route::middleware('auth')->group(function () {
Route::get('/user/notifications', [App\Http\Controllers\NotificationController::class, 'index'])->name('profile.notifications');
Route::delete('/user/notifications', [App\Http\Controllers\NotificationController::class, 'delete'])->name('profile.notifications.delete');
// User Profile Actions
Route::get('/user/settings', [ProfileController::class, 'settings'])->name('profile.settings');
Route::patch('/user/settings', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/user/delete', [ProfileController::class, 'destroy'])->name('profile.delete');
Route::post('/user/settings', [ProfileController::class, 'saveSettings'])->name('profile.settings.save');
Route::get('/user/blacklist', [UserApiController::class, 'getBlacklist'])->name('profile.blacklist');
Route::post('/user/blacklist', [ProfileController::class, 'saveBlacklist'])->name('profile.blacklist.save');
Route::post('/user/delete', [UserController::class, 'delete'])->name('profile.delete');
// Playlist Routes for User Page
Route::get('/user/playlists', [PlaylistController::class, 'playlists'])->name('profile.playlists');
Route::get('/user/playlist/{playlist_id}', [PlaylistController::class, 'showPlaylist'])->name('profile.playlist.show');
@@ -85,8 +86,6 @@ Route::middleware('auth')->group(function () {
Route::get('/download-search', [HomeController::class, 'downloadSearch'])->name('download.search');
});
Route::get('/user/{username}', [UserController::class, 'index'])->name('user.index');
/*
|---------------------------------------------------------------------------------
| Admin Pages
@@ -136,3 +135,5 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () {
Route::post('/admin/add-new-subtitle', [App\Http\Controllers\Admin\SubtitleController::class, 'store'])->name('admin.add.new.subtitle');
Route::post('/admin/update-subtitles', [App\Http\Controllers\Admin\SubtitleController::class, 'update'])->name('admin.update.subtitles');
});
require __DIR__.'/auth.php';

View File

@@ -0,0 +1,54 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered(): void
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_users_can_not_authenticate_with_invalid_password(): void
{
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
public function test_users_can_logout(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/logout');
$this->assertGuest();
$response->assertRedirect('/');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_email_verification_screen_can_be_rendered(): void
{
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get('/verify-email');
$response->assertStatus(200);
}
public function test_email_can_be_verified(): void
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash(): void
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/confirm-password');
$response->assertStatus(200);
}
public function test_password_can_be_confirmed(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'password',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
}
public function test_password_is_not_confirmed_with_invalid_password(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors();
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_reset_password_link_screen_can_be_rendered(): void
{
$response = $this->get('/forgot-password');
$response->assertStatus(200);
}
public function test_reset_password_link_can_be_requested(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response->assertStatus(200);
return true;
});
}
public function test_password_can_be_reset_with_valid_token(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class PasswordUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_password_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrorsIn('updatePassword', 'current_password')
->assertRedirect('/profile');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered(): void
{
$response = $this->get('/register');
$response->assertStatus(200);
}
public function test_new_users_can_register(): void
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProfileTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get('/profile');
$response->assertOk();
}
public function test_profile_information_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete('/profile', [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->delete('/profile', [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile');
$this->assertNotNull($user->fresh());
}
}