From 88343da88a1f4f74880d3d36e88e975d20160da5 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Sun, 10 Aug 2025 23:47:11 +0200 Subject: [PATCH] Add custom invitation system --- .../Controllers/Auth/RegisterController.php | 123 ++++++- app/Http/Controllers/InvitationController.php | 305 ++++++++++++++++++ app/Mail/InvitationMail.php | 62 ++++ app/Models/Invitation.php | 289 ++++++++++++++--- app/Models/User.php | 24 +- app/Services/InvitationService.php | 259 +++++++++++++++ composer.json | 1 - composer.lock | 64 +--- config/larainvite.php | 29 -- ..._01_01_000000_create_invitations_table.php | 150 +++++++++ ...10_195505_fix_invitations_column_names.php | 133 ++++++++ docker/{8.3 => 8.4}/Dockerfile | 16 +- docker/{8.3 => 8.4}/README.md | 2 +- docker/{8.3 => 8.4}/nginx.conf | 4 +- docker/{8.3 => 8.4}/php.ini | 0 docker/{8.3 => 8.4}/scheduler | 0 docker/{8.3 => 8.4}/start-container | 0 docker/{8.3 => 8.4}/supervisord.conf | 2 +- resources/views/emails/invitation.blade.php | 99 ++++++ resources/views/themes/Gentele/headermenu.tpl | 6 + .../themes/Gentele/invitations_create.tpl | 171 ++++++++++ .../themes/Gentele/invitations_index.tpl | 222 +++++++++++++ .../views/themes/Gentele/invitations_show.tpl | 143 ++++++++ resources/views/themes/Gentele/profile.tpl | 4 +- routes/web.php | 17 + 25 files changed, 1947 insertions(+), 178 deletions(-) create mode 100644 app/Http/Controllers/InvitationController.php create mode 100644 app/Mail/InvitationMail.php create mode 100644 app/Services/InvitationService.php delete mode 100644 config/larainvite.php create mode 100644 database/migrations/2025_01_01_000000_create_invitations_table.php create mode 100644 database/migrations/2025_08_10_195505_fix_invitations_column_names.php rename docker/{8.3 => 8.4}/Dockerfile (83%) rename docker/{8.3 => 8.4}/README.md (98%) rename docker/{8.3 => 8.4}/nginx.conf (96%) rename docker/{8.3 => 8.4}/php.ini (100%) rename docker/{8.3 => 8.4}/scheduler (100%) rename docker/{8.3 => 8.4}/start-container (100%) rename docker/{8.3 => 8.4}/supervisord.conf (94%) create mode 100644 resources/views/emails/invitation.blade.php create mode 100644 resources/views/themes/Gentele/invitations_create.tpl create mode 100644 resources/views/themes/Gentele/invitations_index.tpl create mode 100644 resources/views/themes/Gentele/invitations_show.tpl diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 5c78babce..009afd92c 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -17,7 +17,6 @@ use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rules\Password; use Illuminate\Validation\ValidationException; use Jrean\UserVerification\Traits\VerifiesUsers; -use Junaidnasir\Larainvite\Facades\Invite; use Spatie\Permission\Models\Role; class RegisterController extends Controller @@ -121,6 +120,12 @@ class RegisterController extends Controller $this->inviteCodeQuery = '&invitecode='.$inviteCode; } + // Handle invitation token from URL (for email links) + if ($request->has('token')) { + $inviteCode = $request->input('token'); + $this->inviteCodeQuery = '&token='.$inviteCode; + } + $validator = Validator::make($request->all(), [ 'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users', 'indisposable'], @@ -157,19 +162,51 @@ class RegisterController extends Controller return $this->showRegistrationForm($request, $error); } - if (Invite::isAllowed($inviteCode, $email) || Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE) { - $user = $this->create( - [ - 'username' => $userName, - 'password' => $password, - 'email' => $email, - 'host' => $request->ip(), - 'roles_id' => $userDefault !== null ? $userDefault['id'] : User::ROLE_USER, - 'notes' => '', - 'defaultinvites' => $userDefault !== null ? $userDefault['defaultinvites'] : Invitation::DEFAULT_INVITES, - ] - ); - Invite::consume($inviteCode); + // Check invitation validity using custom system + $invitationValid = $this->isInvitationValid($inviteCode, $email); + $registrationOpen = Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE; + + if ($invitationValid || $registrationOpen) { + // Get invited_by from invitation if available + $invitedBy = 0; + $invitation = null; + + if (!empty($inviteCode)) { + $invitation = Invitation::findValidByToken($inviteCode); + if ($invitation) { + $invitedBy = $invitation->invited_by; + + // Validate email matches invitation + if (!empty($invitation->email) && $invitation->email !== $email) { + $error = 'Email address does not match the invitation.'; + return $this->showRegistrationForm($request, $error); + } + } + } + + $userData = [ + 'username' => $userName, + 'password' => $password, + 'email' => $email, + 'host' => $request->ip(), + 'roles_id' => $userDefault !== null ? $userDefault['id'] : User::ROLE_USER, + 'notes' => '', + 'defaultinvites' => $userDefault !== null ? $userDefault['defaultinvites'] : Invitation::DEFAULT_INVITES, + ]; + + // Apply invitation metadata if available + if ($invitation && $invitation->metadata) { + if (isset($invitation->metadata['role'])) { + $userData['roles_id'] = $invitation->metadata['role']; + } + } + + $user = $this->create($userData); + + // Mark invitation as used + if ($invitation) { + $invitation->markAsUsed($user->id); + } // Create verification message that will be shown on login page $notificationMessage = 'Your Account has been created. A verification email has been sent to your email address. You will be able to log in after completing the verification process.'; @@ -186,10 +223,13 @@ class RegisterController extends Controller return redirect('/login'); } + + $error = 'Invalid or expired invitation token!'; break; + case 'view': // See if it is a valid invite. - if (($inviteCode !== null) && ! Invite::isValid($inviteCode)) { + if (($inviteCode !== null) && ! $this->isInvitationTokenValid($inviteCode)) { $error = 'Invalid invitation token!'; $showRegister = 0; } else { @@ -197,6 +237,7 @@ class RegisterController extends Controller } break; } + app('smarty.view')->assign( [ 'username' => e($userName), @@ -220,11 +261,23 @@ class RegisterController extends Controller $this->inviteCodeQuery = '&invitecode='.$inviteCode; } + // Handle invitation token from URL (for email links) + if ($request->has('token')) { + $inviteCode = $request->input('token'); + $this->inviteCodeQuery = '&token='.$inviteCode; + } + if ((int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE) { if (! empty($inviteCode)) { - if (Invite::isValid($inviteCode)) { + if ($this->isInvitationTokenValid($inviteCode)) { $error = ''; $showRegister = 1; + + // Pre-fill email if invitation has one + $invitation = Invitation::findValidByToken($inviteCode); + if ($invitation && !empty($invitation->email)) { + app('smarty.view')->assign('email', $invitation->email); + } } else { $error = 'Invalid or expired invitation token!'; $showRegister = 0; @@ -236,7 +289,7 @@ class RegisterController extends Controller } elseif ((int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_CLOSED) { $error = 'Registrations are currently closed.'; $showRegister = 0; - } elseif ($request->has('invitecode')) { + } elseif ($request->has('invitecode') || $request->has('token')) { $error = 'Registration is open, you don\'t need the invite code to register.'; $showRegister = 0; } else { @@ -258,4 +311,40 @@ class RegisterController extends Controller app('smarty.view')->assign(compact('content', 'meta_title', 'meta_keywords', 'meta_description', 'nocaptcha')); app('smarty.view')->display($theme.'/basepage.tpl'); } + + /** + * Check if invitation is valid for given email + */ + private function isInvitationValid(string $token, string $email): bool + { + if (empty($token)) { + return false; + } + + $invitation = Invitation::findValidByToken($token); + + if (!$invitation) { + return false; + } + + // If invitation has specific email, validate it matches + if (!empty($invitation->email) && $invitation->email !== $email) { + return false; + } + + return true; + } + + /** + * Check if invitation token is valid (without email check) + */ + private function isInvitationTokenValid(string $token): bool + { + if (empty($token)) { + return false; + } + + $invitation = Invitation::findValidByToken($token); + return $invitation !== null; + } } diff --git a/app/Http/Controllers/InvitationController.php b/app/Http/Controllers/InvitationController.php new file mode 100644 index 000000000..2f2bfbf1f --- /dev/null +++ b/app/Http/Controllers/InvitationController.php @@ -0,0 +1,305 @@ +invitationService = $invitationService; + $this->middleware('auth')->except(['show', 'accept']); + } + + /** + * Display a listing of the user's invitations + */ + public function index(Request $request): void + { + $this->setPreferences(); + + $status = $request->get('status'); + $user = auth()->user(); + + $invitations = $this->invitationService->getUserInvitations($user->id, $status); + $stats = $this->invitationService->getUserInvitationStats($user->id); + + // Convert paginated results to array for Smarty + $invitationsArray = []; + foreach ($invitations as $invitation) { + $invitationData = $invitation->toArray(); + + // Add related user data + if ($invitation->usedBy) { + $invitationData['used_by_user'] = $invitation->usedBy->toArray(); + } + + // Convert timestamps for Smarty + $invitationData['created_at'] = strtotime($invitation->created_at); + $invitationData['expires_at'] = strtotime($invitation->expires_at); + if ($invitation->used_at) { + $invitationData['used_at'] = strtotime($invitation->used_at); + } + + $invitationsArray[] = $invitationData; + } + + $this->smarty->assign([ + 'invitations' => $invitationsArray, + 'stats' => $stats, + 'status' => $status, + 'pagination_links' => $invitations->links(), + 'csrf_token' => csrf_token(), + ]); + + // Set meta information + $meta_title = 'My Invitations'; + $meta_keywords = 'invitations,invite,users,manage'; + $meta_description = 'Manage your sent invitations and send new invitations to friends'; + + // Fetch the template content + $content = $this->smarty->fetch('invitations_index.tpl'); + + // Assign content and meta data for final rendering + $this->smarty->assign([ + 'content' => $content, + 'meta_title' => $meta_title, + 'meta_keywords' => $meta_keywords, + 'meta_description' => $meta_description, + ]); + + // Render the page with proper styling + $this->pagerender(); + } + + /** + * Show the form for creating a new invitation + */ + public function create(): void + { + $this->setPreferences(); + + $user = auth()->user(); + + // Calculate available invites (total - active pending invitations) + $activeInvitations = Invitation::where('invited_by', $user->id) + ->where('is_active', true) + ->where('used_at', null) + ->where('expires_at', '>', now()) + ->count(); + + $availableInvites = $user->invites - $activeInvitations; + + $this->smarty->assign([ + 'user_roles' => config('nntmux.user_roles', []), + 'csrf_token' => csrf_token(), + 'old' => old(), + 'errors' => session('errors') ? session('errors')->getBag('default')->getMessages() : [], + 'user_invites_left' => $availableInvites, + 'user_invites_total' => $user->invites, + 'user_invites_pending' => $activeInvitations, + 'can_send_invites' => $availableInvites > 0, + ]); + + // Set meta information + $meta_title = 'Send New Invitation'; + $meta_keywords = 'invitation,invite,send,new,user'; + $meta_description = 'Send a new invitation to invite someone to join the site'; + + // Fetch the template content + $content = $this->smarty->fetch('invitations_create.tpl'); + + // Assign content and meta data for final rendering + $this->smarty->assign([ + 'content' => $content, + 'meta_title' => $meta_title, + 'meta_keywords' => $meta_keywords, + 'meta_description' => $meta_description, + ]); + + // Render the page with proper styling + $this->pagerender(); + } + + /** + * Store a newly created invitation + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'email' => 'required|email|unique:users,email', + 'expiry_days' => 'sometimes|integer|min:1|max:30', + 'role' => 'sometimes|integer|in:' . implode(',', array_keys(config('nntmux.user_roles', []))), + ]); + + try { + $expiryDays = $request->get('expiry_days', Invitation::DEFAULT_INVITE_EXPIRY_DAYS); + $metadata = []; + + if ($request->has('role')) { + $metadata['role'] = $request->get('role'); + } + + $invitation = $this->invitationService->createAndSendInvitation( + $request->email, + auth()->id(), + $expiryDays, + $metadata + ); + + return redirect()->route('invitations.index') + ->with('success', 'Invitation sent successfully to ' . $request->email); + + } catch (\Exception $e) { + return redirect()->back() + ->withInput() + ->with('error', $e->getMessage()); + } + } + + /** + * Display the specified invitation + */ + public function show(string $token): void + { + // For public invitation page, we need to handle both logged in and guest users + if (auth()->check()) { + $this->setPreferences(); + } else { + // Set up basic Smarty configuration for guests + $this->smarty->setTemplateDir([ + 'user' => config('ytake-laravel-smarty.template_path').'/Gentele', + 'shared' => config('ytake-laravel-smarty.template_path').'/shared', + 'default' => config('ytake-laravel-smarty.template_path').'/Gentele', + ]); + + $this->smarty->assign([ + 'isadmin' => false, + 'ismod' => false, + 'loggedin' => false, + 'theme' => 'Gentele', + 'site' => $this->settings, + ]); + } + + $preview = $this->invitationService->getInvitationPreview($token); + + // Convert timestamps for Smarty + if ($preview && isset($preview['expires_at'])) { + $preview['expires_at'] = strtotime($preview['expires_at']); + } + + // Add role name if role is set + if ($preview && isset($preview['metadata']['role'])) { + $roles = config('nntmux.user_roles', []); + $preview['role_name'] = $roles[$preview['metadata']['role']] ?? 'Default'; + } + + $this->smarty->assign([ + 'preview' => $preview, + 'token' => $token, + ]); + + // Set meta information + $meta_title = $preview ? 'Invitation to Join' : 'Invalid Invitation'; + $meta_keywords = 'invitation,join,register,signup'; + $meta_description = $preview ? 'You have been invited to join our community' : 'This invitation link is invalid or expired'; + + // Fetch the template content + $content = $this->smarty->fetch('invitations_show.tpl'); + + // Assign content and meta data for final rendering + $this->smarty->assign([ + 'content' => $content, + 'meta_title' => $meta_title, + 'meta_keywords' => $meta_keywords, + 'meta_description' => $meta_description, + ]); + + // Render the page with proper styling + $this->pagerender(); + } + + /** + * Resend an invitation + */ + public function resend(int $id): RedirectResponse + { + try { + $invitation = Invitation::findOrFail($id); + + // Check if user owns this invitation + if ($invitation->invited_by !== auth()->id()) { + abort(403, 'Unauthorized'); + } + + $this->invitationService->resendInvitation($id); + + return redirect()->route('invitations.index') + ->with('success', 'Invitation resent successfully'); + + } catch (\Exception $e) { + return redirect()->back() + ->with('error', $e->getMessage()); + } + } + + /** + * Cancel an invitation + */ + public function destroy(int $id): RedirectResponse + { + try { + $invitation = Invitation::findOrFail($id); + + // Check if user owns this invitation + if ($invitation->invited_by !== auth()->id()) { + abort(403, 'Unauthorized'); + } + + $this->invitationService->cancelInvitation($id); + + return redirect()->route('invitations.index') + ->with('success', 'Invitation cancelled successfully'); + + } catch (\Exception $e) { + return redirect()->back() + ->with('error', $e->getMessage()); + } + } + + /** + * Get invitation statistics (API endpoint) + */ + public function stats(): JsonResponse + { + $stats = $this->invitationService->getUserInvitationStats(auth()->id()); + return response()->json($stats); + } + + /** + * Clean up expired invitations (admin only) + */ + public function cleanup(): JsonResponse + { + // Check if user is admin + if (!auth()->user()->hasRole('admin')) { + abort(403, 'Unauthorized'); + } + + $cleanedCount = $this->invitationService->cleanupExpiredInvitations(); + + return response()->json([ + 'message' => "Cleaned up {$cleanedCount} expired invitations", + 'count' => $cleanedCount + ]); + } +} diff --git a/app/Mail/InvitationMail.php b/app/Mail/InvitationMail.php new file mode 100644 index 000000000..f59657ddd --- /dev/null +++ b/app/Mail/InvitationMail.php @@ -0,0 +1,62 @@ +invitation = $invitation; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + subject: 'You\'re invited to join ' . config('app.name'), + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + view: 'emails.invitation', + with: [ + 'invitation' => $this->invitation, + 'invitedBy' => $this->invitation->invitedBy, + 'registerUrl' => route('register', ['token' => $this->invitation->token]), + 'expiresAt' => $this->invitation->expires_at, + 'siteName' => config('app.name'), + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/Invitation.php b/app/Models/Invitation.php index 6b20212ee..1ba39f651 100644 --- a/app/Models/Invitation.php +++ b/app/Models/Invitation.php @@ -2,77 +2,282 @@ namespace App\Models; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Str; /** - * App\Models\Invitation. + * App\Models\Invitation * * @property int $id - * @property string $guid - * @property int $users_id - * @property string|null $created_at - * @property string|null $updated_at - * @property-read User $user - * - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereCreatedAt($value) - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereGuid($value) - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereId($value) - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereUpdatedAt($value) - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereUsersId($value) - * - * @mixin \Eloquent + * @property string $token + * @property string $email + * @property int $invited_by + * @property \Illuminate\Support\Carbon $expires_at + * @property \Illuminate\Support\Carbon|null $used_at + * @property int|null $used_by + * @property bool $is_active + * @property array|null $metadata + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\User $invitedBy + * @property-read \App\Models\User|null $usedBy * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation newQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation query() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation active() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation valid() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation expired() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation unused() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation used() */ class Invitation extends Model { public const DEFAULT_INVITES = 1; - public const DEFAULT_INVITE_EXPIRY_DAYS = 7; - /** - * @var bool - */ - public $timestamps = true; - - /** - * @var bool - */ - protected $dateFormat = false; - /** * @var array */ - protected $guarded = ['id']; + protected $fillable = [ + 'token', + 'email', + 'invited_by', + 'expires_at', + 'used_at', + 'used_by', + 'is_active', + 'metadata', + ]; - public function user(): BelongsTo - { - return $this->belongsTo(User::class, 'users_id'); - } + /** + * @var array + */ + protected $casts = [ + 'expires_at' => 'datetime', + 'used_at' => 'datetime', + 'is_active' => 'boolean', + 'metadata' => 'array', + ]; - public static function addInvite(int $uid, string $inviteToken) + /** + * @var array + */ + protected $dates = [ + 'expires_at', + 'used_at', + 'created_at', + 'updated_at', + ]; + + /** + * Get the user who created this invitation + */ + public function invitedBy(): BelongsTo { - self::create(['guid' => $inviteToken, 'users_id' => $uid]); + return $this->belongsTo(User::class, 'invited_by'); } /** - * @return Model|null|static + * Get the user who used this invitation */ - public static function getInvite($inviteToken) + public function usedBy(): BelongsTo { - // - // Tidy any old invites sent greater than DEFAULT_INVITE_EXPIRY_DAYS days ago. - // - self::query()->where('created_at', '<', now()->subDays(self::DEFAULT_INVITE_EXPIRY_DAYS)); - - return self::query()->where('guid', $inviteToken)->first(); + return $this->belongsTo(User::class, 'used_by'); } - public static function deleteInvite(string $inviteToken): void + /** + * Scope to get only active invitations + */ + public function scopeActive(Builder $query): Builder { - self::query()->where('guid', $inviteToken)->delete(); + return $query->where('is_active', true); + } + + /** + * Scope to get valid (active and not expired) invitations + */ + public function scopeValid(Builder $query): Builder + { + return $query->active() + ->where('expires_at', '>', now()) + ->whereNull('used_at'); + } + + /** + * Scope to get expired invitations + */ + public function scopeExpired(Builder $query): Builder + { + return $query->where('expires_at', '<=', now()); + } + + /** + * Scope to get unused invitations + */ + public function scopeUnused(Builder $query): Builder + { + return $query->whereNull('used_at'); + } + + /** + * Scope to get used invitations + */ + public function scopeUsed(Builder $query): Builder + { + return $query->whereNotNull('used_at'); + } + + /** + * Check if the invitation is valid + */ + public function isValid(): bool + { + return $this->is_active && + $this->expires_at->isFuture() && + is_null($this->used_at); + } + + /** + * Check if the invitation is expired + */ + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + /** + * Check if the invitation has been used + */ + public function isUsed(): bool + { + return !is_null($this->used_at); + } + + /** + * Mark the invitation as used + */ + public function markAsUsed(int $userId): bool + { + $this->used_at = now(); + $this->used_by = $userId; + $this->is_active = false; + + return $this->save(); + } + + /** + * Mark the invitation as expired + */ + public function markAsExpired(): bool + { + $this->is_active = false; + return $this->save(); + } + + /** + * Create a new invitation + */ + public static function createInvitation( + string $email, + int $invitedBy, + int $expiryDays = self::DEFAULT_INVITE_EXPIRY_DAYS, + array $metadata = [] + ): self { + return self::create([ + 'token' => Str::random(64), + 'email' => strtolower(trim($email)), + 'invited_by' => $invitedBy, + 'expires_at' => now()->addDays($expiryDays), + 'metadata' => $metadata, + ]); + } + + /** + * Find invitation by token + */ + public static function findByToken(string $token): ?self + { + return self::where('token', $token)->first(); + } + + /** + * Find valid invitation by token + */ + public static function findValidByToken(string $token): ?self + { + return self::valid()->where('token', $token)->first(); + } + + /** + * Find invitation by email + */ + public static function findByEmail(string $email): ?self + { + return self::where('email', strtolower(trim($email)))->first(); + } + + /** + * Get invitation by token (legacy compatibility) + */ + public static function getInvite(string $token): ?self + { + return self::findValidByToken($token); + } + + /** + * Delete invitation by token (legacy compatibility) + */ + public static function deleteInvite(string $token): bool + { + $invitation = self::findByToken($token); + if ($invitation) { + return $invitation->delete(); + } + return false; + } + + /** + * Add invite (legacy compatibility) + */ + public static function addInvite(int $userId, string $token): self + { + // For legacy compatibility, we create an invitation with a specific token + // In practice, this should use createInvitation method instead + return self::create([ + 'token' => $token, + 'email' => '', // Legacy method doesn't specify email + 'invited_by' => $userId, + 'expires_at' => now()->addDays(self::DEFAULT_INVITE_EXPIRY_DAYS), + ]); + } + + /** + * Clean up expired invitations + */ + public static function cleanupExpired(): int + { + $expiredCount = self::expired()->active()->count(); + self::expired()->active()->update(['is_active' => false]); + + return $expiredCount; + } + + /** + * Get invitation statistics + */ + public static function getStats(): array + { + return [ + 'total' => self::count(), + 'active' => self::active()->count(), + 'valid' => self::valid()->count(), + 'expired' => self::expired()->count(), + 'used' => self::used()->count(), + 'unused' => self::unused()->count(), + ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 5cbcdbef7..2e6ee8966 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,7 +4,6 @@ namespace App\Models; use App\Jobs\SendAccountExpiredEmail; use App\Jobs\SendAccountWillExpireEmail; -use App\Jobs\SendInviteEmail; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -24,8 +23,6 @@ use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Jrean\UserVerification\Traits\UserVerification; -use Junaidnasir\Larainvite\Facades\Invite; -use Junaidnasir\Larainvite\InviteTrait; use Spatie\Permission\Models\Role; use Spatie\Permission\Traits\HasRoles; @@ -148,7 +145,7 @@ use Spatie\Permission\Traits\HasRoles; */ class User extends Authenticatable { - use HasRoles, InviteTrait, Notifiable, SoftDeletes, UserVerification; + use HasRoles, Notifiable, SoftDeletes, UserVerification; public const ERR_SIGNUP_BADUNAME = -1; @@ -573,15 +570,15 @@ class User extends Authenticatable */ public static function checkAndUseInvite(string $inviteCode): int { - $invite = Invitation::getInvite($inviteCode); + $invite = Invitation::findValidByToken($inviteCode); if (! $invite) { return -1; } - self::query()->where('id', $invite['users_id'])->decrement('invites'); - Invitation::deleteInvite($inviteCode); + self::query()->where('id', $invite->invited_by)->decrement('invites'); + $invite->markAsUsed(0); // Will be updated with actual user ID later - return $invite['users_id']; + return $invite->invited_by; } /** @@ -669,11 +666,14 @@ class User extends Authenticatable public static function sendInvite($serverUrl, $uid, $emailTo): string { $user = self::find($uid); - $token = Invite::invite($emailTo, $user->id); - $url = $serverUrl.'/register?invitecode='.$token; - Invitation::addInvite($uid, $token); - SendInviteEmail::dispatch($emailTo, $user, $url)->onQueue('emails'); + // Create invitation using our custom system + $invitation = Invitation::createInvitation($emailTo, $user->id); + $url = $serverUrl.'/register?token='.$invitation->token; + + // Send invitation email + $invitationService = app(InvitationService::class); + $invitationService->sendInvitationEmail($invitation); return $url; } diff --git a/app/Services/InvitationService.php b/app/Services/InvitationService.php new file mode 100644 index 000000000..1ecfa91cb --- /dev/null +++ b/app/Services/InvitationService.php @@ -0,0 +1,259 @@ +where('is_active', true) + ->where('used_at', null) + ->where('expires_at', '>', now()) + ->count(); + + // Check if user has invites available (total invites - active pending invitations) + $availableInvites = $user->invites - $activeInvitations; + if ($availableInvites <= 0) { + throw new \Exception('You have no invitations available. You have ' . $activeInvitations . ' pending invitation(s). Contact an administrator if you need more invitations.'); + } + + // Validate email + $validator = Validator::make(['email' => $email], [ + 'email' => 'required|email|unique:users,email' + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + // Check if there's already a valid invitation for this email + $existingInvitation = Invitation::where('email', strtolower(trim($email))) + ->valid() + ->first(); + + if ($existingInvitation) { + throw new \Exception('A valid invitation already exists for this email address.'); + } + + // Create the invitation (don't decrement invites here - only when used) + $invitation = Invitation::createInvitation($email, $invitedBy, $expiryDays, $metadata); + + // Send the invitation email + $this->sendInvitationEmail($invitation); + + return $invitation; + } + + /** + * Send invitation email + */ + public function sendInvitationEmail(Invitation $invitation): void + { + Mail::to($invitation->email)->send(new InvitationMail($invitation)); + } + + /** + * Resend an invitation + */ + public function resendInvitation(int $invitationId): Invitation + { + $invitation = Invitation::findOrFail($invitationId); + + if (!$invitation->isValid()) { + throw new \Exception('Cannot resend an invalid invitation.'); + } + + $this->sendInvitationEmail($invitation); + + return $invitation; + } + + /** + * Cancel an invitation + */ + public function cancelInvitation(int $invitationId): bool + { + $invitation = Invitation::findOrFail($invitationId); + + if ($invitation->isUsed()) { + throw new \Exception('Cannot cancel a used invitation.'); + } + + $invitation->is_active = false; + return $invitation->save(); + } + + /** + * Accept an invitation (use it for registration) + */ + public function acceptInvitation(string $token, array $userData): User + { + $invitation = Invitation::findValidByToken($token); + + if (!$invitation) { + throw new \Exception('Invalid or expired invitation token.'); + } + + // Get the user who sent the invitation + $inviter = User::find($invitation->invited_by); + + // Create the user + $user = User::create(array_merge($userData, [ + 'email' => $invitation->email, + 'invited_by' => $invitation->invited_by, + ])); + + // Mark invitation as used + $invitation->markAsUsed($user->id); + + // Now decrement the inviter's available invites (only when actually used) + if ($inviter) { + $inviter->decrement('invites'); + } + + return $user; + } + + /** + * Get invitation statistics for a user + */ + public function getUserInvitationStats(int $userId): array + { + return [ + 'sent' => Invitation::where('invited_by', $userId)->count(), + 'pending' => Invitation::where('invited_by', $userId)->valid()->count(), + 'used' => Invitation::where('invited_by', $userId)->used()->count(), + 'expired' => Invitation::where('invited_by', $userId)->expired()->count(), + ]; + } + + /** + * Get all invitations for a user + */ + public function getUserInvitations(int $userId, ?string $status = null) + { + $query = Invitation::where('invited_by', $userId) + ->with(['usedBy']) + ->orderBy('created_at', 'desc'); + + return match($status) { + 'valid' => $query->valid()->paginate(15), + 'used' => $query->used()->paginate(15), + 'expired' => $query->expired()->paginate(15), + 'pending' => $query->unused()->paginate(15), + default => $query->paginate(15), + }; + } + + /** + * Clean up expired invitations + */ + public function cleanupExpiredInvitations(): int + { + return Invitation::cleanupExpired(); + } + + /** + * Check if a user can send more invitations + */ + public function canUserSendInvitation(int $userId, int $maxInvitations = null): bool + { + if ($maxInvitations === null) { + return true; // No limit set + } + + $sentCount = Invitation::where('invited_by', $userId)->count(); + return $sentCount < $maxInvitations; + } + + /** + * Get invitation by token for preview + */ + public function getInvitationPreview(string $token): ?array + { + $invitation = Invitation::findByToken($token); + + if (!$invitation) { + return null; + } + + return [ + 'email' => $invitation->email, + 'invited_by' => $invitation->invitedBy->username ?? 'Unknown', + 'expires_at' => $invitation->expires_at, + 'is_valid' => $invitation->isValid(), + 'is_expired' => $invitation->isExpired(), + 'is_used' => $invitation->isUsed(), + 'metadata' => $invitation->metadata, + ]; + } + + /** + * Get detailed invitation information for a user (for debugging) + */ + public function getUserInvitationDetails(int $userId): array + { + $user = User::find($userId); + if (!$user) { + return []; + } + + $totalInvites = $user->invites; + + $activeInvitations = Invitation::where('invited_by', $userId) + ->where('is_active', true) + ->where('used_at', null) + ->where('expires_at', '>', now()) + ->count(); + + $usedInvitations = Invitation::where('invited_by', $userId) + ->whereNotNull('used_at') + ->count(); + + $expiredInvitations = Invitation::where('invited_by', $userId) + ->where('expires_at', '<=', now()) + ->where('used_at', null) + ->count(); + + $cancelledInvitations = Invitation::where('invited_by', $userId) + ->where('is_active', false) + ->where('used_at', null) + ->count(); + + $availableInvites = $totalInvites - $activeInvitations; + + return [ + 'user_id' => $userId, + 'username' => $user->username, + 'total_invites' => $totalInvites, + 'active_pending' => $activeInvitations, + 'used_invitations' => $usedInvitations, + 'expired_invitations' => $expiredInvitations, + 'cancelled_invitations' => $cancelledInvitations, + 'calculated_available' => $availableInvites, + 'can_send_invite' => $availableInvites > 0 + ]; + } +} diff --git a/composer.json b/composer.json index bb1dfa11c..86d8895d7 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,6 @@ "imdbphp/imdbphp": "^8.3", "intervention/image": "^3.11", "jrean/laravel-user-verification": "^13.0", - "junaidnasir/larainvite": "^12.0", "kevinlebrun/colors.php": "^1.0", "laravel/framework": "^12.4", "laravel/horizon": "^5.31", diff --git a/composer.lock b/composer.lock index 70ff808d1..7a8a9cd88 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "25da8443105cf65d7065ce5fb0fc8604", + "content-hash": "10251ecba0052663a759a5be58b311d9", "packages": [ { "name": "aharen/omdbapi", @@ -3188,68 +3188,6 @@ }, "time": "2025-03-25T16:26:47+00:00" }, - { - "name": "junaidnasir/larainvite", - "version": "v12.0.0", - "source": { - "type": "git", - "url": "https://github.com/junaidnasir/larainvite.git", - "reference": "b39adf46b6c0202c2da038400acb8fe8989e7592" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/junaidnasir/larainvite/zipball/b39adf46b6c0202c2da038400acb8fe8989e7592", - "reference": "b39adf46b6c0202c2da038400acb8fe8989e7592", - "shasum": "" - }, - "require": { - "illuminate/database": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/events": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": ">=5.5.9" - }, - "require-dev": { - "phpunit/phpunit": "^5.2|^7.0|^8.0|^9.0|^10.5" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Invite": "Junaidnasir\\Larainvite\\Facades\\Invite" - }, - "providers": [ - "Junaidnasir\\Larainvite\\LaraInviteServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Junaidnasir\\Larainvite\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Junaid Nasir", - "email": "contact@junaidnasir.com" - } - ], - "description": "Laravel Invitation package, existing users can invite others by email", - "homepage": "https://github.com/junaidnasir/larainvite", - "keywords": [ - "Invite", - "invitations", - "laravel" - ], - "support": { - "issues": "https://github.com/junaidnasir/larainvite/issues", - "source": "https://github.com/junaidnasir/larainvite/tree/v12.0.0" - }, - "time": "2025-03-12T23:26:59+00:00" - }, { "name": "kalnoy/nestedset", "version": "v6.0.6", diff --git a/config/larainvite.php b/config/larainvite.php deleted file mode 100644 index 005fddd53..000000000 --- a/config/larainvite.php +++ /dev/null @@ -1,29 +0,0 @@ - 48, - - /* - |-------------------------------------------------------------------------- - | User Model - |-------------------------------------------------------------------------- - */ - 'UserModel' => App\Models\User::class, - - /* - |-------------------------------------------------------------------------- - | Invitation Model - |-------------------------------------------------------------------------- - */ - 'InvitationModel' => 'Junaidnasir\Larainvite\Models\LaraInviteModel', -]; diff --git a/database/migrations/2025_01_01_000000_create_invitations_table.php b/database/migrations/2025_01_01_000000_create_invitations_table.php new file mode 100644 index 000000000..877c9744e --- /dev/null +++ b/database/migrations/2025_01_01_000000_create_invitations_table.php @@ -0,0 +1,150 @@ +string('token', 64)->unique()->after('id'); + } + + if (!Schema::hasColumn('invitations', 'email')) { + $table->string('email')->after('token'); + } + + if (!Schema::hasColumn('invitations', 'invited_by')) { + $table->unsignedBigInteger('invited_by')->after('email'); + $table->index('invited_by'); + } + + if (!Schema::hasColumn('invitations', 'expires_at')) { + $table->timestamp('expires_at')->after('invited_by'); + } + + if (!Schema::hasColumn('invitations', 'used_at')) { + $table->timestamp('used_at')->nullable()->after('expires_at'); + } + + if (!Schema::hasColumn('invitations', 'used_by')) { + $table->unsignedBigInteger('used_by')->nullable()->after('used_at'); + $table->index('used_by'); + } + + if (!Schema::hasColumn('invitations', 'is_active')) { + $table->boolean('is_active')->default(true)->after('used_by'); + } + + if (!Schema::hasColumn('invitations', 'metadata')) { + $table->json('metadata')->nullable()->after('is_active'); + } + + // Add timestamps if they don't exist + if (!Schema::hasColumn('invitations', 'created_at')) { + $table->timestamps(); + } + }); + + // Add composite indexes safely + $this->addIndexSafely('invitations', ['token', 'is_active'], 'invitations_token_is_active_index'); + $this->addIndexSafely('invitations', ['email', 'is_active'], 'invitations_email_is_active_index'); + $this->addIndexSafely('invitations', ['expires_at'], 'invitations_expires_at_index'); + + } else { + // Table doesn't exist, create it with full structure + Schema::create('invitations', function (Blueprint $table) { + $table->id(); + $table->string('token', 64)->unique(); + $table->string('email'); + $table->unsignedBigInteger('invited_by'); + $table->timestamp('expires_at'); + $table->timestamp('used_at')->nullable(); + $table->unsignedBigInteger('used_by')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('metadata')->nullable(); // For storing additional data like role, permissions, etc. + $table->timestamps(); + + // Add indexes + $table->index('invited_by'); + $table->index('used_by'); + $table->index(['token', 'is_active']); + $table->index(['email', 'is_active']); + $table->index('expires_at'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Only drop columns we added, don't drop the entire table + // in case there's existing data from the old system + if (Schema::hasTable('invitations')) { + Schema::table('invitations', function (Blueprint $table) { + // Drop indexes first + $this->dropIndexSafely('invitations', 'invitations_token_is_active_index'); + $this->dropIndexSafely('invitations', 'invitations_email_is_active_index'); + $this->dropIndexSafely('invitations', 'invitations_expires_at_index'); + + // Drop individual indexes + $this->dropIndexSafely('invitations', 'invitations_invited_by_index'); + $this->dropIndexSafely('invitations', 'invitations_used_by_index'); + + // Drop columns we might have added + $columnsToCheck = ['token', 'email', 'invited_by', 'expires_at', 'used_at', 'used_by', 'is_active', 'metadata']; + foreach ($columnsToCheck as $column) { + if (Schema::hasColumn('invitations', $column)) { + $table->dropColumn($column); + } + } + }); + } + } + + /** + * Safely add an index if it doesn't exist + */ + private function addIndexSafely(string $table, array $columns, string $indexName): void + { + try { + $indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + if (empty($indexes)) { + Schema::table($table, function (Blueprint $table) use ($columns, $indexName) { + $table->index($columns, $indexName); + }); + } + } catch (\Exception $e) { + // Index creation failed, but continue + } + } + + /** + * Safely drop an index if it exists + */ + private function dropIndexSafely(string $table, string $indexName): void + { + try { + $indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + if (!empty($indexes)) { + Schema::table($table, function (Blueprint $table) use ($indexName) { + $table->dropIndex($indexName); + }); + } + } catch (\Exception $e) { + // Index drop failed, but continue + } + } +}; diff --git a/database/migrations/2025_08_10_195505_fix_invitations_column_names.php b/database/migrations/2025_08_10_195505_fix_invitations_column_names.php new file mode 100644 index 000000000..0748198d9 --- /dev/null +++ b/database/migrations/2025_08_10_195505_fix_invitations_column_names.php @@ -0,0 +1,133 @@ +dropForeign('FK_users_inv'); + } catch (\Exception $e) { + // Foreign key might not exist or have different name + } + + // Rename the column from users_id to invited_by + $table->renameColumn('users_id', 'invited_by'); + } + + // Add missing columns that our system needs + if (!Schema::hasColumn('invitations', 'token')) { + $table->string('token', 64)->unique()->after('id'); + } + + if (!Schema::hasColumn('invitations', 'email')) { + $table->string('email')->after('token'); + } + + if (!Schema::hasColumn('invitations', 'expires_at')) { + $table->timestamp('expires_at')->after('invited_by'); + } + + if (!Schema::hasColumn('invitations', 'used_at')) { + $table->timestamp('used_at')->nullable()->after('expires_at'); + } + + if (!Schema::hasColumn('invitations', 'used_by')) { + $table->unsignedBigInteger('used_by')->nullable()->after('used_at'); + $table->index('used_by'); + } + + if (!Schema::hasColumn('invitations', 'is_active')) { + $table->boolean('is_active')->default(true)->after('used_by'); + } + + if (!Schema::hasColumn('invitations', 'metadata')) { + $table->json('metadata')->nullable()->after('is_active'); + } + + // Add timestamps if they don't exist + if (!Schema::hasColumn('invitations', 'created_at')) { + $table->timestamps(); + } + }); + + // Add indexes safely + $this->addIndexSafely('invitations', ['invited_by'], 'invitations_invited_by_index'); + $this->addIndexSafely('invitations', ['token', 'is_active'], 'invitations_token_is_active_index'); + $this->addIndexSafely('invitations', ['email', 'is_active'], 'invitations_email_is_active_index'); + $this->addIndexSafely('invitations', ['expires_at'], 'invitations_expires_at_index'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('invitations', function (Blueprint $table) { + // Drop indexes first + $this->dropIndexSafely('invitations', 'invitations_invited_by_index'); + $this->dropIndexSafely('invitations', 'invitations_token_is_active_index'); + $this->dropIndexSafely('invitations', 'invitations_email_is_active_index'); + $this->dropIndexSafely('invitations', 'invitations_expires_at_index'); + $this->dropIndexSafely('invitations', 'invitations_used_by_index'); + + // Rename back to original column name if it was renamed + if (Schema::hasColumn('invitations', 'invited_by') && !Schema::hasColumn('invitations', 'users_id')) { + $table->renameColumn('invited_by', 'users_id'); + } + + // Drop columns we might have added + $columnsToCheck = ['token', 'email', 'expires_at', 'used_at', 'used_by', 'is_active', 'metadata']; + foreach ($columnsToCheck as $column) { + if (Schema::hasColumn('invitations', $column)) { + $table->dropColumn($column); + } + } + }); + } + + /** + * Safely add an index if it doesn't exist + */ + private function addIndexSafely(string $table, array $columns, string $indexName): void + { + try { + $indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + if (empty($indexes)) { + Schema::table($table, function (Blueprint $table) use ($columns, $indexName) { + $table->index($columns, $indexName); + }); + } + } catch (\Exception $e) { + // Index creation failed, but continue + } + } + + /** + * Safely drop an index if it exists + */ + private function dropIndexSafely(string $table, string $indexName): void + { + try { + $indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + if (!empty($indexes)) { + Schema::table($table, function (Blueprint $table) use ($indexName) { + $table->dropIndex($indexName); + }); + } + } catch (\Exception $e) { + // Index drop failed, but continue + } + } +}; diff --git a/docker/8.3/Dockerfile b/docker/8.4/Dockerfile similarity index 83% rename from docker/8.3/Dockerfile rename to docker/8.4/Dockerfile index 34057ae27..39316ef12 100644 --- a/docker/8.3/Dockerfile +++ b/docker/8.4/Dockerfile @@ -22,15 +22,15 @@ RUN apt-get update \ && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ - && apt-get install -y php8.3 php8.3-cli php8.3-common php8.3-mysql php8.3-xdebug php8.3-dev \ - php8.3-amqp php8.3-bcmath php8.3-bz2 php8.3-curl php8.3-gd php8.3-gmp php8.3-gnupg \ - php8.3-http php8.3-igbinary php8.3-imagick php8.3-intl php8.3-mbstring php8.3-msgpack \ - php8.3-oauth php8.3-opcache php8.3-raphf php8.3-readline php8.3-redis php8.3-soap php8.3-sqlite3 \ - php8.3-swoole php8.3-tidy php8.3-uploadprogress php8.3-uuid php8.3-xml php8.3-xmlrpc php8.3-xsl \ - php8.3-yaml php8.3-zip php8.3-zstd php8.3-pgsql php8.3-sockets php8.3-imap php8.3-ldap php8.3-memcached php8.3-pcov \ + && apt-get install -y php8.4 php8.4-cli php8.4-common php8.4-mysql php8.4-xdebug php8.4-dev \ + php8.4-amqp php8.4-bcmath php8.4-bz2 php8.4-curl php8.4-gd php8.4-gmp php8.4-gnupg \ + php8.4-http php8.4-igbinary php8.4-imagick php8.4-intl php8.4-mbstring php8.4-msgpack \ + php8.4-oauth php8.4-opcache php8.4-raphf php8.4-readline php8.4-redis php8.4-soap php8.4-sqlite3 \ + php8.4-swoole php8.4-tidy php8.4-uploadprogress php8.4-uuid php8.4-xml php8.4-xmlrpc php8.4-xsl \ + php8.4-yaml php8.4-zip php8.4-zstd php8.4-pgsql php8.4-sockets php8.4-imap php8.4-ldap php8.4-memcached php8.4-pcov \ tmux iputils-ping net-tools ffmpeg \ sudo jq htop fonts-powerline nano bash-completion time wget cron \ - nginx php8.3-fpm \ + nginx php8.4-fpm \ jpegoptim webp optipng pngquant libavif-bin \ && wget https://mediaarea.net/repo/deb/repo-mediaarea_1.0-25_all.deb \ && dpkg -i repo-mediaarea_1.0-25_all.deb \ @@ -69,7 +69,7 @@ RUN chmod 0644 /etc/cron.d/scheduler \ COPY start-container /usr/local/bin/start-container COPY nginx.conf /etc/nginx/sites-available/default COPY supervisord.conf /etc/supervisor/supervisord.conf -COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini +COPY php.ini /etc/php/8.4/cli/conf.d/99-sail.ini RUN chmod +x /usr/local/bin/start-container diff --git a/docker/8.3/README.md b/docker/8.4/README.md similarity index 98% rename from docker/8.3/README.md rename to docker/8.4/README.md index d08d3d0c2..407d7b73b 100644 --- a/docker/8.3/README.md +++ b/docker/8.4/README.md @@ -14,7 +14,7 @@ nginx runs on port 80 and is mapped to port 80 on the host machine too. If you'r Mailpit is used as a fake mailserver which catches all e-mails in one inbox. You can acess this via [this link](http://localhost:8025). Mailpit's SMTP server runs on port `1025`. -> This project currently uses PHP 8.3 and MariaDB 11.x +> This project currently uses PHP 8.4 and MariaDB 11.x ## Prerequisites diff --git a/docker/8.3/nginx.conf b/docker/8.4/nginx.conf similarity index 96% rename from docker/8.3/nginx.conf rename to docker/8.4/nginx.conf index dcbbe1ffa..60c29193e 100644 --- a/docker/8.3/nginx.conf +++ b/docker/8.4/nginx.conf @@ -38,7 +38,7 @@ server { location ~ \.php$ { include /etc/nginx/fastcgi_params; - fastcgi_pass unix:/run/php/php8.3-fpm.sock; + fastcgi_pass unix:/run/php/php8.4-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } @@ -46,4 +46,4 @@ server { location ~ /\.ht { deny all; } -} \ No newline at end of file +} diff --git a/docker/8.3/php.ini b/docker/8.4/php.ini similarity index 100% rename from docker/8.3/php.ini rename to docker/8.4/php.ini diff --git a/docker/8.3/scheduler b/docker/8.4/scheduler similarity index 100% rename from docker/8.3/scheduler rename to docker/8.4/scheduler diff --git a/docker/8.3/start-container b/docker/8.4/start-container similarity index 100% rename from docker/8.3/start-container rename to docker/8.4/start-container diff --git a/docker/8.3/supervisord.conf b/docker/8.4/supervisord.conf similarity index 94% rename from docker/8.3/supervisord.conf rename to docker/8.4/supervisord.conf index a029593f0..bee93d2c2 100644 --- a/docker/8.3/supervisord.conf +++ b/docker/8.4/supervisord.conf @@ -16,7 +16,7 @@ serverurl=unix:////var/run/supervisor.sock supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface [program:php-fpm] -command=/usr/sbin/php-fpm8.3 --nodaemonize -c /etc/php/8.3/fpm/php-fpm.conf +command=/usr/sbin/php-fpm8.4 --nodaemonize -c /etc/php/8.4/fpm/php-fpm.conf autostart=true autorestart=true redirect_stderr=true diff --git a/resources/views/emails/invitation.blade.php b/resources/views/emails/invitation.blade.php new file mode 100644 index 000000000..5bfb285dc --- /dev/null +++ b/resources/views/emails/invitation.blade.php @@ -0,0 +1,99 @@ + + + + + + Invitation to {{ $siteName }} + + + +
+

You're Invited!

+

{{ $invitedBy->username }} has invited you to join {{ $siteName }}

+
+ +
+

Welcome to {{ $siteName }}

+ +

Hello!

+ +

You have been invited by {{ $invitedBy->username }} to join {{ $siteName }}, our exclusive community.

+ +

To accept this invitation and create your account, click the button below:

+ +
+ Accept Invitation +
+ +

Or copy and paste this link into your browser:

+

+ {{ $registerUrl }} +

+ +
+ ⚠️ Important: This invitation will expire on {{ $expiresAt->format('F j, Y \a\t g:i A') }}. + Please complete your registration before this date. +
+ +

If you have any questions, please don't hesitate to contact us.

+ +

Welcome aboard!

+

The {{ $siteName }} Team

+
+ + + + diff --git a/resources/views/themes/Gentele/headermenu.tpl b/resources/views/themes/Gentele/headermenu.tpl index 177135e9e..f4dc7fcca 100755 --- a/resources/views/themes/Gentele/headermenu.tpl +++ b/resources/views/themes/Gentele/headermenu.tpl @@ -247,6 +247,12 @@ My Shows +
  • + + My Invitations + +
  • +
  • Account Settings diff --git a/resources/views/themes/Gentele/invitations_create.tpl b/resources/views/themes/Gentele/invitations_create.tpl new file mode 100644 index 000000000..9b362f2f8 --- /dev/null +++ b/resources/views/themes/Gentele/invitations_create.tpl @@ -0,0 +1,171 @@ +
    + +
    + +
    +
    +
    +
    +
    +
    Send New Invitation
    +
    +
    + + {$user_invites_left} invites left +
    + + Back to Invitations + +
    +
    +
    + {if $smarty.session.error} + + {/if} + + {if $errors} +
    +
      + {foreach $errors as $error} +
    • {$error}
    • + {/foreach} +
    +
    + {/if} + + {if !$can_send_invites} +
    + +
    No Invitations Available
    +

    You have used all of your available invitations. You cannot send new invitations at this time.

    + +
    + {else} +
    + + +
    +
    + +
    + Available Invitations: You have {$user_invites_left} invitation{if $user_invites_left != 1}s{/if} remaining. + {if $user_invites_left == 1} + This is your last invitation, so use it wisely! + {/if} +
    +
    +
    + +
    + + + {if $errors.email} +
    {$errors.email}
    + {/if} +
    + The person will receive an invitation email at this address. +
    +
    + +
    + + + {if $errors.expiry_days} +
    {$errors.expiry_days}
    + {/if} +
    + How long the invitation will remain valid. +
    +
    + + {if $user_roles} +
    + + + {if $errors.role} +
    {$errors.role}
    + {/if} +
    + The role to assign to the user when they accept the invitation. +
    +
    + {/if} + +
    +
    + +
    + How it works: +
      +
    • The recipient will receive an email with a secure invitation link
    • +
    • They can use this link to create their account within the specified time period
    • +
    • Once the time expires, the invitation link becomes invalid
    • +
    • You can track the status of all your invitations from the main invitations page
    • +
    +
    +
    +
    + +
    + + Cancel + + +
    +
    + {/if} +
    +
    +
    +
    +
    diff --git a/resources/views/themes/Gentele/invitations_index.tpl b/resources/views/themes/Gentele/invitations_index.tpl new file mode 100644 index 000000000..aa941c6ac --- /dev/null +++ b/resources/views/themes/Gentele/invitations_index.tpl @@ -0,0 +1,222 @@ +
    + +
    + +
    +
    +
    +
    +
    +
    My Invitations
    + + Send New Invitation + +
    +
    + {if $smarty.session.success} + + {/if} + + {if $smarty.session.error} + + {/if} + + +
    +
    +
    +
    +

    {$stats.total|default:0}

    + Total Sent +
    +
    +
    +
    +
    +
    +

    {$stats.used|default:0}

    + Accepted +
    +
    +
    +
    +
    +
    +

    {$stats.pending|default:0}

    + Pending +
    +
    +
    +
    +
    +
    +

    {$stats.expired|default:0}

    + Expired +
    +
    +
    +
    + + + + + + {if $invitations|@count > 0} +
    + + + + + + + + + + + + + + {foreach $invitations as $invitation} + + + + + + + + + + {/foreach} + +
    EmailStatusSent DateExpiresUsed ByUsed DateActions
    +
    + + {$invitation.email|escape:"htmlall"} +
    +
    + {if $invitation.used_at} + Accepted + {elseif $invitation.expires_at < $smarty.now} + Expired + {elseif !$invitation.is_active} + Cancelled + {else} + Pending + {/if} + +
    + + {$invitation.created_at|date_format:'M j, Y'} + {$invitation.created_at|date_format:'H:i'} +
    +
    +
    + + {$invitation.expires_at|date_format:'M j, Y'} + {$invitation.expires_at|date_format:'H:i'} +
    +
    + {if $invitation.used_by_user} +
    + + {$invitation.used_by_user.username|default:$invitation.used_by_user.email|escape:"htmlall"} +
    + {else} + - + {/if} +
    + {if $invitation.used_at} +
    + + {$invitation.used_at|date_format:'M j, Y'} + {$invitation.used_at|date_format:'H:i'} +
    + {else} + - + {/if} +
    + {if !$invitation.used_at && $invitation.expires_at > $smarty.now && $invitation.is_active} +
    +
    + + +
    +
    + + + +
    +
    + {else} + - + {/if} +
    +
    + + + {if $pagination_links} +
    + {$pagination_links} +
    + {/if} + {else} +
    + +
    No invitations found
    +

    + {if $status} + No {$status} invitations to display. + {else} + You haven't sent any invitations yet. + {/if} +

    + + Send Your First Invitation + +
    + {/if} +
    +
    +
    +
    +
    diff --git a/resources/views/themes/Gentele/invitations_show.tpl b/resources/views/themes/Gentele/invitations_show.tpl new file mode 100644 index 000000000..8aa88c311 --- /dev/null +++ b/resources/views/themes/Gentele/invitations_show.tpl @@ -0,0 +1,143 @@ +
    + +
    + +
    +
    +
    +
    +
    +
    Invitation to Join {$site->title}
    +
    +
    + {if $preview} +
    +
    + +
    +
    You've been invited!
    +

    + {$preview.inviter_name|default:'Someone'|escape:'htmlall'} has invited you to join {$site->title}. +

    +
    +
    +
    + +
    +
    +
    +
    +
    Invitation Details
    +
    +
    +
    +
    Invited by:
    +
    {$preview.inviter_name|default:'Anonymous'|escape:'htmlall'}
    +
    +
    +
    Email:
    +
    {$preview.email|default:'N/A'|escape:'htmlall'}
    +
    +
    +
    Expires:
    +
    + {if $preview.expires_at} + {$preview.expires_at|date_format:'M j, Y H:i'} + {if $preview.expires_at < $smarty.now} +
    Expired + {elseif $preview.is_used} +
    Already Used + {else} +
    Valid + {/if} + {else} + N/A + {/if} +
    +
    + {if $preview.metadata.role} +
    +
    Assigned Role:
    +
    + {$preview.role_name|default:'Default'|escape:'htmlall'} +
    +
    + {/if} +
    +
    +
    +
    +
    +
    +
    What's Next?
    +
    +
    +

    To accept this invitation and create your account:

    +
      +
    1. Click the "Accept Invitation" button below
    2. +
    3. Fill out the registration form with your details
    4. +
    5. Verify your email address when prompted
    6. +
    7. Start exploring {$site->title}!
    8. +
    +
    +
    +
    +
    + +
    + {if $preview.is_used} +
    + +
    This invitation has already been used
    +

    The account has been successfully created using this invitation.

    +
    + + Login to Your Account + + {elseif $preview.expires_at < $smarty.now} +
    + +
    This invitation has expired
    +

    Please contact the person who invited you for a new invitation.

    +
    + + Contact Support + + {else} + + Accept Invitation & Create Account + +
    + + Already have an account? Login here + +
    + {/if} +
    + {else} +
    + +
    Invalid Invitation
    +

    This invitation link is not valid, has expired, or has been removed.

    + +
    + {/if} +
    +
    +
    +
    +
    diff --git a/resources/views/themes/Gentele/profile.tpl b/resources/views/themes/Gentele/profile.tpl index 4da763900..a505c28d0 100755 --- a/resources/views/themes/Gentele/profile.tpl +++ b/resources/views/themes/Gentele/profile.tpl @@ -50,8 +50,8 @@ API & Downloads {if ($user.id === $userdata.id || $isadmin === "true") && $site->registerstatus == 1} - - Invites + + My Invitations {/if} {if (isset($isadmin) && $isadmin === "true") && $downloadlist|@count > 0} diff --git a/routes/web.php b/routes/web.php index 3d514e4ac..e6c12a8d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -70,6 +70,7 @@ use App\Http\Controllers\RssController; use App\Http\Controllers\SearchController; use App\Http\Controllers\SeriesController; use App\Http\Controllers\TermsController; +use App\Http\Controllers\InvitationController; // Auth::routes(); @@ -225,4 +226,20 @@ Route::middleware('role_or_permission:Admin|Moderator|edit release')->prefix('ad Route::match(['GET', 'POST'], 'release-edit', [AdminReleasesController::class, 'edit'])->name('admin.release-edit'); }); +// Invitation management routes +Route::prefix('invitations')->name('invitations.')->group(function () { + Route::get('/', [InvitationController::class, 'index'])->name('index'); + Route::get('/create', [InvitationController::class, 'create'])->name('create'); + Route::post('/store', [InvitationController::class, 'store'])->name('store'); + Route::post('/{id}/resend', [InvitationController::class, 'resend'])->name('resend'); + Route::delete('/{id}', [InvitationController::class, 'destroy'])->name('destroy'); + Route::get('/stats', [InvitationController::class, 'stats'])->name('stats'); +}); + +// Public invitation view (no auth required) +Route::get('/invitation/{token}', [InvitationController::class, 'show'])->name('invitation.show'); + +// Admin invitation cleanup +Route::post('/admin/invitations/cleanup', [InvitationController::class, 'cleanup'])->name('admin.invitations.cleanup')->middleware('role:Admin'); + Route::post('btcpay/webhook', [BtcPaymentController::class, 'btcPayCallback'])->name('btcpay.webhook');