From b73ca9a1eab9a6b8511e66301fd064988f4c1f1b Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 19 Dec 2022 22:30:50 -0700 Subject: [PATCH] Add Admin Invites --- app/Console/Commands/AdminInviteCommand.php | 163 ++++++ .../Controllers/AdminInviteController.php | 191 +++++++ app/Models/AdminInvite.php | 21 + config/instance.php | 4 + ...2_13_092726_create_admin_invites_table.php | 41 ++ .../assets/components/invite/AdminInvite.vue | 488 ++++++++++++++++++ resources/assets/js/admin_invite.js | 4 + resources/views/invite/admin_invite.blade.php | 21 + routes/api.php | 4 + routes/web.php | 3 + 10 files changed, 940 insertions(+) create mode 100644 app/Console/Commands/AdminInviteCommand.php create mode 100644 app/Http/Controllers/AdminInviteController.php create mode 100644 app/Models/AdminInvite.php create mode 100644 database/migrations/2022_12_13_092726_create_admin_invites_table.php create mode 100644 resources/assets/components/invite/AdminInvite.vue create mode 100644 resources/assets/js/admin_invite.js create mode 100644 resources/views/invite/admin_invite.blade.php diff --git a/app/Console/Commands/AdminInviteCommand.php b/app/Console/Commands/AdminInviteCommand.php new file mode 100644 index 000000000..a89779b0e --- /dev/null +++ b/app/Console/Commands/AdminInviteCommand.php @@ -0,0 +1,163 @@ +info(' ____ _ ______ __ '); + $this->info(' / __ \(_) _____ / / __/__ ____/ / '); + $this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / '); + $this->info(' / ____/ /> info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ '); + $this->info(' '); + $this->info(' Pixelfed Admin Inviter'); + $this->line(' '); + $this->info(' Manage user registration invite links'); + $this->line(' '); + + $action = $this->choice( + 'Select an action', + [ + 'Create invite', + 'View invites', + 'Expire invite', + 'Cancel' + ], + 3 + ); + + switch($action) { + case 'Create invite': + return $this->create(); + break; + + case 'View invites': + return $this->view(); + break; + + case 'Expire invite': + return $this->expire(); + break; + + case 'Cancel': + return; + break; + } + } + + protected function create() + { + $this->info('Create Invite'); + $this->line('============='); + $this->info('Set an optional invite name (only visible to admins)'); + $name = $this->ask('Invite Name (optional)', 'Untitled Invite'); + + $this->info('Set an optional invite description (only visible to admins)'); + $description = $this->ask('Invite Description (optional)'); + + $this->info('Set an optional message to invitees (visible to all)'); + $message = $this->ask('Invite Message (optional)', 'You\'ve been invited to join'); + + $this->info('Set maximum # of invite uses, use 0 for unlimited'); + $max_uses = $this->ask('Max uses', 1); + + $shouldExpire = $this->choice( + 'Set an invite expiry date?', + [ + 'No - invite never expires', + 'Yes - expire after 24 hours', + 'Custom - let me pick an expiry date' + ], + 0 + ); + switch($shouldExpire) { + case 'No - invite never expires': + $expires = null; + break; + + case 'Yes - expire after 24 hours': + $expires = now()->addHours(24); + break; + + case 'Custom - let me pick an expiry date': + $this->info('Set custom expiry date in days'); + $customExpiry = $this->ask('Custom Expiry', 14); + $expires = now()->addDays($customExpiry); + break; + } + + $this->info('Skip email verification for invitees?'); + $skipEmailVerification = $this->choice('Skip email verification', ['No', 'Yes'], 0); + + $invite = new AdminInvite; + $invite->name = $name; + $invite->description = $description; + $invite->message = $message; + $invite->max_uses = $max_uses; + $invite->skip_email_verification = $skipEmailVerification; + $invite->expires_at = $expires; + $invite->invite_code = Str::uuid() . Str::random(random_int(1,6)); + $invite->save(); + + $this->info('####################'); + $this->info('# Invite Generated!'); + $this->line(' '); + $this->info($invite->url()); + $this->line(' '); + return Command::SUCCESS; + } + + protected function view() + { + $this->info('View Invites'); + $this->line('============='); + $this->table( + ['Invite Code', 'Uses Left', 'Expires'], + AdminInvite::all(['invite_code', 'max_uses', 'uses', 'expires_at'])->map(function($invite) { + return [ + 'invite_code' => $invite->invite_code, + 'uses_left' => $invite->max_uses ? ($invite->max_uses - $invite->uses) : '∞', + 'expires_at' => $invite->expires_at ? $invite->expires_at->diffForHumans() : 'never' + ]; + })->toArray() + ); + } + + protected function expire() + { + $token = $this->anticipate('Enter invite code to expire', function($val) { + return AdminInvite::where('invite_code', 'like', '%' . $val . '%')->pluck('invite_code')->toArray(); + }); + + $invite = AdminInvite::whereInviteCode($token)->firstOrFail(); + $invite->max_uses = 1; + $invite->expires_at = now()->subHours(2); + $invite->save(); + $this->info('Expired the following invite: ' . $invite->url()); + } +} diff --git a/app/Http/Controllers/AdminInviteController.php b/app/Http/Controllers/AdminInviteController.php new file mode 100644 index 000000000..d2276f133 --- /dev/null +++ b/app/Http/Controllers/AdminInviteController.php @@ -0,0 +1,191 @@ +user()) { + return redirect('/'); + } + return view('invite.admin_invite', compact('code')); + } + + public function apiVerifyCheck(Request $request) + { + $this->validate($request, [ + 'token' => 'required', + ]); + + $invite = AdminInvite::whereInviteCode($request->input('token'))->first(); + abort_if(!$invite, 404); + abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.'); + abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.'); + $res = [ + 'message' => $invite->message, + 'max_uses' => $invite->max_uses, + 'sev' => $invite->skip_email_verification + ]; + return response()->json($res); + } + + public function apiUsernameCheck(Request $request) + { + $this->validate($request, [ + 'token' => 'required', + 'username' => 'required' + ]); + + $invite = AdminInvite::whereInviteCode($request->input('token'))->first(); + abort_if(!$invite, 404); + abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.'); + abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.'); + + $usernameRules = [ + 'required', + 'min:2', + 'max:15', + 'unique:users', + function ($attribute, $value, $fail) { + $dash = substr_count($value, '-'); + $underscore = substr_count($value, '_'); + $period = substr_count($value, '.'); + + if(ends_with($value, ['.php', '.js', '.css'])) { + return $fail('Username is invalid.'); + } + + if(($dash + $underscore + $period) > 1) { + return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).'); + } + + if (!ctype_alnum($value[0])) { + return $fail('Username is invalid. Must start with a letter or number.'); + } + + if (!ctype_alnum($value[strlen($value) - 1])) { + return $fail('Username is invalid. Must end with a letter or number.'); + } + + $val = str_replace(['_', '.', '-'], '', $value); + if(!ctype_alnum($val)) { + return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).'); + } + + $restricted = RestrictedNames::get(); + if (in_array(strtolower($value), array_map('strtolower', $restricted))) { + return $fail('Username cannot be used.'); + } + }, + ]; + + $rules = ['username' => $usernameRules]; + $validator = Validator::make($request->all(), $rules); + + if($validator->fails()) { + return response()->json($validator->errors(), 400); + } + + return response()->json([]); + } + + public function apiEmailCheck(Request $request) + { + $this->validate($request, [ + 'token' => 'required', + 'email' => 'required' + ]); + + $invite = AdminInvite::whereInviteCode($request->input('token'))->first(); + abort_if(!$invite, 404); + abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.'); + abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.'); + + $emailRules = [ + 'required', + 'string', + 'email', + 'max:255', + 'unique:users', + function ($attribute, $value, $fail) { + $banned = EmailService::isBanned($value); + if($banned) { + return $fail('Email is invalid.'); + } + }, + ]; + + $rules = ['email' => $emailRules]; + $validator = Validator::make($request->all(), $rules); + + if($validator->fails()) { + return response()->json($validator->errors(), 400); + } + + return response()->json([]); + } + + public function apiRegister(Request $request) + { + $this->validate($request, [ + 'token' => 'required', + 'username' => 'required', + 'name' => 'nullable', + 'email' => 'required|email', + 'password' => 'required', + 'password_confirm' => 'required' + ]); + + $invite = AdminInvite::whereInviteCode($request->input('token'))->firstOrFail(); + abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite expired'); + abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.'); + + $invite->uses = $invite->uses + 1; + + event(new Registered($user = User::create([ + 'name' => $request->input('name') ?? $request->input('username'), + 'username' => $request->input('username'), + 'email' => $request->input('email'), + 'password' => Hash::make($request->input('password')), + ]))); + $invite->used_by = array_merge($invite->used_by ?? [], [[ + 'user_id' => $user->id, + 'username' => $user->username + ]]); + $invite->save(); + + if($invite->skip_email_verification) { + $user->email_verified_at = now(); + $user->save(); + } + + if(Auth::attempt([ + 'email' => $request->input('email'), + 'password' => $request->input('password') + ])) { + $request->session()->regenerate(); + return redirect()->intended('/'); + } else { + return response()->json([], 400); + } + } +} diff --git a/app/Models/AdminInvite.php b/app/Models/AdminInvite.php new file mode 100644 index 000000000..cb6aa7932 --- /dev/null +++ b/app/Models/AdminInvite.php @@ -0,0 +1,21 @@ + 'array', + 'expires_at' => 'datetime', + ]; + + public function url() + { + return url('/auth/invite/a/' . $this->invite_code); + } +} diff --git a/config/instance.php b/config/instance.php index 062b3fb42..a443eb6b7 100644 --- a/config/instance.php +++ b/config/instance.php @@ -103,4 +103,8 @@ return [ 'avatar' => [ 'local_to_cloud' => env('PF_LOCAL_AVATAR_TO_CLOUD', false) ], + + 'admin_invites' => [ + 'enabled' => env('PF_ADMIN_INVITES_ENABLED', true) + ], ]; diff --git a/database/migrations/2022_12_13_092726_create_admin_invites_table.php b/database/migrations/2022_12_13_092726_create_admin_invites_table.php new file mode 100644 index 000000000..b5807a37c --- /dev/null +++ b/database/migrations/2022_12_13_092726_create_admin_invites_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('name')->nullable(); + $table->string('invite_code')->unique()->index(); + $table->text('description')->nullable(); + $table->text('message')->nullable(); + $table->unsignedInteger('max_uses')->nullable(); + $table->unsignedInteger('uses')->nullable(); + $table->boolean('skip_email_verification')->default(false); + $table->timestamp('expires_at')->nullable(); + $table->json('used_by')->nullable(); + $table->unsignedInteger('admin_user_id')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('admin_invites'); + } +}; diff --git a/resources/assets/components/invite/AdminInvite.vue b/resources/assets/components/invite/AdminInvite.vue new file mode 100644 index 000000000..38311c771 --- /dev/null +++ b/resources/assets/components/invite/AdminInvite.vue @@ -0,0 +1,488 @@ + + + + + diff --git a/resources/assets/js/admin_invite.js b/resources/assets/js/admin_invite.js new file mode 100644 index 000000000..fd8995276 --- /dev/null +++ b/resources/assets/js/admin_invite.js @@ -0,0 +1,4 @@ +Vue.component( + 'admin-invite', + require('./../components/invite/AdminInvite.vue').default +); diff --git a/resources/views/invite/admin_invite.blade.php b/resources/views/invite/admin_invite.blade.php new file mode 100644 index 000000000..ea6baba6d --- /dev/null +++ b/resources/views/invite/admin_invite.blade.php @@ -0,0 +1,21 @@ +@extends('layouts.blank') + +@section('content') + +@endsection + +@push('scripts') + + +@endpush + +@push('styles') + + +@endpush diff --git a/routes/api.php b/routes/api.php index 8c2904e7f..f1051a33e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -155,6 +155,10 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::post('iar', 'Api\ApiV1Dot1Controller@inAppRegistration'); Route::post('iarc', 'Api\ApiV1Dot1Controller@inAppRegistrationConfirm'); Route::get('iarer', 'Api\ApiV1Dot1Controller@inAppRegistrationEmailRedirect'); + + Route::post('invite/admin/verify', 'AdminInviteController@apiVerifyCheck')->middleware('throttle:20,120'); + Route::post('invite/admin/uc', 'AdminInviteController@apiUsernameCheck')->middleware('throttle:20,120'); + Route::post('invite/admin/ec', 'AdminInviteController@apiEmailCheck')->middleware('throttle:10,1440'); }); }); diff --git a/routes/web.php b/routes/web.php index 9a0df78ed..0b47847b3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -587,6 +587,9 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('privacy', 'MobileController@privacy'); }); + Route::get('auth/invite/a/{code}', 'AdminInviteController@index'); + Route::post('api/v1.1/auth/invite/admin/re', 'AdminInviteController@apiRegister')->middleware('throttle:5,1440'); + Route::get('stories/{username}', 'ProfileController@stories'); Route::get('p/{id}', 'StatusController@shortcodeRedirect'); Route::get('c/{collection}', 'CollectionController@show');