Enable pricing (#151)

* Enable Pro plan - WIP

* no pricing page if have no paid plans

* Set pricing ids in env

* views & submissions FREE for all

* extra param for env

* form password FREE for all

* Custom Code is PRO feature

* Replace codeinput prism with codemirror

* Better form Cleaning message

* Added risky user email spam protection

* fix form cleaning

* Pricing page new UI

* form cleaner

* Polish changes

* Fixed tests

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
formsdev
2023-08-30 13:28:29 +05:30
committed by GitHub
parent 29b153bd76
commit fb79a5bf3e
48 changed files with 1011 additions and 269 deletions

View File

@@ -34,13 +34,21 @@ class FormController extends Controller
$this->authorize('viewAny', Form::class);
$workspaceIsPro = $workspace->is_pro;
$forms = $workspace->forms()->with(['creator','views','submissions'])->paginate(10)->through(function (Form $form) use ($workspace, $workspaceIsPro){
$forms = $workspace->forms()->with(['creator','views','submissions'])
->orderByDesc('updated_at')
->paginate(10)->through(function (Form $form) use ($workspace, $workspaceIsPro){
// Add attributes for faster loading
$form->extra = (object) [
'loadedWorkspace' => $workspace,
'workspaceIsPro' => $workspaceIsPro,
'userIsOwner' => true,
'cleanings' => $this->formCleaner
->processForm(request(), $form)
->simulateCleaning($workspace)
->getPerformedCleanings()
];
return $form;
});
return FormResource::collection($forms);
@@ -91,8 +99,7 @@ class FormController extends Controller
return $this->success([
'message' => $this->formCleaner->hasCleaned() ? 'Form successfully created, but the Pro features you used will be disabled when sharing your form:' : 'Form created.',
'form_cleaning' => $this->formCleaner->getPerformedCleanings(),
'form' => new FormResource($form),
'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()),
'users_first_form' => $request->user()->forms()->count() == 1
]);
}
@@ -116,8 +123,7 @@ class FormController extends Controller
return $this->success([
'message' => $this->formCleaner->hasCleaned() ? 'Form successfully updated, but the Pro features you used will be disabled when sharing your form:' : 'Form updated.',
'form_cleaning' => $this->formCleaner->getPerformedCleanings(),
'form' => new FormResource($form)
'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()),
]);
}

View File

@@ -5,8 +5,6 @@ namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use Carbon\CarbonPeriod;
use App\Models\Forms\FormStatistic;
use Illuminate\Http\Request;
class FormStatsController extends Controller
{
@@ -15,9 +13,10 @@ class FormStatsController extends Controller
$this->middleware('auth');
}
public function getFormStats(Request $request)
public function getFormStats(string $formId)
{
$form = $request->form; // Added by ProForm middleware
$form = Form::findOrFail($formId);
$this->authorize('view', $form);
$formStats = $form->statistics()->where('date','>',now()->subDays(29)->startOfDay())->get();

View File

@@ -45,9 +45,8 @@ class PublicFormController extends Controller
$form->views()->create();
}
$formResource = new FormResource($form);
$formResource->setCleanings($formCleaner->getPerformedCleanings());
return $formResource;
return (new FormResource($form))
->setCleanings($formCleaner->getPerformedCleanings());
}
public function listUsers(Request $request)

View File

@@ -2,13 +2,14 @@
namespace App\Http\Controllers;
use App\Http\Requests\Subscriptions\UpdateStripeDetailsRequest;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Laravel\Cashier\Subscription;
class SubscriptionController extends Controller
{
const SUBSCRIPTION_PLANS = ['monthly_2022', 'yearly_2022'];
const SUBSCRIPTION_PLANS = ['monthly', 'yearly'];
const PRO_SUBSCRIPTION_NAME = 'default';
const ENTERPRISE_SUBSCRIPTION_NAME = 'enterprise';
@@ -41,7 +42,7 @@ class SubscriptionController extends Controller
->allowPromotionCodes();
if ($trial != null) {
$checkoutBuilder->trialDays(3);
$checkoutBuilder->trialUntil(now()->addDays(3)->addHour());
}
$checkout = $checkoutBuilder
@@ -49,6 +50,11 @@ class SubscriptionController extends Controller
->checkout([
'success_url' => url('/subscriptions/success'),
'cancel_url' => url('/subscriptions/error'),
'billing_address_collection' => 'required',
'customer_update' => [
'address' => 'auto',
'name' => 'never',
]
]);
return $this->success([
@@ -56,6 +62,22 @@ class SubscriptionController extends Controller
]);
}
public function updateStripeDetails(UpdateStripeDetailsRequest $request)
{
$user = Auth::user();
if (!$user->hasStripeId()) {
$user->createAsStripeCustomer();
}
$user->updateStripeCustomer([
'email' => $request->email,
'name' => $request->name,
]);
return $this->success([
'message' => 'Details saved.',
]);
}
public function billingPortal()
{
$this->middleware('auth');
@@ -69,7 +91,7 @@ class SubscriptionController extends Controller
]);
}
private function getPricing($product = 'pro')
private function getPricing($product = 'default')
{
return App::environment() == 'production' ? config('pricing.production.'.$product.'.pricing') : config('pricing.test.'.$product.'.pricing');
}

View File

@@ -26,7 +26,7 @@ class PasswordProtectedForm
'form' => $form,
]);
$userIsFormOwner = Auth::check() && Auth::user()->workspaces()->find($form->workspace_id) !== null;
if (!$userIsFormOwner && $form->is_pro && $form->has_password) {
if (!$userIsFormOwner && $form->has_password) {
if($this->hasCorrectPassword($request, $form)){
return $next($request);
}

View File

@@ -14,8 +14,8 @@ use App\Rules\ValidHCaptcha;
class AnswerFormRequest extends FormRequest
{
const MAX_FILE_SIZE_PRO = 5000000;
const MAX_FILE_SIZE_ENTERPRISE = 20000000;
const MAX_FILE_SIZE_FREE = 5000000; // 5 MB
const MAX_FILE_SIZE_PRO = 50000000; // 50 MB
public Form $form;
@@ -26,10 +26,10 @@ class AnswerFormRequest extends FormRequest
{
$this->form = $request->form;
$this->maxFileSize = self::MAX_FILE_SIZE_PRO;
$this->maxFileSize = self::MAX_FILE_SIZE_FREE;
$workspace = $this->form->workspace;
if ($workspace && $workspace->is_enterprise) {
$this->maxFileSize = self::MAX_FILE_SIZE_ENTERPRISE;
if ($workspace && $workspace->is_pro) {
$this->maxFileSize = self::MAX_FILE_SIZE_PRO;
}
}
@@ -53,9 +53,9 @@ class AnswerFormRequest extends FormRequest
foreach ($this->form->properties as $property) {
$rules = [];
if (!$this->form->is_pro) { // If not pro then not check logic
/*if (!$this->form->is_pro) { // If not pro then not check logic
$property['logic'] = false;
}
}*/
// For get values instead of Id for select/multi select options
$data = $this->toArray();
@@ -96,12 +96,12 @@ class AnswerFormRequest extends FormRequest
}
// Validate hCaptcha
if ($this->form->is_pro && $this->form->use_captcha) {
if ($this->form->use_captcha) {
$this->requestRules['h-captcha-response'] = [new ValidHCaptcha()];
}
// Validate submission_id for edit mode
if ($this->form->editable_submissions) {
if ($this->form->is_pro && $this->form->editable_submissions) {
$this->requestRules['submission_id'] = 'string';
}
@@ -160,7 +160,7 @@ class AnswerFormRequest extends FormRequest
return ['numeric'];
case 'select':
case 'multi_select':
if ($this->form->is_pro && ($property['allow_creation'] ?? false)) {
if (($property['allow_creation'] ?? false)) {
return ['string'];
}
return [Rule::in($this->getSelectPropertyOptions($property))];
@@ -174,7 +174,7 @@ class AnswerFormRequest extends FormRequest
return ['url'];
case 'files':
$allowedFileTypes = [];
if($this->form->is_pro && !empty($property['allowed_file_types'])){
if(!empty($property['allowed_file_types'])){
$allowedFileTypes = explode(",", $property['allowed_file_types']);
}
$this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize, $allowedFileTypes, $this->form)];

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Subscriptions;
use Illuminate\Foundation\Http\FormRequest;
class UpdateStripeDetailsRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'name' => 'required|string',
'email' => 'required|email',
];
}
}

View File

@@ -26,8 +26,8 @@ class FormResource extends JsonResource
$ownerData = $this->userIsFormOwner() ? [
'creator' => new UserResource($this->creator),
'views_count' => $this->when($this->workspaceIsPro(), $this->views_count),
'submissions_count' => $this->when($this->workspaceIsPro(), $this->submissions_count),
'views_count' => $this->views_count,
'submissions_count' => $this->submissions_count,
'notifies' => $this->notifies,
'notifies_slack' => $this->notifies_slack,
'notifies_discord' => $this->notifies_discord,
@@ -35,7 +35,7 @@ class FormResource extends JsonResource
'webhook_url' => $this->webhook_url,
'redirect_url' => $this->redirect_url,
'database_fields_update' => $this->database_fields_update,
'cleanings' => $this->cleanings,
'cleanings' => $this->getCleanigns(),
'notification_sender' => $this->notification_sender,
'notification_subject' => $this->notification_subject,
'notification_body' => $this->notification_body,
@@ -95,7 +95,7 @@ class FormResource extends JsonResource
private function doesMissPassword(Request $request)
{
if (!$this->workspaceIsPro() || !$this->has_password) return false;
if (!$this->has_password) return false;
return !PasswordProtectedForm::hasCorrectPassword($request, $this->resource);
}
@@ -132,4 +132,9 @@ class FormResource extends JsonResource
&& Auth::user()->workspaces()->find($this->workspace_id) !== null
);
}
private function getCleanigns()
{
return $this->extra?->cleanings ?? $this->cleanings;
}
}

View File

@@ -18,6 +18,8 @@ class SubmissionConfirmation implements ShouldQueue
{
use InteractsWithQueue;
const RISKY_USERS_LIMIT = 120;
/**
* Handle the event.
*
@@ -26,7 +28,13 @@ class SubmissionConfirmation implements ShouldQueue
*/
public function handle(FormSubmitted $event)
{
if (!$event->form->send_submission_confirmation) return;
if (
!$event->form->is_pro ||
!$event->form->send_submission_confirmation ||
$this->riskLimitReached($event) // To avoid phishing abuse we limit this feature for risky users
) {
return;
}
$email = $this->getRespondentEmail($event);
if (!$email) return;
@@ -56,6 +64,21 @@ class SubmissionConfirmation implements ShouldQueue
return null;
}
private function riskLimitReached(FormSubmitted $event): bool
{
// This is a per-workspace limit for risky workspaces
if ($event->form->workspace->is_risky) {
if ($event->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) {
\Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [
'form_id' => $event->form->id,
'workspace_id' => $event->form->workspace->id,
]);
return true;
}
}
return false;
}
public static function validateEmail($email): bool {
return (boolean) filter_var($email, FILTER_VALIDATE_EMAIL);
}

View File

@@ -179,6 +179,15 @@ class User extends Authenticatable implements JWTSubject //, MustVerifyEmail
return [];
}
public function getIsRiskyAttribute()
{
return $this->created_at->isAfter(now()->subDays(3)) || // created in last 3 days
$this->subscriptions()->where(function ($q) {
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
})->first()?->onTrial();
}
public static function boot ()
{
parent::boot();

View File

@@ -24,7 +24,9 @@ class Workspace extends Model
public function getIsProAttribute()
{
return true; // Temporary true for ALL
if(is_null(config('cashier.key'))){
return true; // If no paid plan so TRUE for ALL
}
// Make sure at least one owner is pro
foreach ($this->owners as $owner) {
@@ -37,7 +39,9 @@ class Workspace extends Model
public function getIsEnterpriseAttribute()
{
return true; // Temporary true for ALL
if(is_null(config('cashier.key'))){
return true; // If no paid plan so TRUE for ALL
}
foreach ($this->owners as $owner) {
if ($owner->has_enterprise_subscription) {
@@ -47,6 +51,28 @@ class Workspace extends Model
return false;
}
public function getIsRiskyAttribute()
{
// A workspace is risky if all of his users are risky
foreach ($this->owners as $owner) {
if (!$owner->is_risky) {
return false;
}
}
return true;
}
public function getSubmissionsCountAttribute()
{
$total = 0;
foreach ($this->forms as $form) {
$total += $form->submissions_count;
}
return $total;
}
/**
* Relationships
*/

View File

@@ -11,7 +11,6 @@ use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Stevebauman\Purify\Facades\Purify;
use function App\Service\str_starts_with;
use function collect;
class FormCleaner
@@ -26,76 +25,34 @@ class FormCleaner
private array $formDefaults = [
'notifies' => false,
'color' => '#3B82F6',
'hide_title' => false,
'no_branding' => false,
'transparent_background' => false,
'uppercase_labels' => true,
'webhook_url' => null,
'cover_picture' => null,
'logo_picture' => null,
'database_fields_update' => null,
'theme' => 'default',
'use_captcha' => false,
'password' => null,
'slack_webhook_url' => null,
'discord_webhook_url' => null,
'editable_submissions' => false,
'custom_code' => null,
];
private array $fieldDefaults = [
// 'name' => '' TODO: prevent name changing, use alias for column and keep original name as it is
'hide_field_name' => false,
'prefill' => null,
'placeholder' => null,
'help' => null,
'file_upload' => false,
'with_time' => null,
'width' => 'full',
'generates_uuid' => false,
'generates_auto_increment_id' => false,
'logic' => null,
'allow_creation' => false
];
private array $cleaningMessages = [
// For form
'notifies' => "Email notification were disabled.",
'color' => "Form color set to default blue.",
'hide_title' => "Title is not hidden.",
'no_branding' => "OpenForm branding is not hidden.",
'transparent_background' => "Transparent background was disabled.",
'uppercase_labels' => "Labels use uppercase letters",
'webhook_url' => "Webhook disabled.",
'cover_picture' => 'The cover picture was removed.',
'logo_picture' => 'The logo was removed.',
'database_fields_update' => 'Form submission will only create new records (no updates).',
'theme' => 'Default theme was applied.',
'slack_webhook_url' => "Slack webhook disabled.",
'discord_webhook_url' => "Discord webhook disabled.",
'editable_submissions' => 'Users will not be able to edit their submissions.',
'custom_code' => 'Custom code was disabled',
// For fields
'hide_field_name' => 'Hide field name removed.',
'prefill' => "Field prefill removed.",
'placeholder' => "Empty text (placeholder) removed",
'help' => "Help text removed.",
'file_upload' => "Link field is not a file upload.",
'with_time' => "Time was removed from date input.",
'custom_block' => 'The custom block was removed.',
'files' => 'The file upload file was hidden.',
'relation' => 'The relation file was hidden.',
'width' => 'The field width was set to full width',
'allow_creation' => 'Select option creation was disabled.',
// Advanced fields
'generates_uuid' => 'ID generation disabled.',
'generates_auto_increment_id' => 'ID generation disabled.',
'use_captcha' => 'Captcha form protection was disabled.',
// Security & Privacy
'password' => 'Password protection was disabled',
'logic' => 'Logic disabled for this property'
];
/**
@@ -144,7 +101,8 @@ class FormCleaner
/**
* Create form cleaner instance from existing form
*/
public function processForm(Request $request, Form $form) : FormCleaner {
public function processForm(Request $request, Form $form) : FormCleaner
{
$data = (new FormResource($form))->toArray($request);
$this->data = $this->commonCleaning($data);
@@ -159,10 +117,11 @@ class FormCleaner
* Dry run celanings
* @param User|null $user
*/
public function simulateCleaning(Workspace $workspace): FormCleaner {
if($this->isPro($workspace)) return $this;
$this->data = $this->removeProFeatures($this->data, true);
public function simulateCleaning(Workspace $workspace): FormCleaner
{
if (!$this->isPro($workspace)) {
$this->data = $this->removeProFeatures($this->data, true);
}
return $this;
}
@@ -174,9 +133,9 @@ class FormCleaner
*/
public function performCleaning(Workspace $workspace): FormCleaner
{
if($this->isPro($workspace)) return $this;
$this->data = $this->removeProFeatures($this->data);
if (!$this->isPro($workspace)) {
$this->data = $this->removeProFeatures($this->data);
}
return $this;
}
@@ -212,6 +171,7 @@ class FormCleaner
private function cleanProperties(array &$data, $simulation = false): void
{
foreach ($data['properties'] as $key => &$property) {
/*
// Remove pro custom blocks
if (\Str::of($property['type'])->startsWith('nf-')) {
$this->cleanings[$property['name']][] = 'custom_block';
@@ -221,6 +181,15 @@ class FormCleaner
continue;
}
// Remove logic
if (($property['logic']['conditions'] ?? null) != null || ($property['logic']['actions'] ?? []) != []) {
$this->cleanings[$property['name']][] = 'logic';
if (!$simulation) {
unset($data['properties'][$key]['logic']);
}
}
*/
// Clean pro field options
$this->cleanField($property, $this->fieldDefaults, $simulation);
}
@@ -229,8 +198,18 @@ class FormCleaner
private function clean(array &$data, array $defaults, $simulation = false): void
{
foreach ($defaults as $key => $value) {
if (Arr::get($data, $key) !== $value) {
if (!isset($this->cleanings['form'])) $this->cleanings['form'] = [];
// Get value from form
$formVal = Arr::get($data, $key);
// Transform boolean values
$formVal = (($formVal === 0 || $formVal === "0") ? false : $formVal);
$formVal = (($formVal === 1 || $formVal === "1") ? true : $formVal);
if (!is_null($formVal) && $formVal !== $value) {
if (!isset($this->cleanings['form'])) {
$this->cleanings['form'] = [];
}
$this->cleanings['form'][] = $key;
// If not a simulation, do the cleaning
@@ -253,14 +232,14 @@ class FormCleaner
}
// Remove pro types columns
foreach (['files'] as $proType) {
/*foreach (['files'] as $proType) {
if ($data['type'] == $proType && (!isset($data['hidden']) || !$data['hidden'])) {
$this->cleanings[$data['name']][] = $proType;
if (!$simulation) {
$data['hidden'] = true;
}
}
}
}*/
}
}