diff --git a/app/Http/Controllers/Auth/AppSumoAuthController.php b/app/Http/Controllers/Auth/AppSumoAuthController.php new file mode 100644 index 000000000..505d2d966 --- /dev/null +++ b/app/Http/Controllers/Auth/AppSumoAuthController.php @@ -0,0 +1,117 @@ +validate($request, [ + 'code' => 'required', + ]); + $accessToken = $this->retrieveAccessToken($request->code); + $license = $this->fetchOrCreateLicense($accessToken); + + // If user connected, attach license + if (Auth::check()) return $this->attachLicense($license); + + // otherwise start login flow by passing the encrypted license key id + if (is_null($license->user_id)) { + return redirect(url('/register?appsumo_license='.encrypt($license->id))); + } + + return redirect(url('/register?appsumo_error=1')); + } + + private function retrieveAccessToken(string $requestCode): string + { + return Http::withHeaders([ + 'Content-type' => 'application/json' + ])->post('https://appsumo.com/openid/token/', [ + 'grant_type' => 'authorization_code', + 'code' => $requestCode, + 'redirect_uri' => route('appsumo.callback'), + 'client_id' => config('services.appsumo.client_id'), + 'client_secret' => config('services.appsumo.client_secret'), + ])->throw()->json('access_token'); + } + + private function fetchOrCreateLicense(string $accessToken): License + { + // Fetch license from API + $licenseKey = Http::get('https://appsumo.com/openid/license_key/?access_token=' . $accessToken) + ->throw() + ->json('license_key'); + + // Fetch or create license model + $license = License::where('license_provider','appsumo')->where('license_key',$licenseKey)->first(); + if (!$license) { + $licenseData = Http::withHeaders([ + 'X-AppSumo-Licensing-Key' => config('services.appsumo.api_key'), + ])->get('https://api.licensing.appsumo.com/v2/licenses/'.$licenseKey)->json(); + + // Create new license + $license = License::create([ + 'license_key' => $licenseKey, + 'license_provider' => 'appsumo', + 'status' => $licenseData['status'] === 'active' ? License::STATUS_ACTIVE : License::STATUS_INACTIVE, + 'meta' => $licenseData, + ]); + } + + return $license; + } + + private function attachLicense(License $license) { + if (!Auth::check()) { + throw new AuthenticationException('User not authenticated'); + } + + // Attach license if not already attached + if (is_null($license->user_id)) { + $license->user_id = Auth::id(); + $license->save(); + return redirect(url('/home?appsumo_connect=1')); + } + + // Licensed already attached + return redirect(url('/home?appsumo_error=1')); + } + + /** + * @param User $user + * @param string|null $licenseHash + * @return string|null + * + * Returns null if no license found + * Returns true if license was found and attached + * Returns false if there was an error (license not found or already attached) + */ + public static function registerWithLicense(User $user, ?string $licenseHash): ?bool + { + if (!$licenseHash) { + return null; + } + $licenseId = decrypt($licenseHash); + $license = License::find($licenseId); + + if ($license && is_null($license->user_id)) { + $license->user_id = $user->id; + $license->save(); + return true; + } + + return false; + } +} diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 736eff8a4..df7e99a31 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Http\Resources\UserResource; use App\Models\Workspace; use App\Models\User; use Illuminate\Contracts\Auth\MustVerifyEmail; @@ -15,6 +16,8 @@ class RegisterController extends Controller { use RegistersUsers; + private ?bool $appsumoLicense = null; + /** * Create a new controller instance. * @@ -28,8 +31,8 @@ public function __construct() /** * The user has been registered. * - * @param \Illuminate\Http\Request $request - * @param \App\User $user + * @param \Illuminate\Http\Request $request + * @param \App\User $user * @return \Illuminate\Http\JsonResponse */ protected function registered(Request $request, User $user) @@ -38,13 +41,17 @@ protected function registered(Request $request, User $user) return response()->json(['status' => trans('verification.sent')]); } - return response()->json($user); + return response()->json(array_merge( + (new UserResource($user))->toArray($request), + [ + 'appsumo_license' => $this->appsumoLicense, + ])); } /** * Get a validator for an incoming registration request. * - * @param array $data + * @param array $data * @return \Illuminate\Contracts\Validation\Validator */ protected function validator(array $data) @@ -54,8 +61,9 @@ protected function validator(array $data) 'email' => 'required|email:filter|max:255|unique:users|indisposable', 'password' => 'required|min:6|confirmed', 'hear_about_us' => 'required|string', - 'agree_terms' => ['required',Rule::in([true])] - ],[ + 'agree_terms' => ['required', Rule::in([true])], + 'appsumo_license' => ['nullable'], + ], [ 'agree_terms' => 'Please agree with the terms and conditions.' ]); } @@ -63,7 +71,7 @@ protected function validator(array $data) /** * Create a new user instance after a valid registration. * - * @param array $data + * @param array $data * @return \App\User */ protected function create(array $data) @@ -87,6 +95,8 @@ protected function create(array $data) ] ], false); + $this->appsumoLicense = AppSumoAuthController::registerWithLicense($user, $data['appsumo_license'] ?? null); + return $user; } } diff --git a/app/Http/Controllers/Webhook/AppSumoController.php b/app/Http/Controllers/Webhook/AppSumoController.php new file mode 100644 index 000000000..6bbf900dc --- /dev/null +++ b/app/Http/Controllers/Webhook/AppSumoController.php @@ -0,0 +1,91 @@ +validateSignature($request); + + if ($request->test) { + return $this->success([ + 'message' => 'Webhook received.', + 'event' => $request->event, + 'success' => true, + ]); + } + + // Call the right function depending on the event using match() + match ($request->event) { + 'activate' => $this->handleActivateEvent($request), + 'upgrade', 'downgrade' => $this->handleChangeEvent($request), + 'deactivate' => $this->handleDeactivateEvent($request), + default => null, + }; + + return $this->success([ + 'message' => 'Webhook received.', + 'event' => $request->event, + 'success' => true, + ]); + } + + private function handleActivateEvent($request) + { + $licence = License::firstOrNew([ + 'license_key' => $request->license_key, + 'license_provider' => 'appsumo', + 'status' => License::STATUS_ACTIVE, + ]); + $licence->meta = $request->json()->all(); + $licence->save(); + } + + private function handleChangeEvent($request) + { + // Deactivate old license + $oldLicense = License::where([ + 'license_key' => $request->prev_license_key, + 'license_provider' => 'appsumo', + ])->firstOrFail(); + $oldLicense->update([ + 'status' => License::STATUS_INACTIVE, + ]); + + // Create new license + License::create([ + 'license_key' => $request->license_key, + 'license_provider' => 'appsumo', + 'status' => License::STATUS_ACTIVE, + 'meta' => $request->json()->all(), + ]); + } + + private function handleDeactivateEvent($request) + { + // Deactivate old license + $oldLicense = License::where([ + 'license_key' => $request->prev_license_key, + 'license_provider' => 'appsumo', + ])->firstOrFail(); + $oldLicense->update([ + 'status' => License::STATUS_INACTIVE, + ]); + } + + private function validateSignature(Request $request) + { + $signature = $request->header('x-appsumo-signature'); + $payload = $request->getContent(); + + if ($signature === hash_hmac('sha256', $payload, config('services.appsumo.api_key'))) { + throw new UnauthorizedException('Invalid signature.'); + } + } +} diff --git a/app/Http/Requests/AnswerFormRequest.php b/app/Http/Requests/AnswerFormRequest.php index be8085072..2a3039f77 100644 --- a/app/Http/Requests/AnswerFormRequest.php +++ b/app/Http/Requests/AnswerFormRequest.php @@ -16,9 +16,6 @@ class AnswerFormRequest extends FormRequest { - const MAX_FILE_SIZE_FREE = 5000000; // 5 MB - const MAX_FILE_SIZE_PRO = 50000000; // 50 MB - public Form $form; protected array $requestRules = []; @@ -27,12 +24,7 @@ class AnswerFormRequest extends FormRequest public function __construct(Request $request) { $this->form = $request->form; - - $this->maxFileSize = self::MAX_FILE_SIZE_FREE; - $workspace = $this->form->workspace; - if ($workspace && $workspace->is_pro) { - $this->maxFileSize = self::MAX_FILE_SIZE_PRO; - } + $this->maxFileSize = $this->form->workspace->max_file_size; } /** diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index cfabaa00f..5e8de1df3 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -21,6 +21,7 @@ public function toArray($request) 'template_editor' => $this->template_editor, 'has_customer_id' => $this->has_customer_id, 'has_forms' => $this->has_forms, + 'active_license' => $this->licenses()->active()->first(), ] : []; return array_merge(parent::toArray($request), $personalData); diff --git a/app/Models/License.php b/app/Models/License.php new file mode 100644 index 000000000..bb72f34e6 --- /dev/null +++ b/app/Models/License.php @@ -0,0 +1,45 @@ + 'array', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + public function getMaxFileSizeAttribute() + { + return [ + 1 => 25000000, // 25 MB, + 2 => 50000000, // 50 MB, + 3 => 75000000, // 75 MB, + ][$this->meta['tier']]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 538136b78..3ce5c21d8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,6 +13,7 @@ use Illuminate\Notifications\Notifiable; use Laravel\Cashier\Billable; use Tymon\JWTAuth\Contracts\JWTSubject; + class User extends Authenticatable implements JWTSubject { use Notifiable, HasFactory, Billable; @@ -80,7 +81,9 @@ public function getHasFormsAttribute() public function getIsSubscribedAttribute() { - return $this->subscribed() || in_array($this->email, config('opnform.extra_pro_users_emails')); + return $this->subscribed() + || in_array($this->email, config('opnform.extra_pro_users_emails')) + || !is_null($this->activeLicense()); } public function getHasCustomerIdAttribute() @@ -138,7 +141,7 @@ public function workspaces() public function forms() { - return $this->hasMany(Form::class,'creator_id'); + return $this->hasMany(Form::class, 'creator_id'); } public function formTemplates() @@ -146,6 +149,16 @@ public function formTemplates() return $this->hasMany(Template::class, 'creator_id'); } + public function licenses() + { + return $this->hasMany(License::class); + } + + public function activeLicense(): License + { + return $this->licenses()->active()->first(); + } + /** * ================================= * Oauth Related @@ -187,26 +200,26 @@ public function getIsRiskyAttribute() })->first()?->onTrial(); } - public static function boot () + public static function boot() { parent::boot(); - static::deleting(function(User $user) { + static::deleting(function (User $user) { // Remove user's workspace if he's the only one with this workspace foreach ($user->workspaces as $workspace) { if ($workspace->users()->count() == 1) { $workspace->delete(); } } - }); + }); } public function scopeWithActiveSubscription($query) { - return $query->whereHas('subscriptions', function($query) { - $query->where(function($q){ - $q->where('stripe_status', 'trialing') - ->orWhere('stripe_status', 'active'); - }); + return $query->whereHas('subscriptions', function ($query) { + $query->where(function ($q) { + $q->where('stripe_status', 'trialing') + ->orWhere('stripe_status', 'active'); + }); }); } diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 5974a5937..c0efa231d 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -2,8 +2,8 @@ namespace App\Models; +use App\Http\Requests\AnswerFormRequest; use App\Models\Forms\Form; -use App\Models\User; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -11,6 +11,9 @@ class Workspace extends Model { use HasFactory; + const MAX_FILE_SIZE_FREE = 5000000; // 5 MB + const MAX_FILE_SIZE_PRO = 50000000; // 50 MB + protected $fillable = [ 'name', 'icon', @@ -37,6 +40,26 @@ public function getIsProAttribute() return false; } + public function getMaxFileSizeAttribute() + { + if(is_null(config('cashier.key'))){ + return self::MAX_FILE_SIZE_PRO; + } + + // Return max file size depending on subscription + foreach ($this->owners as $owner) { + if ($owner->is_subscribed) { + if ($license = $owner->activeLicense()) { + // In case of special License + return $license->max_file_size; + } + } + return self::MAX_FILE_SIZE_PRO; + } + + return self::MAX_FILE_SIZE_FREE; + } + public function getIsEnterpriseAttribute() { if(is_null(config('cashier.key'))){ diff --git a/config/services.php b/config/services.php index fe0ad4026..7e2b9d165 100644 --- a/config/services.php +++ b/config/services.php @@ -45,7 +45,7 @@ ], 'notion' => [ - 'worker' => env('NOTION_WORKER','https://notion-forms-worker.notionforms.workers.dev/v1') + 'worker' => env('NOTION_WORKER', 'https://notion-forms-worker.notionforms.workers.dev/v1') ], 'openai' => [ @@ -53,8 +53,14 @@ ], 'unsplash' => [ - 'access_key' => env('UNSPLASH_ACCESS_KEY'), - 'secret_key' => env('UNSPLASH_SECRET_KEY'), + 'access_key' => env('UNSPLASH_ACCESS_KEY'), + 'secret_key' => env('UNSPLASH_SECRET_KEY'), + ], + + 'appsumo' => [ + 'client_id' => env('APPSUMO_CLIENT_ID'), + 'client_secret' => env('APPSUMO_CLIENT_SECRET'), + 'api_key' => env('APPSUMO_API_KEY'), ], 'google_analytics_code' => env('GOOGLE_ANALYTICS_CODE'), diff --git a/database/migrations/2023_10_30_133259_create_licenses_table.php b/database/migrations/2023_10_30_133259_create_licenses_table.php new file mode 100644 index 000000000..034cde5db --- /dev/null +++ b/database/migrations/2023_10_30_133259_create_licenses_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('license_key'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('license_provider'); + $table->string('status'); + $table->json('meta'); + $table->timestamps(); + + $table->index(['license_key', 'license_provider']); + $table->index(['user_id', 'license_provider']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('licenses'); + } +}; diff --git a/public/img/appsumo/as-Select-dark.png b/public/img/appsumo/as-Select-dark.png new file mode 100644 index 000000000..44dc132c8 Binary files /dev/null and b/public/img/appsumo/as-Select-dark.png differ diff --git a/public/img/appsumo/as-taco-white-bg.png b/public/img/appsumo/as-taco-white-bg.png new file mode 100644 index 000000000..16442a479 Binary files /dev/null and b/public/img/appsumo/as-taco-white-bg.png differ diff --git a/resources/js/components/common/Button.vue b/resources/js/components/common/Button.vue index 85e21d87c..0e8304878 100644 --- a/resources/js/components/common/Button.vue +++ b/resources/js/components/common/Button.vue @@ -7,24 +7,24 @@ > - + - - - - + + + + + stroke-linejoin="round" + /> @@ -72,11 +72,11 @@ export default { target: { type: String, default: '_self' - }, + } }, computed: { - btnClasses() { + btnClasses () { const sizes = this.sizes const colorShades = this.colorShades return `v-btn ${sizes['p-y']} ${sizes['p-x']} @@ -84,14 +84,14 @@ export default { ${colorShades?.text} transition ease-in duration-200 text-center text-${sizes?.font} font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg flex items-center hover:no-underline` }, - colorShades() { + colorShades () { if (this.color === 'blue') { return { main: 'bg-blue-600', hover: 'hover:bg-blue-700', ring: 'focus:ring-blue-500', 'ring-offset': 'focus:ring-offset-blue-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'outline-blue') { return { @@ -99,7 +99,15 @@ export default { hover: 'hover:bg-blue-600', ring: 'focus:ring-blue-500', 'ring-offset': 'focus:ring-offset-blue-200', - text: 'text-blue-600 hover:text-white', + text: 'text-blue-600 hover:text-white' + } + } else if (this.color === 'outline-gray') { + return { + main: 'bg-transparent border border-gray-300', + hover: 'hover:bg-gray-500', + ring: 'focus:ring-gray-500', + 'ring-offset': 'focus:ring-offset-gray-200', + text: 'text-gray-500 hover:text-white' } } else if (this.color === 'red') { return { @@ -107,7 +115,7 @@ export default { hover: 'hover:bg-red-700', ring: 'focus:ring-red-500', 'ring-offset': 'focus:ring-offset-red-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'gray') { return { @@ -115,7 +123,7 @@ export default { hover: 'hover:bg-gray-700', ring: 'focus:ring-gray-500', 'ring-offset': 'focus:ring-offset-gray-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'light-gray') { return { @@ -123,7 +131,7 @@ export default { hover: 'hover:bg-gray-100', ring: 'focus:ring-gray-500', 'ring-offset': 'focus:ring-offset-gray-300', - text: 'text-gray-700', + text: 'text-gray-700' } } else if (this.color === 'green') { return { @@ -131,7 +139,7 @@ export default { hover: 'hover:bg-green-700', ring: 'focus:ring-green-500', 'ring-offset': 'focus:ring-offset-green-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'yellow') { return { @@ -139,7 +147,7 @@ export default { hover: 'hover:bg-yellow-700', ring: 'focus:ring-yellow-500', 'ring-offset': 'focus:ring-offset-yellow-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'white') { return { @@ -147,12 +155,12 @@ export default { hover: 'hover:bg-gray-200', ring: 'focus:ring-white-500', 'ring-offset': 'focus:ring-offset-white-200', - text: 'text-gray-700', + text: 'text-gray-700' } } console.error('Unknown color') }, - sizes() { + sizes () { if (this.size === 'small') { return { font: 'sm', @@ -169,8 +177,8 @@ export default { }, methods: { - onClick(event) { - this.$emit('click',event) + onClick (event) { + this.$emit('click', event) } } } diff --git a/resources/js/components/vendor/appsumo/AppSumoBilling.vue b/resources/js/components/vendor/appsumo/AppSumoBilling.vue new file mode 100644 index 000000000..5dcffbd71 --- /dev/null +++ b/resources/js/components/vendor/appsumo/AppSumoBilling.vue @@ -0,0 +1,77 @@ + + + diff --git a/resources/js/components/vendor/appsumo/AppSumoRegister.vue b/resources/js/components/vendor/appsumo/AppSumoRegister.vue new file mode 100644 index 000000000..71573cc3a --- /dev/null +++ b/resources/js/components/vendor/appsumo/AppSumoRegister.vue @@ -0,0 +1,50 @@ + + + diff --git a/resources/js/pages/auth/components/RegisterForm.vue b/resources/js/pages/auth/components/RegisterForm.vue index a06f82d69..7eadc7dc6 100644 --- a/resources/js/pages/auth/components/RegisterForm.vue +++ b/resources/js/pages/auth/components/RegisterForm.vue @@ -1,6 +1,6 @@ diff --git a/resources/js/pages/settings/billing.vue b/resources/js/pages/settings/billing.vue index 17164ebb0..47a497f2c 100644 --- a/resources/js/pages/settings/billing.vue +++ b/resources/js/pages/settings/billing.vue @@ -1,14 +1,21 @@ @@ -16,11 +23,13 @@ import axios from 'axios' import VButton from '../../components/common/Button.vue' import SeoMeta from '../../mixins/seo-meta.js' +import { mapGetters } from 'vuex' +import AppSumoBilling from '../../components/vendor/appsumo/AppSumoBilling.vue' export default { - components: {VButton}, - scrollToTop: false, + components: { AppSumoBilling, VButton }, mixins: [SeoMeta], + scrollToTop: false, data: () => ({ metaTitle: 'Billing', @@ -28,7 +37,7 @@ export default { }), methods: { - openBillingDashboard() { + openBillingDashboard () { this.billingLoading = true axios.get('/api/subscription/billing-portal').then((response) => { const url = response.data.portal_url @@ -39,6 +48,12 @@ export default { this.billingLoading = false }) } + }, + + computed: { + ...mapGetters({ + user: 'auth/user' + }) } } diff --git a/routes/api.php b/routes/api.php index 9ec4b922b..cfb0abad8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -129,6 +129,12 @@ Route::get('oauth/{driver}/callback', [OAuthController::class, 'handleCallback'])->name('oauth.callback'); }); + +Route::group(['prefix' => 'appsumo'], function () { + Route::get('oauth/callback', [\App\Http\Controllers\Auth\AppSumoAuthController::class, 'handleCallback'])->name('appsumo.callback'); + Route::post('webhook', [\App\Http\Controllers\Webhook\AppSumoController::class, 'handle'])->name('appsumo.webhook'); +}); + /* * Public Forms related routes */