From b50f5791551a8395a4ec452a1bc6b440e29ff109 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Wed, 29 Nov 2023 14:53:08 +0100 Subject: [PATCH 01/16] Adding Custom domains (#247) * WIP * wip * Finished doing most of the work --- .env.example | 3 + _ide_helper_models.php | 462 ++++++++++++++++++ app/Http/Controllers/CaddyController.php | 36 ++ app/Http/Controllers/WorkspaceController.php | 8 + app/Http/Kernel.php | 2 + .../Middleware/CaddyRequestMiddleware.php | 39 ++ .../Middleware/CustomDomainRestriction.php | 55 +++ app/Http/Requests/UserFormRequest.php | 3 +- .../Workspace/CustomDomainRequest.php | 58 +++ app/Models/Forms/Form.php | 21 +- app/Models/License.php | 9 + app/Models/Workspace.php | 27 + config/custom-domains.php | 9 + ...add_custom_domains_to_workspaces_table.php | 37 ++ ...61121_add_custom_domain_to_forms_table.php | 32 ++ package-lock.json | 16 +- .../js/components/forms/TextAreaInput.vue | 8 +- .../form-components/FormCustomSeo.vue | 19 +- .../components/pages/forms/show/ExtraMenu.vue | 217 ++++---- .../components/pages/pricing/CustomPlan.vue | 8 +- .../components/pages/pricing/PricingTable.vue | 45 +- resources/js/middleware/custom-domains.js | 16 + resources/js/middleware/notion-connection.js | 17 - resources/js/mixins/forms/input.js | 8 + resources/js/pages/forms/show/index.vue | 111 +++-- resources/js/pages/settings/workspace.vue | 181 +++++-- resources/js/router/index.js | 8 +- resources/js/store/modules/open/workspaces.js | 4 + resources/views/spa.blade.php | 3 +- routes/api.php | 1 + routes/spa.php | 2 +- routes/web.php | 3 + vite.config.js | 9 +- 33 files changed, 1210 insertions(+), 267 deletions(-) create mode 100644 _ide_helper_models.php create mode 100644 app/Http/Controllers/CaddyController.php create mode 100644 app/Http/Middleware/CaddyRequestMiddleware.php create mode 100644 app/Http/Middleware/CustomDomainRestriction.php create mode 100644 app/Http/Requests/Workspace/CustomDomainRequest.php create mode 100644 config/custom-domains.php create mode 100644 database/migrations/2023_11_28_104644_add_custom_domains_to_workspaces_table.php create mode 100644 database/migrations/2023_11_28_161121_add_custom_domain_to_forms_table.php create mode 100644 resources/js/middleware/custom-domains.js delete mode 100644 resources/js/middleware/notion-connection.js diff --git a/.env.example b/.env.example index f781ac1..355837f 100644 --- a/.env.example +++ b/.env.example @@ -74,3 +74,6 @@ ADMIN_EMAILS= TEMPLATE_EDITOR_EMAILS= OPEN_AI_API_KEY= + +CADDY_SECRET= +CADDY_AUTHORIZED_IP= diff --git a/_ide_helper_models.php b/_ide_helper_models.php new file mode 100644 index 0000000..ca91bea --- /dev/null +++ b/_ide_helper_models.php @@ -0,0 +1,462 @@ + + */ + + +namespace App\Models\Forms\AI{ +/** + * App\Models\Forms\AI\AiFormCompletion + * + * @property int $id + * @property string $form_prompt + * @property string $status + * @property mixed|null $result + * @property string $ip + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion query() + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereFormPrompt($value) + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereIp($value) + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereResult($value) + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereStatus($value) + * @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereUpdatedAt($value) + */ + class AiFormCompletion extends \Eloquent {} +} + +namespace App\Models\Forms{ +/** + * App\Models\Forms\Form + * + * @property int $id + * @property int $workspace_id + * @property string $title + * @property string $slug + * @property array $properties + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property bool $notifies + * @property string|null $description + * @property string $submit_button_text + * @property bool $re_fillable + * @property string $re_fill_button_text + * @property string $color + * @property bool $uppercase_labels + * @property bool $no_branding + * @property bool $hide_title + * @property string $submitted_text + * @property string $dark_mode + * @property string|null $webhook_url + * @property bool $send_submission_confirmation + * @property string|null $logo_picture + * @property string|null $cover_picture + * @property string|null $redirect_url + * @property string|null $custom_code + * @property string|null $notification_emails + * @property string $theme + * @property array|null $database_fields_update + * @property string $width + * @property bool $transparent_background + * @property \Illuminate\Support\Carbon|null $closes_at + * @property string|null $closed_text + * @property string $notification_subject + * @property string $notification_body + * @property bool $notifications_include_submission + * @property bool $use_captcha + * @property bool $can_be_indexed + * @property string|null $password + * @property string $notification_sender + * @property array $tags + * @property \Illuminate\Support\Carbon|null $deleted_at + * @property int $creator_id + * @property array $removed_properties + * @property int|null $max_submissions_count + * @property string|null $max_submissions_reached_text + * @property string|null $slack_webhook_url + * @property string $visibility + * @property bool $editable_submissions + * @property string|null $discord_webhook_url + * @property string $editable_submissions_button_text + * @property bool $confetti_on_submission + * @property object $seo_meta + * @property object|null $notification_settings + * @property bool $auto_save + * @property-read \App\Models\User $creator + * @property-read mixed $edit_url + * @property-read mixed $form_pending_submission_key + * @property-read mixed $has_password + * @property-read mixed $is_closed + * @property-read mixed $is_pro + * @property-read mixed $max_number_of_submissions_reached + * @property-read mixed $notifies_discord + * @property-read mixed $notifies_slack + * @property-read mixed $notifies_webhook + * @property-read mixed $share_url + * @property-read int|null $submissions_count + * @property-read int|null $views_count + * @property-read \Illuminate\Database\Eloquent\Collection $statistics + * @property-read int|null $statistics_count + * @property-read \Illuminate\Database\Eloquent\Collection $submissions + * @property-read \Illuminate\Database\Eloquent\Collection $views + * @property-read \App\Models\Workspace|null $workspace + * @property-read \Illuminate\Database\Eloquent\Collection $zappierHooks + * @property-read int|null $zappier_hooks_count + * @method static \Database\Factories\FormFactory factory($count = null, $state = []) + * @method static \Illuminate\Database\Eloquent\Builder|Form newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Form newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Form onlyTrashed() + * @method static \Illuminate\Database\Eloquent\Builder|Form query() + * @method static \Illuminate\Database\Eloquent\Builder|Form whereAutoSave($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereCanBeIndexed($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereClosedText($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereClosesAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereColor($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereConfettiOnSubmission($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereCoverPicture($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatorId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereCustomCode($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereDarkMode($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereDatabaseFieldsUpdate($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereDeletedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereDescription($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereDiscordWebhookUrl($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereEditableSubmissions($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereEditableSubmissionsButtonText($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereHideTitle($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereLogoPicture($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereMaxSubmissionsCount($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereMaxSubmissionsReachedText($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereNoBranding($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationBody($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationEmails($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSender($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSettings($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSubject($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationsIncludeSubmission($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotifies($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form wherePassword($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereProperties($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereReFillButtonText($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereReFillable($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereRedirectUrl($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereRemovedProperties($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereSendSubmissionConfirmation($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereSeoMeta($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereSlackWebhookUrl($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmitButtonText($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmittedText($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereTags($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereTheme($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereTitle($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereTransparentBackground($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereUppercaseLabels($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereUseCaptcha($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereVisibility($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereWebhookUrl($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereWidth($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereWorkspaceId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form withTrashed() + * @method static \Illuminate\Database\Eloquent\Builder|Form withoutTrashed() + */ + class Form extends \Eloquent {} +} + +namespace App\Models\Forms{ +/** + * App\Models\Forms\FormStatistic + * + * @property int $id + * @property int $form_id + * @property array $data + * @property string $date + * @property-read \App\Models\Forms\Form|null $form + * @method static \Illuminate\Database\Eloquent\Builder|FormStatistic newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormStatistic newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormStatistic query() + * @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereData($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereFormId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereId($value) + */ + class FormStatistic extends \Eloquent {} +} + +namespace App\Models\Forms{ +/** + * App\Models\Forms\FormSubmission + * + * @property int $id + * @property int $form_id + * @property array $data + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\Forms\Form|null $form + * @method static \Illuminate\Database\Eloquent\Builder|FormSubmission newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormSubmission newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormSubmission query() + * @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereData($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereFormId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereUpdatedAt($value) + */ + class FormSubmission extends \Eloquent {} +} + +namespace App\Models\Forms{ +/** + * App\Models\Forms\FormView + * + * @property int $id + * @property int $form_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\Forms\Form|null $form + * @method static \Illuminate\Database\Eloquent\Builder|FormView newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormView newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormView query() + * @method static \Illuminate\Database\Eloquent\Builder|FormView whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormView whereFormId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormView whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormView whereUpdatedAt($value) + */ + class FormView extends \Eloquent {} +} + +namespace App\Models\Integration{ +/** + * App\Models\Integration\FormZapierWebhook + * + * @property int $id + * @property int $form_id + * @property string $hook_url + * @property \Illuminate\Support\Carbon|null $deleted_at + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\Forms\Form|null $form + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook onlyTrashed() + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook query() + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereDeletedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereFormId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereHookUrl($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook withTrashed() + * @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook withoutTrashed() + */ + class FormZapierWebhook extends \Eloquent {} +} + +namespace App\Models{ +/** + * App\Models\License + * + * @property int $id + * @property string $license_key + * @property int|null $user_id + * @property string $license_provider + * @property string $status + * @property array $meta + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read mixed $custom_domain_limit_count + * @property-read mixed $max_file_size + * @property-read \App\Models\User|null $user + * @method static \Illuminate\Database\Eloquent\Builder|License active() + * @method static \Illuminate\Database\Eloquent\Builder|License newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|License newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|License query() + * @method static \Illuminate\Database\Eloquent\Builder|License whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|License whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|License whereLicenseKey($value) + * @method static \Illuminate\Database\Eloquent\Builder|License whereLicenseProvider($value) + * @method static \Illuminate\Database\Eloquent\Builder|License whereMeta($value) + * @method static \Illuminate\Database\Eloquent\Builder|License whereStatus($value) + * @method static \Illuminate\Database\Eloquent\Builder|License whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|License whereUserId($value) + */ + class License extends \Eloquent {} +} + +namespace App\Models{ +/** + * App\Models\OAuthProvider + * + * @property int $id + * @property int $user_id + * @property string $provider + * @property string $provider_user_id + * @property string|null $access_token + * @property string|null $refresh_token + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\User $user + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider query() + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereAccessToken($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereProvider($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereProviderUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereRefreshToken($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereUserId($value) + */ + class OAuthProvider extends \Eloquent {} +} + +namespace App\Models{ +/** + * App\Models\Template + * + * @property int $id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property string $name + * @property string $slug + * @property string $description + * @property string|null $image_url + * @property array $structure + * @property array $questions + * @property bool $publicly_listed + * @property array $industries + * @property array $types + * @property string|null $short_description + * @property array $related_templates + * @property int|null $creator_id + * @property-read mixed $share_url + * @method static \Illuminate\Database\Eloquent\Builder|Template newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Template newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Template publiclyListed() + * @method static \Illuminate\Database\Eloquent\Builder|Template query() + * @method static \Illuminate\Database\Eloquent\Builder|Template whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereCreatorId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereDescription($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereImageUrl($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereIndustries($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template wherePubliclyListed($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereQuestions($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereRelatedTemplates($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereShortDescription($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereStructure($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereTypes($value) + * @method static \Illuminate\Database\Eloquent\Builder|Template whereUpdatedAt($value) + */ + class Template extends \Eloquent {} +} + +namespace App\Models{ +/** + * App\Models\User + * + * @property int $id + * @property string $name + * @property string $email + * @property \Illuminate\Support\Carbon|null $email_verified_at + * @property string|null $password + * @property string|null $remember_token + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property string|null $stripe_id + * @property string|null $pm_type + * @property string|null $pm_last_four + * @property string|null $trial_ends_at + * @property string|null $hear_about_us + * @property-read \Illuminate\Database\Eloquent\Collection $formTemplates + * @property-read int|null $form_templates_count + * @property-read \Illuminate\Database\Eloquent\Collection $forms + * @property-read int|null $forms_count + * @property-read mixed $admin + * @property-read mixed $has_customer_id + * @property-read mixed $has_forms + * @property-read mixed $is_risky + * @property-read mixed $is_subscribed + * @property-read string $photo_url + * @property-read mixed $template_editor + * @property-read \Illuminate\Database\Eloquent\Collection $licenses + * @property-read int|null $licenses_count + * @property-read \Illuminate\Notifications\DatabaseNotificationCollection $notifications + * @property-read int|null $notifications_count + * @property-read \Illuminate\Database\Eloquent\Collection $oauthProviders + * @property-read int|null $oauth_providers_count + * @property-read \Illuminate\Database\Eloquent\Collection $subscriptions + * @property-read int|null $subscriptions_count + * @property-read \Illuminate\Database\Eloquent\Collection $workspaces + * @property-read int|null $workspaces_count + * @method static \Database\Factories\UserFactory factory($count = null, $state = []) + * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User query() + * @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerifiedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereHearAboutUs($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value) + * @method static \Illuminate\Database\Eloquent\Builder|User wherePmLastFour($value) + * @method static \Illuminate\Database\Eloquent\Builder|User wherePmType($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereStripeId($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereTrialEndsAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User withActiveSubscription() + */ + class User extends \Eloquent implements \Tymon\JWTAuth\Contracts\JWTSubject {} +} + +namespace App\Models{ +/** + * App\Models\Workspace + * + * @property int $id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property string $name + * @property string|null $icon + * @property mixed|null $custom_domains + * @property-read \Illuminate\Database\Eloquent\Collection $forms + * @property-read int|null $forms_count + * @property-read mixed $custom_domain_count_limit + * @property-read mixed $is_enterprise + * @property-read mixed $is_pro + * @property-read mixed $is_risky + * @property-read mixed $max_file_size + * @property-read mixed $submissions_count + * @property-read \Illuminate\Database\Eloquent\Collection $users + * @property-read int|null $users_count + * @method static \Illuminate\Database\Eloquent\Builder|Workspace newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Workspace newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Workspace query() + * @method static \Illuminate\Database\Eloquent\Builder|Workspace whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Workspace whereCustomDomains($value) + * @method static \Illuminate\Database\Eloquent\Builder|Workspace whereIcon($value) + * @method static \Illuminate\Database\Eloquent\Builder|Workspace whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Workspace whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Workspace whereUpdatedAt($value) + */ + class Workspace extends \Eloquent {} +} + diff --git a/app/Http/Controllers/CaddyController.php b/app/Http/Controllers/CaddyController.php new file mode 100644 index 0000000..8977fbf --- /dev/null +++ b/app/Http/Controllers/CaddyController.php @@ -0,0 +1,36 @@ +validate([ + 'domain' => 'required|string', + ]); + // make sure domain is valid + $domain = $request->input('domain'); + if (!preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/', $domain)) { + return $this->error([ + 'success' => false, + 'message' => 'Invalid domain', + ]); + } + + if (Workspace::whereJsonContains('custom_domains',$domain)->exists()) { + return $this->success([ + 'success' => true, + 'message' => 'OK', + ]); + } + + return $this->error([ + 'success' => false, + 'message' => 'Unauthorized domain', + ]); + } +} diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/WorkspaceController.php index ae5efc2..4efebee 100644 --- a/app/Http/Controllers/WorkspaceController.php +++ b/app/Http/Controllers/WorkspaceController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Http\Controllers\Controller; +use App\Http\Requests\Workspace\CustomDomainRequest; use App\Models\Workspace; use Illuminate\Http\Request; use App\Service\WorkspaceHelper; @@ -29,6 +30,13 @@ class WorkspaceController extends Controller return (new WorkspaceHelper($workspace))->getAllUsers(); } + public function saveCustomDomain(CustomDomainRequest $request) + { + $request->workspace->custom_domains = $request->customDomains; + $request->workspace->save(); + return $request->workspace; + } + public function delete($id) { $workspace = Workspace::findOrFail($id); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 970e320..54c54dd 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -2,6 +2,7 @@ namespace App\Http; +use App\Http\Middleware\CustomDomainRestriction; use App\Http\Middleware\EmbeddableForms; use App\Http\Middleware\IsAdmin; use App\Http\Middleware\IsNotSubscribed; @@ -26,6 +27,7 @@ class Kernel extends HttpKernel \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, \App\Http\Middleware\SetLocale::class, + CustomDomainRestriction::class, ]; /** diff --git a/app/Http/Middleware/CaddyRequestMiddleware.php b/app/Http/Middleware/CaddyRequestMiddleware.php new file mode 100644 index 0000000..d367461 --- /dev/null +++ b/app/Http/Middleware/CaddyRequestMiddleware.php @@ -0,0 +1,39 @@ +json([ + 'success' => false, + 'message' => 'Custom domains not enabled', + ], 401); + } + + if (config('custom-domains.enabled') && !in_array($request->ip(), config('custom-domains.authorized_ips'))) { + return response()->json([ + 'success' => false, + 'message' => 'Unauthorized', + ], 401); + } + + $secret = $request->route('secret'); + if (config('custom-domains.caddy_secret') && (!$secret || $secret !== config('custom-domains.caddy_secret'))) { + return response()->json([ + 'success' => false, + 'message' => 'Unauthorized', + ], 401); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CustomDomainRestriction.php b/app/Http/Middleware/CustomDomainRestriction.php new file mode 100644 index 0000000..0b970b7 --- /dev/null +++ b/app/Http/Middleware/CustomDomainRestriction.php @@ -0,0 +1,55 @@ +hasHeader(self::CUSTOM_DOMAIN_HEADER) || !config('custom-domains.enabled')) { + return $next($request); + } + + $customDomain = $request->header(self::CUSTOM_DOMAIN_HEADER); + if (!preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/', $customDomain)) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid domain', + ], 400); + } + + // Check if domain is different from current domain + $notionFormsDomain = parse_url(config('app.url'))['host']; + if ($customDomain == $notionFormsDomain) { + return $next($request); + } + + // Check if domain is known + if (!$workspace = Workspace::whereJsonContains('custom_domains',$customDomain)->first()) { + return response()->json([ + 'success' => false, + 'message' => 'Unknown domain', + ], 400); + } + + Workspace::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspace) { + $builder->where('workspaces.id', $workspace->id); + }); + Form::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspace) { + $builder->where('forms.workspace_id', $workspace->id); + }); + + return $next($request); + } +} diff --git a/app/Http/Requests/UserFormRequest.php b/app/Http/Requests/UserFormRequest.php index 1006095..81b0da7 100644 --- a/app/Http/Requests/UserFormRequest.php +++ b/app/Http/Requests/UserFormRequest.php @@ -125,7 +125,8 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest 'password' => 'sometimes|nullable', // Custom SEO - 'seo_meta' => 'nullable|array' + 'seo_meta' => 'nullable|array', + 'custom_domain' => 'sometimes|nullable|regex:/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/' ]; } diff --git a/app/Http/Requests/Workspace/CustomDomainRequest.php b/app/Http/Requests/Workspace/CustomDomainRequest.php new file mode 100644 index 0000000..82a3e66 --- /dev/null +++ b/app/Http/Requests/Workspace/CustomDomainRequest.php @@ -0,0 +1,58 @@ +workspace = Workspace::findOrFail($request->workspaceId); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'custom_domains' => [ + 'present', + 'array', + function($attribute, $value, $fail) { + $errors = []; + $domains = collect($value)->filter(function ($domain) { + return !empty( trim($domain) ); + })->each(function($domain) use (&$errors) { + if (!preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/', $domain)) { + $errors[] = 'Invalid domain: ' . $domain; + } + }); + + if (count($errors)) { + $fail($errors); + } + + $limit = $this->workspace->custom_domain_count_limit; + if ($limit && $domains->count() > $limit) { + $fail('You can only add ' . $limit . ' domain(s).'); + } + + $this->customDomains = $domains->toArray(); + } + ] + ]; + } + + protected function passedValidation(){ + $this->replace(['custom_domains' => $this->customDomains]); + } +} diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index ede1be7..497a1e3 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -53,6 +53,7 @@ class Form extends Model 'visibility', // Customization + 'custom_domain', 'theme', 'width', 'cover_picture', @@ -141,12 +142,15 @@ class Form extends Model public function getShareUrlAttribute() { - return url('/forms/'.$this->slug); + if ($this->custom_domain) { + return 'https://' . $this->custom_domain . '/forms/' . $this->slug; + } + return url('/forms/' . $this->slug); } public function getEditUrlAttribute() { - return url('/forms/'.$this->slug.'/show'); + return url('/forms/' . $this->slug . '/show'); } public function getSubmissionsCountAttribute() @@ -156,12 +160,12 @@ class Form extends Model public function getViewsCountAttribute() { - if(env('DB_CONNECTION') == 'pgsql') { + if (env('DB_CONNECTION') == 'pgsql') { return $this->views()->count() + - $this->statistics()->sum(DB::raw("cast(data->>'views' as integer)")); - } elseif(env('DB_CONNECTION') == 'mysql') { + $this->statistics()->sum(DB::raw("cast(data->>'views' as integer)")); + } elseif (env('DB_CONNECTION') == 'mysql') { return (int)($this->views()->count() + - $this->statistics()->sum(DB::raw("json_extract(data, '$.views')"))); + $this->statistics()->sum(DB::raw("json_extract(data, '$.views')"))); } return 0; } @@ -219,7 +223,8 @@ class Form extends Model return !empty($this->password); } - protected function removedProperties(): Attribute { + protected function removedProperties(): Attribute + { return Attribute::make( get: function ($value) { return $value ? json_decode($value, true) : []; @@ -283,7 +288,7 @@ class Form extends Model { return !empty($this->webhook_url); } - + public function getNotifiesDiscordAttribute() { return !empty($this->discord_webhook_url); diff --git a/app/Models/License.php b/app/Models/License.php index bb72f34..5102737 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -42,4 +42,13 @@ class License extends Model 3 => 75000000, // 75 MB, ][$this->meta['tier']]; } + + public function getCustomDomainLimitCountAttribute() + { + return [ + 1 => 5, + 2 => 25, + 3 => null, + ][$this->meta['tier']]; + } } diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index c0efa23..262633a 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -14,10 +14,13 @@ class Workspace extends Model const MAX_FILE_SIZE_FREE = 5000000; // 5 MB const MAX_FILE_SIZE_PRO = 50000000; // 50 MB + const MAX_DOMAIN_PRO = 1; + protected $fillable = [ 'name', 'icon', 'user_id', + 'custom_domain', ]; protected $appends = [ @@ -25,6 +28,10 @@ class Workspace extends Model 'is_enterprise' ]; + protected $casts = [ + 'custom_domains' => 'array', + ]; + public function getIsProAttribute() { if(is_null(config('cashier.key'))){ @@ -60,6 +67,26 @@ class Workspace extends Model return self::MAX_FILE_SIZE_FREE; } + public function getCustomDomainCountLimitAttribute() + { + if(is_null(config('cashier.key'))){ + return null; + } + + // 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->custom_domain_limit_count; + } + } + return self::MAX_DOMAIN_PRO; + } + + return 0; + } + public function getIsEnterpriseAttribute() { if(is_null(config('cashier.key'))){ diff --git a/config/custom-domains.php b/config/custom-domains.php new file mode 100644 index 0000000..197a1e7 --- /dev/null +++ b/config/custom-domains.php @@ -0,0 +1,9 @@ + !empty(env('CADDY_SECRET')) && !empty(env('CADDY_AUTHORIZED_IPS', [])), + 'caddy_secret' => env('CADDY_SECRET'), + 'authorized_ips' => env('CADDY_AUTHORIZED_IPS', []), + +]; diff --git a/database/migrations/2023_11_28_104644_add_custom_domains_to_workspaces_table.php b/database/migrations/2023_11_28_104644_add_custom_domains_to_workspaces_table.php new file mode 100644 index 0000000..166d075 --- /dev/null +++ b/database/migrations/2023_11_28_104644_add_custom_domains_to_workspaces_table.php @@ -0,0 +1,37 @@ +json('custom_domains')->default(new Expression("(JSON_OBJECT())"))->nullable(true); + } else { + $table->json('custom_domains')->default('{}')->nullable(true); + } + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('workspaces', function (Blueprint $table) { + $table->dropColumn('custom_domains'); + }); + } +}; diff --git a/database/migrations/2023_11_28_161121_add_custom_domain_to_forms_table.php b/database/migrations/2023_11_28_161121_add_custom_domain_to_forms_table.php new file mode 100644 index 0000000..456a758 --- /dev/null +++ b/database/migrations/2023_11_28_161121_add_custom_domain_to_forms_table.php @@ -0,0 +1,32 @@ +string('custom_domain')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn(['custom_domain']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index ab16766..d6d676f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4253,9 +4253,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001434", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001434.tgz", - "integrity": "sha512-aOBHrLmTQw//WFa2rcF1If9fa3ypkC1wzqqiKHgfdrXTWcU8C4gKVZT77eQAPWN1APys3+uQ0Df07rKauXGEYA==", + "version": "1.0.30001565", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz", + "integrity": "sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==", "dev": true, "funding": [ { @@ -4265,6 +4265,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -16655,9 +16659,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001434", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001434.tgz", - "integrity": "sha512-aOBHrLmTQw//WFa2rcF1If9fa3ypkC1wzqqiKHgfdrXTWcU8C4gKVZT77eQAPWN1APys3+uQ0Df07rKauXGEYA==", + "version": "1.0.30001565", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz", + "integrity": "sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==", "dev": true }, "caw": { diff --git a/resources/js/components/forms/TextAreaInput.vue b/resources/js/components/forms/TextAreaInput.vue index a25e83a..8c1476e 100644 --- a/resources/js/components/forms/TextAreaInput.vue +++ b/resources/js/components/forms/TextAreaInput.vue @@ -22,9 +22,9 @@ - + - {{ charCount }}/{{ maxCharLimit }} + {{ charCount }}/{{ maxCharLimift }} @@ -40,10 +40,10 @@ export default { props: { maxCharLimit: { type: Number, required: false, default: null }, - showCharLimit: { type: Boolean, required: false, default: false }, + showCharLimit: { type: Boolean, required: false, default: false } }, computed: { - charCount() { + charCount () { return (this.compVal) ? this.compVal.length : 0 } } diff --git a/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue b/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue index 44a136c..feed7f6 100644 --- a/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue +++ b/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue @@ -8,8 +8,11 @@

- Customize the image and text that appear when you share your form on other sites (Open Graph). + Customize the link, images and text that appear when you share your form on other sites (Open Graph).

+ @@ -41,6 +44,20 @@ export default { set (value) { this.$store.commit('open/working_form/set', value) } + }, + workspace () { + return this.$store.getters['open/workspaces/getCurrent']() + }, + customDomainOptions () { + return this.workspace.custom_domains.map((domain) => { + return { + name: domain, + value: domain + } + }) + }, + customDomainAllowed () { + return window.config.custom_domains_enabled } }, watch: {}, diff --git a/resources/js/components/pages/forms/show/ExtraMenu.vue b/resources/js/components/pages/forms/show/ExtraMenu.vue index d59a55a..f74b58b 100644 --- a/resources/js/components/pages/forms/show/ExtraMenu.vue +++ b/resources/js/components/pages/forms/show/ExtraMenu.vue @@ -1,103 +1,116 @@ diff --git a/resources/js/router/index.js b/resources/js/router/index.js index 4e7b466..8004e5d 100644 --- a/resources/js/router/index.js +++ b/resources/js/router/index.js @@ -3,14 +3,14 @@ import store from '~/store' import Meta from 'vue-meta' import routes from './routes' import Router from 'vue-router' -import {sync} from 'vuex-router-sync' +import { sync } from 'vuex-router-sync' import * as Sentry from '@sentry/vue' Vue.use(Meta) Vue.use(Router) // The middleware for every page of the application. -const globalMiddleware = ['locale', 'check-auth', 'notion-connection'] +const globalMiddleware = ['locale', 'check-auth', 'custom-domains'] // Load middleware modules dynamically. const requireContext = import.meta.glob('../middleware/**/*.js', { eager: true }) @@ -253,7 +253,7 @@ function resolveMiddleware (requireContext) { .map(file => [file.match(/[^/]*(?=\.[^.]*$)/)[0], requireContext[file]] ).forEach(([name, middleware]) => { - middlewares[name] = middleware.default || middleware - }) + middlewares[name] = middleware.default || middleware + }) return middlewares } diff --git a/resources/js/store/modules/open/workspaces.js b/resources/js/store/modules/open/workspaces.js index c14becf..ff56b11 100644 --- a/resources/js/store/modules/open/workspaces.js +++ b/resources/js/store/modules/open/workspaces.js @@ -61,6 +61,10 @@ export const mutations = { }, remove (state, itemId) { state.content = state.content.filter((val) => val.id !== itemId) + if (state.currentId === itemId) { + state.currentId = state.content.length > 0 ? state.content[0].id : null + localStorage.setItem(localStorageCurrentWorkspaceKey, state.currentId) + } }, startLoading () { state.loading = true diff --git a/resources/views/spa.blade.php b/resources/views/spa.blade.php index a4f4ab8..7bfab3a 100644 --- a/resources/views/spa.blade.php +++ b/resources/views/spa.blade.php @@ -16,7 +16,8 @@ 'crisp_website_id' => config('services.crisp_website_id'), 'ai_features_enabled' => !is_null(config('services.openai.api_key')), 's3_enabled' => config('filesystems.default') === 's3', - 'paid_plans_enabled' => !is_null(config('cashier.key')) + 'paid_plans_enabled' => !is_null(config('cashier.key')), + 'custom_domains_enabled' => config('custom-domains.enabled'), ]; @endphp diff --git a/routes/api.php b/routes/api.php index 1719bea..5368579 100644 --- a/routes/api.php +++ b/routes/api.php @@ -69,6 +69,7 @@ Route::group(['middleware' => 'auth:api'], function () { Route::get('/forms', [FormController::class, 'index'])->name('forms.index'); + Route::put('/custom-domains', [WorkspaceController::class, 'saveCustomDomain'])->name('save-custom-domains'); Route::delete('/', [WorkspaceController::class, 'delete'])->name('delete'); Route::get('form-stats/{formId}', [FormStatsController::class, 'getFormStats'])->name('form.stats'); diff --git a/routes/spa.php b/routes/spa.php index 918171f..91f771d 100644 --- a/routes/spa.php +++ b/routes/spa.php @@ -14,4 +14,4 @@ use Illuminate\Support\Facades\Route; | */ -Route::get('{path}', SpaController::class)->where('path', '^(?!(api|stats|mailcoach|vapor|sitemap|dist)).*$'); \ No newline at end of file +Route::get('{path}', SpaController::class)->where('path', '^(?!(api|stats|mailcoach|vapor|sitemap|caddy|dist)).*$'); diff --git a/routes/web.php b/routes/web.php index a7abe4f..e74bf09 100644 --- a/routes/web.php +++ b/routes/web.php @@ -38,3 +38,6 @@ Route::get('local/temp/{path}', function (Request $request, string $path){ })->where('path', '(.*)')->name('local.temp'); Route::get('/sitemap.xml', [\App\Http\Controllers\SitemapController::class, 'getSitemap'])->name('sitemap'); + +Route::get('caddy/ask-certificate/{secret?}', [\App\Http\Controllers\CaddyController::class, 'ask']) + ->name('caddy.ask')->middleware(\App\Http\Middleware\CaddyRequestMiddleware::class); diff --git a/vite.config.js b/vite.config.js index 2aea756..0393366 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,7 +7,8 @@ const plugins = [ laravel({ input: [ 'resources/js/app.js' - ] + ], + refresh: true }), vue({ template: { @@ -46,5 +47,11 @@ export default defineConfig({ '~': '/resources/js', '@': '/resources' } + }, + server: { + hmr: { + host: 'localhost', + protocol: 'ws' + } } }) From ac968403b658151e81bbcae88b4c1949f692b38c Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Wed, 29 Nov 2023 16:16:31 +0100 Subject: [PATCH 02/16] Log caddy request, cache in vuex WS domain changes --- app/Http/Controllers/CaddyController.php | 15 ++++++++++++++- resources/js/pages/settings/workspace.vue | 3 ++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/CaddyController.php b/app/Http/Controllers/CaddyController.php index 8977fbf..245a56b 100644 --- a/app/Http/Controllers/CaddyController.php +++ b/app/Http/Controllers/CaddyController.php @@ -21,13 +21,26 @@ class CaddyController extends Controller ]); } - if (Workspace::whereJsonContains('custom_domains',$domain)->exists()) { + \Log::info('Caddy request received',[ + 'domain' => $domain, + ]); + + if ($workspace = Workspace::whereJsonContains('custom_domains',$domain)->first()) { + \Log::info('Caddy request successful',[ + 'domain' => $domain, + 'workspace' => $workspace->id, + ]); return $this->success([ 'success' => true, 'message' => 'OK', ]); } + \Log::info('Caddy request failed',[ + 'domain' => $domain, + 'workspace' => $workspace?->id, + ]); + return $this->error([ 'success' => false, 'message' => 'Unauthorized domain', diff --git a/resources/js/pages/settings/workspace.vue b/resources/js/pages/settings/workspace.vue index 826d68c..0ff63d3 100644 --- a/resources/js/pages/settings/workspace.vue +++ b/resources/js/pages/settings/workspace.vue @@ -168,7 +168,8 @@ export default { custom_domains: this.customDomains.split('\n') .map(domain => domain.trim()) .filter(domain => domain && domain.length > 0) - }).then(() => { + }).then((response) => { + this.$store.commit('open/workspaces/addOrUpdate', response.data) this.alertSuccess('Custom domains saved.') }).catch((error) => { this.alertError('Failed to update custom domains: ' + error.response.data.message) From 042b029d5f6d42c11a8bc8d30cd938794aa68c91 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Wed, 29 Nov 2023 18:00:53 +0100 Subject: [PATCH 03/16] Fix caddy authorized IPs --- config/custom-domains.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/custom-domains.php b/config/custom-domains.php index 197a1e7..17c3a53 100644 --- a/config/custom-domains.php +++ b/config/custom-domains.php @@ -4,6 +4,6 @@ return [ 'enabled' => !empty(env('CADDY_SECRET')) && !empty(env('CADDY_AUTHORIZED_IPS', [])), 'caddy_secret' => env('CADDY_SECRET'), - 'authorized_ips' => env('CADDY_AUTHORIZED_IPS', []), + 'authorized_ips' => explode(',', env('CADDY_AUTHORIZED_IPS')), ]; From 0b57ab4cddaf16e3232bccf74618a1e06cf7d207 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Thu, 30 Nov 2023 11:20:19 +0100 Subject: [PATCH 04/16] Better error message for Caddy authorization --- app/Http/Middleware/CaddyRequestMiddleware.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Middleware/CaddyRequestMiddleware.php b/app/Http/Middleware/CaddyRequestMiddleware.php index d367461..43def15 100644 --- a/app/Http/Middleware/CaddyRequestMiddleware.php +++ b/app/Http/Middleware/CaddyRequestMiddleware.php @@ -22,7 +22,7 @@ class CaddyRequestMiddleware if (config('custom-domains.enabled') && !in_array($request->ip(), config('custom-domains.authorized_ips'))) { return response()->json([ 'success' => false, - 'message' => 'Unauthorized', + 'message' => 'Unauthorized IP', ], 401); } From 164b8c4ddd85aaabd4793b3ae979463146621155 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Thu, 30 Nov 2023 11:31:56 +0100 Subject: [PATCH 05/16] Fix the custom domain default redirect --- resources/js/middleware/custom-domains.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/middleware/custom-domains.js b/resources/js/middleware/custom-domains.js index abfbe75..2438c3c 100644 --- a/resources/js/middleware/custom-domains.js +++ b/resources/js/middleware/custom-domains.js @@ -1,11 +1,11 @@ export default async (to, from, next) => { - if (!window.config.custom_domains) { + if (!window.config.custom_domains_enabled) { next() } const isCustomDomain = getDomain(window.location.href) !== getDomain(window.config.app_url) if (isCustomDomain && !['forms.show_public'].includes(to.name)) { // If route isn't a public form, redirect - window.location.href = 'https://opnform.com/' + window.location.href = 'https://opnform.com/?utm_source=failed_custom_domain_redirect' } else { next() } From 8fd85776c6888feaf77dc7a507a4532ce9b76f25 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Thu, 30 Nov 2023 11:45:23 +0100 Subject: [PATCH 06/16] add missing url --- resources/views/spa.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/views/spa.blade.php b/resources/views/spa.blade.php index 7bfab3a..98e8f78 100644 --- a/resources/views/spa.blade.php +++ b/resources/views/spa.blade.php @@ -1,6 +1,7 @@ @php $config = [ 'appName' => config('app.name'), + 'app_url' => config('app.url'), 'locale' => $locale = app()->getLocale(), 'locales' => config('app.locales'), 'githubAuth' => config('services.github.client_id'), From a18077934c921a5e09801f211680f25f07ed3243 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Sat, 2 Dec 2023 14:51:08 +0100 Subject: [PATCH 07/16] Cached some model properties and remove useless eager loading (#253) * Cached some model properties and remove useless eager loading * Remove ray call * Remove double loading of forms * Add disableCache feature when needed --- app/Http/Controllers/Forms/FormController.php | 4 +- app/Http/Resources/FormResource.php | 1 - app/Models/Forms/Form.php | 32 +++-- app/Models/Traits/CachableAttributes.php | 45 +++++++ app/Models/Traits/CachesAttributes.php | 112 ++++++++++++++++ app/Models/User.php | 4 +- app/Models/Workspace.php | 121 +++++++++++------- app/Policies/WorkspacePolicy.php | 2 +- resources/js/components/Navbar.vue | 61 +++++---- tests/Feature/Forms/AnswerFormTest.php | 4 +- 10 files changed, 291 insertions(+), 95 deletions(-) create mode 100644 app/Models/Traits/CachableAttributes.php create mode 100644 app/Models/Traits/CachesAttributes.php diff --git a/app/Http/Controllers/Forms/FormController.php b/app/Http/Controllers/Forms/FormController.php index 2a0fc0c..9e0d85c 100644 --- a/app/Http/Controllers/Forms/FormController.php +++ b/app/Http/Controllers/Forms/FormController.php @@ -34,7 +34,7 @@ class FormController extends Controller $this->authorize('viewAny', Form::class); $workspaceIsPro = $workspace->is_pro; - $forms = $workspace->forms()->with(['creator','views','submissions']) + $forms = $workspace->forms() ->orderByDesc('updated_at') ->paginate(10)->through(function (Form $form) use ($workspace, $workspaceIsPro){ @@ -66,7 +66,7 @@ class FormController extends Controller $this->authorize('viewAny', Form::class); $workspaceIsPro = $workspace->is_pro; - $newForms = $workspace->forms()->with(['creator','views','submissions'])->get()->map(function (Form $form) use ($workspace, $workspaceIsPro){ + $newForms = $workspace->forms()->get()->map(function (Form $form) use ($workspace, $workspaceIsPro){ // Add attributes for faster loading $form->extra = (object) [ 'loadedWorkspace' => $workspace, diff --git a/app/Http/Resources/FormResource.php b/app/Http/Resources/FormResource.php index 7f6a86f..34c04b1 100644 --- a/app/Http/Resources/FormResource.php +++ b/app/Http/Resources/FormResource.php @@ -25,7 +25,6 @@ class FormResource extends JsonResource } $ownerData = $this->userIsFormOwner() ? [ - 'creator' => new UserResource($this->creator), 'views_count' => $this->views_count, 'submissions_count' => $this->submissions_count, 'notifies' => $this->notifies, diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index 497a1e3..e76f399 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -4,6 +4,8 @@ namespace App\Models\Forms; use App\Events\Models\FormCreated; use App\Models\Integration\FormZapierWebhook; +use App\Models\Traits\CachableAttributes; +use App\Models\Traits\CachesAttributes; use App\Models\User; use App\Models\Workspace; use Database\Factories\FormFactory; @@ -17,8 +19,9 @@ use Stevebauman\Purify\Facades\Purify; use Illuminate\Support\Facades\DB; use Illuminate\Database\Eloquent\Casts\Attribute; -class Form extends Model +class Form extends Model implements CachableAttributes { + use CachesAttributes; const DARK_MODE_VALUES = ['auto', 'light', 'dark']; const THEMES = ['default', 'simple', 'notion']; const WIDTHS = ['centered', 'full']; @@ -126,6 +129,12 @@ class Form extends Model 'removed_properties' ]; + protected $cachableAttributes = [ + 'is_pro', + 'submissions_count', + 'views_count', + ]; + /** * The event map for the model. * @@ -137,7 +146,9 @@ class Form extends Model public function getIsProAttribute() { - return optional($this->workspace)->is_pro; + return $this->remember('is_pro', 15, function(): bool { + return optional($this->workspace)->is_pro; + }); } public function getShareUrlAttribute() @@ -155,19 +166,21 @@ class Form extends Model public function getSubmissionsCountAttribute() { - return $this->submissions()->count(); + return $this->remember('submissions_count', 5, function(): int { + return $this->submissions()->count(); + }); } public function getViewsCountAttribute() { - if (env('DB_CONNECTION') == 'pgsql') { + return $this->remember('views_count', 5, function(): int { + if (env('DB_CONNECTION') == 'mysql') { + return (int)($this->views()->count() + + $this->statistics()->sum(DB::raw("json_extract(data, '$.views')"))); + } return $this->views()->count() + $this->statistics()->sum(DB::raw("cast(data->>'views' as integer)")); - } elseif (env('DB_CONNECTION') == 'mysql') { - return (int)($this->views()->count() + - $this->statistics()->sum(DB::raw("json_extract(data, '$.views')"))); - } - return 0; + }); } public function setDescriptionAttribute($value) @@ -205,6 +218,7 @@ class Form extends Model public function getMaxNumberOfSubmissionsReachedAttribute() { + $this->disableCache('submissions_count'); return ($this->max_submissions_count && $this->max_submissions_count <= $this->submissions_count); } diff --git a/app/Models/Traits/CachableAttributes.php b/app/Models/Traits/CachableAttributes.php new file mode 100644 index 0000000..d012505 --- /dev/null +++ b/app/Models/Traits/CachableAttributes.php @@ -0,0 +1,45 @@ + */ + protected $attributeCache = []; + + protected $disabledCache = []; + + public static function bootCachesAttributes(): void + { + static::deleting(function (Model $model): void { + /** @var Model|CachableAttributes $model */ + $model->flush(); + }); + } + + public function disableCache($key) + { + $this->disabledCache[] = $key; + return $this; + } + + public function remember(string $attribute, ?int $ttl, Closure $callback) + { + if (in_array($attribute, $this->disabledCache)) { + return value($callback); + } + + if ($ttl === 0 || ! $this->exists) { + if (! isset($this->attributeCache[$attribute])) { + $this->attributeCache[$attribute] = value($callback); + } + + return $this->attributeCache[$attribute]; + } + + if ($ttl === null) { + return $this->getCacheRepository()->rememberForever($this->getCacheKey($attribute), $callback); + } + + if ($ttl < 0) { + throw new InvalidArgumentException("The TTL has to be null, 0 or any positive number - you provided `{$ttl}`."); + } + + return $this->getCacheRepository()->remember($this->getCacheKey($attribute), $ttl, $callback); + } + + public function rememberForever(string $attribute, Closure $callback) + { + return $this->remember($attribute, null, $callback); + } + + public function forget(string $attribute): bool + { + unset($this->attributeCache[$attribute]); + + if (! $this->exists) { + return true; + } + + return $this->getCacheRepository()->forget($this->getCacheKey($attribute)); + } + + public function flush(): bool + { + $result = true; + + foreach ($this->cachableAttributes ?? [] as $attribute) { + $result = $this->forget($attribute) ? $result : false; + } + + return $result; + } + + protected function getCacheKey(string $attribute): string + { + return implode('.', [ + $this->attributeCachePrefix ?? 'model_attribute_cache', + $this->getConnectionName() ?? 'connection', + $this->getTable(), + $this->getKey(), + $attribute, + $this->updated_at?->timestamp ?? '0' + ]); + } + + protected function getCacheRepository(): CacheRepository + { + return $this->getCacheFactory()->store($this->attributeCacheStore); + } + + protected function getCacheFactory(): CacheFactoryContract + { + return app('cache'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 764d927..a7c3127 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -59,13 +59,11 @@ class User extends Authenticatable implements JWTSubject 'photo_url', ]; - protected $withCount = ['workspaces']; - public function ownsForm(Form $form) { return $this->workspaces()->find($form->workspace_id) !== null; } - + /** * Get the profile photo URL attribute. * diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 262633a..8bf903b 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -4,12 +4,14 @@ namespace App\Models; use App\Http\Requests\AnswerFormRequest; use App\Models\Forms\Form; +use App\Models\Traits\CachableAttributes; +use App\Models\Traits\CachesAttributes; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -class Workspace extends Model +class Workspace extends Model implements CachableAttributes { - use HasFactory; + use HasFactory, CachesAttributes; const MAX_FILE_SIZE_FREE = 5000000; // 5 MB const MAX_FILE_SIZE_PRO = 50000000; // 50 MB @@ -32,20 +34,14 @@ class Workspace extends Model 'custom_domains' => 'array', ]; - public function getIsProAttribute() - { - 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) { - if ($owner->is_subscribed) { - return true; - } - } - return false; - } + protected $cachableAttributes = [ + 'is_pro', + 'is_enterprise', + 'is_risky', + 'submissions_count', + 'max_file_size', + 'custom_domain_count' + ]; public function getMaxFileSizeAttribute() { @@ -53,18 +49,20 @@ class Workspace extends Model 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 $this->remember('max_file_size', 15, function(): int { + // 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_PRO; - } - return self::MAX_FILE_SIZE_FREE; + return self::MAX_FILE_SIZE_FREE; + }); } public function getCustomDomainCountLimitAttribute() @@ -73,18 +71,36 @@ class Workspace extends Model return null; } - // 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->custom_domain_limit_count; + return $this->remember('custom_domain_count', 15, function(): int { + foreach ($this->owners as $owner) { + if ($owner->is_subscribed) { + if ($license = $owner->activeLicense()) { + // In case of special License + return $license->custom_domain_limit_count; + } } + return self::MAX_DOMAIN_PRO; } - return self::MAX_DOMAIN_PRO; + + return 0; + }); + } + + public function getIsProAttribute() + { + if(is_null(config('cashier.key'))){ + return true; // If no paid plan so TRUE for ALL } - return 0; + return $this->remember('is_pro', 15, function(): bool { + // Make sure at least one owner is pro + foreach ($this->owners as $owner) { + if ($owner->is_subscribed) { + return true; + } + } + return false; + }); } public function getIsEnterpriseAttribute() @@ -93,34 +109,41 @@ class Workspace extends Model return true; // If no paid plan so TRUE for ALL } - foreach ($this->owners as $owner) { - if ($owner->has_enterprise_subscription) { - return true; + return $this->remember('is_enterprise', 15, function(): bool { + // Make sure at least one owner is pro + foreach ($this->owners as $owner) { + if ($owner->has_enterprise_subscription) { + return true; + } } - } - return false; + 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 $this->remember('is_risky', 15, function(): bool { + // A workspace is risky if all of his users are risky + foreach ($this->owners as $owner) { + if (!$owner->is_risky) { + return false; + } } - } - return true; + return true; + }); } public function getSubmissionsCountAttribute() { - $total = 0; - foreach ($this->forms as $form) { - $total += $form->submissions_count; - } + return $this->remember('submissions_count', 15, function(): int { + $total = 0; + foreach ($this->forms as $form) { + $total += $form->submissions_count; + } - return $total; + return $total; + }); } /** diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index e97a25e..4a237d8 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -30,7 +30,7 @@ class WorkspacePolicy */ public function view(User $user, Workspace $workspace) { - return $user->workspaces()->find($workspace->id)!==null; + return $workspace->users()->find($user->id)!==null; } /** diff --git a/resources/js/components/Navbar.vue b/resources/js/components/Navbar.vue index a8dadb7..40a2c6c 100644 --- a/resources/js/components/Navbar.vue +++ b/resources/js/components/Navbar.vue @@ -10,34 +10,37 @@ > {{ appName }} - + - +