diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 156209320..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - root: true, - parser: 'vue-eslint-parser', - parserOptions: { - parser: '@babel/eslint-parser', - ecmaVersion: 2018, - sourceType: 'module' - }, - extends: [ - 'plugin:vue/recommended', - 'standard' - ], - rules: { - 'vue/max-attributes-per-line': 'off' - } -} diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index 6d24a2bd4..4415debb7 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/laravel.yml @@ -128,6 +128,27 @@ jobs: path: storage/logs/laravel.log retention-days: 3 + build-nuxt-app: + runs-on: ubuntu-latest + name: Build the Nuxt app + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Get into client folder + run: cd client + + - uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Prepare the environment + run: cp .env.example .env + - name: Install npm dependencies run: npm install --no-audit --no-progress --silent diff --git a/README.md b/README.md index ec84b0702..087db453d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ It takes 1 minute to try out the builder for free. You'll have high availability ### Docker installation 🐳 -There's a `Dockerfile` for building a self-contained docker image including databases, webservers etc. +> ⚠️ **Warning**: the Docker setup is currently not working as we're migrating the front-end to Nuxt. [Track progress here](https://github.com/JhumanJ/OpnForm/issues/283). This can be built and run locally but is also hosted publicly on docker hub at `jhumanj/opnform` and is generally best run directly from there. @@ -154,8 +154,11 @@ First, let's work with the codebase and its dependencies. # Get the code! git clone git@github.com:JhumanJ/OpnForm.git && cd OpnForm -# Install PHP and JS dependencies -composer install && npm install +# Install PHP dependencies +composer install + + # Install JS dependencies +cd client && npm install # Compile assets (see the scripts section in package.json) npm run dev # or build @@ -186,7 +189,8 @@ Now, create an S3 bucket (or equivalent). Create an IAM user with access to this OpnForm is a standard web application built with: - [Laravel](https://laravel.com/) PHP framework -- [Vue.js](https://vuejs.org/) front-end framework +- [NuxtJs](https://nuxt.com/) Front-end SSR framework +- [Vue.js 3](https://vuejs.org/) Front-end framework - [TailwindCSS](https://tailwindcss.com/) ## Contribute diff --git a/amplify.yml b/amplify.yml new file mode 100644 index 000000000..5b6affdf1 --- /dev/null +++ b/amplify.yml @@ -0,0 +1,17 @@ +version: 1 +frontend: + phases: + preBuild: + commands: + - cd client + - npm ci + build: + commands: + - npm run build + artifacts: + baseDirectory: client/.amplify-hosting + files: + - '**/*' + cache: + paths: + - client/node_modules/**/* diff --git a/app/Console/Commands/Tax/GenerateTaxExport.php b/app/Console/Commands/Tax/GenerateTaxExport.php new file mode 100644 index 000000000..555b66d69 --- /dev/null +++ b/app/Console/Commands/Tax/GenerateTaxExport.php @@ -0,0 +1,252 @@ + 20, + "BE" => 21, + "BG" => 20, + "HR" => 25, + "CY" => 19, + "CZ" => 21, + "DK" => 25, + "EE" => 20, + "FI" => 24, + "FR" => 20, + "DE" => 19, + "GR" => 24, + "HU" => 27, + "IE" => 23, + "IT" => 22, + "LV" => 21, + "LT" => 21, + "LU" => 17, + "MT" => 18, + "NL" => 21, + "PL" => 23, + "PT" => 23, + "RO" => 19, + "SK" => 20, + "SI" => 22, + "ES" => 21, + "SE" => 25 + ]; + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + // iterate through all Stripe invoices + $startDate = $this->option('start-date'); + $endDate = $this->option('end-date'); + + // Validate the date format + if ($startDate && !Carbon::createFromFormat('Y-m-d', $startDate)) { + $this->error('Invalid start date format. Use YYYY-MM-DD.'); + return Command::FAILURE; + } + + if ($endDate && !Carbon::createFromFormat('Y-m-d', $endDate)) { + $this->error('Invalid end date format. Use YYYY-MM-DD.'); + return Command::FAILURE; + } else if (!$endDate && $this->option('full-month')) { + $endDate = Carbon::parse($startDate)->endOfMonth()->endOfDay()->format('Y-m-d'); + } + + $this->info('Start date: ' . $startDate); + $this->info('End date: ' . $endDate); + + $processedInvoices = []; + + // Create a progress bar + $queryOptions = [ + 'limit' => 100, + 'expand' => ['data.customer', 'data.customer.address', 'data.customer.tax_ids', 'data.payment_intent', + 'data.payment_intent.payment_method', 'data.charge.balance_transaction'], + 'status' => 'paid', + ]; + if ($startDate) { + $queryOptions['created']['gte'] = Carbon::parse($startDate)->startOfDay()->timestamp; + } + if ($endDate) { + $queryOptions['created']['lte'] = Carbon::parse($endDate)->endOfDay()->timestamp; + } + + $invoices = Cashier::stripe()->invoices->all($queryOptions); + $bar = $this->output->createProgressBar(); + $bar->start(); + $paymentNotSuccessfulCount = 0; + $totalInvoice = 0; + + do { + foreach ($invoices as $invoice) { + // Ignore if payment was refunded + if (($invoice->payment_intent->status ?? null) !== 'succeeded') { + $paymentNotSuccessfulCount++; + continue; + } + + $processedInvoices[] = $this->formatInvoice($invoice); + $totalInvoice++; + + // Advance the progress bar + $bar->advance(); + } + + $queryOptions['starting_after'] = end($invoices->data)->id; + + sleep(5); + $invoices = $invoices->all($queryOptions); + } while ($invoices->has_more); + + $bar->finish(); + $this->line(''); + + $aggregatedReport = $this->aggregateReport($processedInvoices); + + $filePath = 'tax-export-per-invoice_' . $startDate . '_' . $endDate . '.xlsx'; + $this->exportAsXlsx($processedInvoices, $filePath); + + $aggregatedReportFilePath = 'tax-export-aggregated_' . $startDate . '_' . $endDate . '.xlsx'; + $this->exportAsXlsx($aggregatedReport, $aggregatedReportFilePath); + + // Display the results + $this->info('Total invoices: ' . $totalInvoice . ' (with ' . $paymentNotSuccessfulCount . ' payment not successful or trial free invoice)'); + + return Command::SUCCESS; + } + + private function aggregateReport($invoices): array + { + // Sum invoices per country + $aggregatedReport = []; + foreach ($invoices as $invoice) { + $country = $invoice['cust_country']; + $customerType = is_null($invoice['cust_vat_id']) && $this->isEuropeanCountry($country) ? 'individual' : 'business'; + if (!isset($aggregatedReport[$country])) { + $defaultVal = [ + 'count' => 0, + 'total_usd' => 0, + 'tax_total_usd' => 0, + 'total_after_tax_usd' => 0, + 'total_eur' => 0, + 'tax_total_eur' => 0, + 'total_after_tax_eur' => 0, + ]; + $aggregatedReport[$country] = [ + 'individual' => $defaultVal, + 'business' => $defaultVal + ]; + } + $aggregatedReport[$country][$customerType]['count']++; + $aggregatedReport[$country][$customerType]['total_usd'] = ($aggregatedReport[$country][$customerType]['total_usd'] ?? 0) + $invoice['total_usd']; + $aggregatedReport[$country][$customerType]['tax_total_usd'] = ($aggregatedReport[$country][$customerType]['tax_total_usd'] ?? 0) + $invoice['tax_total_usd']; + $aggregatedReport[$country][$customerType]['total_after_tax_usd'] = ($aggregatedReport[$country][$customerType]['total_after_tax_usd'] ?? 0) + $invoice['total_after_tax_usd']; + $aggregatedReport[$country][$customerType]['total_eur'] = ($aggregatedReport[$country][$customerType]['total_eur'] ?? 0) + $invoice['total_eur']; + $aggregatedReport[$country][$customerType]['tax_total_eur'] = ($aggregatedReport[$country][$customerType]['tax_total_eur'] ?? 0) + $invoice['tax_total_eur']; + $aggregatedReport[$country][$customerType]['total_after_tax_eur'] = ($aggregatedReport[$country][$customerType]['total_after_tax_eur'] ?? 0) + $invoice['total_after_tax_eur']; + } + + $finalReport = []; + foreach ($aggregatedReport as $country => $data) { + foreach ($data as $customerType => $aggData) { + $finalReport[] = [ + 'country' => $country, + 'customer_type' => $customerType, + ...$aggData + ]; + } + } + return $finalReport; + } + + private function formatInvoice(Invoice $invoice): array + { + $country = $invoice->customer->address->country ?? $invoice->payment_intent->payment_method->card->country ?? null; + + $vatId = $invoice->customer->tax_ids->data[0]->value ?? null; + $taxRate = $this->computeTaxRate($country, $vatId); + + $taxAmountCollectedUsd = $taxRate > 0 ? $invoice->total * $taxRate / ($taxRate + 100) : 0; + $totalEur = $invoice->charge->balance_transaction->amount; + $taxAmountCollectedEur = $taxRate > 0 ? $totalEur * $taxRate / ($taxRate + 100) : 0; + + return [ + 'invoice_id' => $invoice->id, + 'created_at' => Carbon::createFromTimestamp($invoice->created)->format('Y-m-d H:i:s'), + 'cust_id' => $invoice->customer->id, + 'cust_vat_id' => $vatId, + 'cust_country' => $country, + 'tax_rate' => $taxRate, + 'total_usd' => $invoice->total / 100, + 'tax_total_usd' => $taxAmountCollectedUsd / 100, + 'total_after_tax_usd' => ($invoice->total - $taxAmountCollectedUsd) / 100, + 'total_eur' => $totalEur / 100, + 'tax_total_eur' => $taxAmountCollectedEur / 100, + 'total_after_tax_eur' => ($totalEur - $taxAmountCollectedEur) / 100, + ]; + } + + private function computeTaxRate($countryCode, $vatId) + { + // Since we're a French company, for France, always apply 20% VAT + if ($countryCode == 'FR' || + is_null($countryCode) || + empty($countryCode)) { + return self::EU_TAX_RATES['FR']; + } + + if ($taxRate = (self::EU_TAX_RATES[$countryCode] ?? null)) { + // If VAT ID is provided, then TAX is 0% + if (!$vatId) return $taxRate; + } + + return 0; + } + + private function isEuropeanCountry($countryCode) + { + return isset(self::EU_TAX_RATES[$countryCode]); + } + + private function exportAsXlsx($data, $filename) + { + if (count($data) == 0) { + $this->info('Empty data. No file generated.'); + return; + } + + (new ArrayExport($data))->store($filename, 'local', \Maatwebsite\Excel\Excel::XLSX); + $this->line('File generated: ' . storage_path('app/' . $filename)); + } + + +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 830207d67..966ea02b7 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -48,7 +48,7 @@ protected function unauthenticated($request, AuthenticationException $exception) { return $request->expectsJson() ? response()->json(['message' => $exception->getMessage()], 401) - : redirect()->guest(url('/login')); + : redirect(front_url('login')); } public function report(Throwable $exception) diff --git a/app/Exports/Tax/ArrayExport.php b/app/Exports/Tax/ArrayExport.php new file mode 100644 index 000000000..733f5d8b5 --- /dev/null +++ b/app/Exports/Tax/ArrayExport.php @@ -0,0 +1,27 @@ +data; + } + + public function headings(): array + { + return array_keys($this->data[0]); + } +} + diff --git a/app/Http/Controllers/Auth/AppSumoAuthController.php b/app/Http/Controllers/Auth/AppSumoAuthController.php index 505d2d966..a5cd5aaaf 100644 --- a/app/Http/Controllers/Auth/AppSumoAuthController.php +++ b/app/Http/Controllers/Auth/AppSumoAuthController.php @@ -28,10 +28,10 @@ public function handleCallback(Request $request) // 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(front_url('/register?appsumo_license='.encrypt($license->id))); } - return redirect(url('/register?appsumo_error=1')); + return redirect(front_url('/register?appsumo_error=1')); } private function retrieveAccessToken(string $requestCode): string @@ -82,11 +82,11 @@ private function attachLicense(License $license) { if (is_null($license->user_id)) { $license->user_id = Auth::id(); $license->save(); - return redirect(url('/home?appsumo_connect=1')); + return redirect(front_url('/home?appsumo_connect=1')); } // Licensed already attached - return redirect(url('/home?appsumo_error=1')); + return redirect(front_url('/home?appsumo_error=1')); } /** diff --git a/app/Http/Controllers/CaddyController.php b/app/Http/Controllers/CaddyController.php index 245a56b47..b7bd35f9b 100644 --- a/app/Http/Controllers/CaddyController.php +++ b/app/Http/Controllers/CaddyController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Http\Requests\Workspace\CustomDomainRequest; use App\Models\Workspace; use Illuminate\Http\Request; @@ -14,7 +15,7 @@ public function ask(Request $request) ]); // make sure domain is valid $domain = $request->input('domain'); - if (!preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/', $domain)) { + if (!preg_match(CustomDomainRequest::CUSTOM_DOMAINS_REGEX, $domain)) { return $this->error([ 'success' => false, 'message' => 'Invalid domain', diff --git a/app/Http/Controllers/Content/FileUploadController.php b/app/Http/Controllers/Content/FileUploadController.php index 62e6423a7..3481d8c64 100644 --- a/app/Http/Controllers/Content/FileUploadController.php +++ b/app/Http/Controllers/Content/FileUploadController.php @@ -17,6 +17,7 @@ class FileUploadController extends Controller */ public function upload(Request $request) { + $request->validate(['file' => 'required|file']); $uuid = (string) Str::uuid(); $path = $request->file('file')->storeAs(PublicFormController::TMP_FILE_UPLOAD_PATH, $uuid); diff --git a/app/Http/Controllers/Forms/FormSubmissionController.php b/app/Http/Controllers/Forms/FormSubmissionController.php index 75d1a90d7..dca04b18e 100644 --- a/app/Http/Controllers/Forms/FormSubmissionController.php +++ b/app/Http/Controllers/Forms/FormSubmissionController.php @@ -15,7 +15,8 @@ class FormSubmissionController extends Controller { public function __construct() { - $this->middleware('auth'); + $this->middleware('auth', ['except' => ['submissionFile']]); + $this->middleware('signed', ['only' => ['submissionFile']]); } public function submissions(string $id) @@ -51,9 +52,6 @@ public function export(string $id) public function submissionFile($id, $fileName) { - $form = Form::findOrFail((int) $id); - $this->authorize('view', $form); - $fileName = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $id).'/' .urldecode($fileName); @@ -63,8 +61,12 @@ public function submissionFile($id, $fileName) ], 404); } + if (config('filesystems.default') !== 's3') { + return response()->file(Storage::path($fileName)); + } + return redirect( - Storage::temporaryUrl($fileName, now()->addMinute()) + Storage::temporaryUrl($fileName, now()->addMinute()) ); } } diff --git a/app/Http/Controllers/SitemapController.php b/app/Http/Controllers/SitemapController.php index 1c624e0f1..712073efe 100644 --- a/app/Http/Controllers/SitemapController.php +++ b/app/Http/Controllers/SitemapController.php @@ -9,62 +9,24 @@ class SitemapController extends Controller { - /** - * Contains route name and the associated priority - * - * @var array - */ - protected $urls = [ - ['/', 1], - ['/pricing', 0.9], - ['/privacy-policy', 0.5], - ['/terms-conditions', 0.5], - ['/login', 0.4], - ['/register', 0.4], - ['/password/reset', 0.3], - ['/form-templates', 0.9], - ]; - public function getSitemap(Request $request) + public function index(Request $request) { - $sitemap = Sitemap::create(); - foreach ($this->urls as $url) { - $sitemap->add($this->createUrl($url[0], $url[1])); - } - $this->addTemplatesUrls($sitemap); - $this->addTemplatesTypesUrls($sitemap); - $this->addTemplatesIndustriesUrls($sitemap); - - return $sitemap->toResponse($request); + return [ + ...$this->getTemplatesUrls() + ]; } - private function createUrl($url, $priority, $frequency = 'daily') + private function getTemplatesUrls() { - return Url::create($url)->setPriority($priority)->setChangeFrequency($frequency); - } - - private function addTemplatesUrls(Sitemap $sitemap) - { - Template::where('publicly_listed', true)->chunk(100, function ($templates) use ($sitemap) { + $urls = []; + Template::where('publicly_listed', true)->chunk(100, function ($templates) use (&$urls) { foreach ($templates as $template) { - $sitemap->add($this->createUrl('/form-templates/' . $template->slug, 0.8)); + $urls[] = [ + 'loc' => '/templates/' . $template->slug + ]; } }); - } - - private function addTemplatesTypesUrls(Sitemap $sitemap) - { - $types = json_decode(file_get_contents(resource_path('data/forms/templates/types.json')), true); - foreach ($types as $type) { - $sitemap->add($this->createUrl('/form-templates/types/' . $type['slug'], 0.7)); - } - } - - private function addTemplatesIndustriesUrls(Sitemap $sitemap) - { - $industries = json_decode(file_get_contents(resource_path('data/forms/templates/industries.json')), true); - foreach ($industries as $industry) { - $sitemap->add($this->createUrl('/form-templates/industries/' . $industry['slug'], 0.7)); - } + return $urls; } } diff --git a/app/Http/Controllers/SpaController.php b/app/Http/Controllers/SpaController.php deleted file mode 100644 index 50456f13c..000000000 --- a/app/Http/Controllers/SpaController.php +++ /dev/null @@ -1,18 +0,0 @@ - (new SeoMetaResolver($request))->getMetas(), - ]); - } -} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 4205b3668..8cc0213aa 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -45,8 +45,8 @@ public function checkout($pricing, $plan, $trial = null) $checkout = $checkoutBuilder ->collectTaxIds() ->checkout([ - 'success_url' => url('/subscriptions/success'), - 'cancel_url' => url('/subscriptions/error'), + 'success_url' => front_url('/subscriptions/success'), + 'cancel_url' => front_url('/subscriptions/error'), 'billing_address_collection' => 'required', 'customer_update' => [ 'address' => 'auto', diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 4d09ef9f1..1ae7aebc1 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -2,8 +2,8 @@ namespace App\Http; +use App\Http\Middleware\AuthenticateJWT; use App\Http\Middleware\CustomDomainRestriction; -use App\Http\Middleware\EmbeddableForms; use App\Http\Middleware\IsAdmin; use App\Http\Middleware\IsNotSubscribed; use App\Http\Middleware\IsSubscribed; @@ -19,14 +19,15 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ - // \App\Http\Middleware\TrustHosts::class, +// \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustProxies::class, - \Fruitcake\Cors\HandleCors::class, + \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, \App\Http\Middleware\SetLocale::class, + AuthenticateJWT::class, CustomDomainRestriction::class, ]; @@ -44,12 +45,10 @@ class Kernel extends HttpKernel \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, - EmbeddableForms::class ], 'spa' => [ \Illuminate\Routing\Middleware\SubstituteBindings::class, - EmbeddableForms::class ], 'api' => [ diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index 513b77e8a..b481f9f08 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -15,7 +15,7 @@ class Authenticate extends Middleware protected function redirectTo($request) { if (! $request->expectsJson()) { - return redirect('/login'); + return redirect(front_url('login')); } } } diff --git a/app/Http/Middleware/AuthenticateJWT.php b/app/Http/Middleware/AuthenticateJWT.php new file mode 100644 index 000000000..8cbc86b75 --- /dev/null +++ b/app/Http/Middleware/AuthenticateJWT.php @@ -0,0 +1,54 @@ +getPayload(); + } catch (JWTException $e) { + return $next($request); + } + + // Validate IP and User Agent + if ($payload) { + if ($frontApiSecret = $request->header(self::API_SERVER_SECRET_HEADER_NAME)) { + // If it's a trusted SSR request, skip the rest + if ($frontApiSecret === config('app.front_api_secret')) { + return $next($request); + } + } + + $error = null; + if (!\Hash::check($request->ip(), $payload->get('ip'))) { + $error = 'Origin IP is invalid'; + } + + if (!\Hash::check($request->userAgent(), $payload->get('ua'))) { + $error = 'Origin User Agent is invalid'; + } + + if ($error) { + auth()->invalidate(); + return response()->json([ + 'message' => $error + ], 403); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CustomDomainRestriction.php b/app/Http/Middleware/CustomDomainRestriction.php index 0b970b765..31f4b0e8c 100644 --- a/app/Http/Middleware/CustomDomainRestriction.php +++ b/app/Http/Middleware/CustomDomainRestriction.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Http\Requests\Workspace\CustomDomainRequest; use App\Models\Forms\Form; use App\Models\Workspace; use Closure; @@ -10,7 +11,7 @@ class CustomDomainRestriction { - const CUSTOM_DOMAIN_HEADER = "User-Custom-Domain"; + const CUSTOM_DOMAIN_HEADER = "x-custom-domain"; /** * Handle an incoming request. @@ -22,11 +23,12 @@ public function handle(Request $request, Closure $next) } $customDomain = $request->header(self::CUSTOM_DOMAIN_HEADER); - if (!preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/', $customDomain)) { + if (!preg_match(CustomDomainRequest::CUSTOM_DOMAINS_REGEX, $customDomain)) { return response()->json([ 'success' => false, 'message' => 'Invalid domain', - ], 400); + 'error' => 'invalid_domain', + ], 420); } // Check if domain is different from current domain @@ -40,7 +42,8 @@ public function handle(Request $request, Closure $next) return response()->json([ 'success' => false, 'message' => 'Unknown domain', - ], 400); + 'error' => 'invalid_domain', + ], 420); } Workspace::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspace) { diff --git a/app/Http/Middleware/EmbeddableForms.php b/app/Http/Middleware/EmbeddableForms.php deleted file mode 100644 index e571fc4de..000000000 --- a/app/Http/Middleware/EmbeddableForms.php +++ /dev/null @@ -1,36 +0,0 @@ -expectsJson() || $request->wantsJson()) { - return $next($request); - } - - $response = $next($request); - - if (!str_starts_with($request->url(), url('/forms/'))) { - if ($response instanceof Response) { - $response->header('X-Frame-Options', 'SAMEORIGIN'); - } elseif ($response instanceof \Symfony\Component\HttpFoundation\Response) { - $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); - } - } - - return $response; - } -} diff --git a/app/Http/Requests/AnswerFormRequest.php b/app/Http/Requests/AnswerFormRequest.php index 2a3039f77..73e6df6e8 100644 --- a/app/Http/Requests/AnswerFormRequest.php +++ b/app/Http/Requests/AnswerFormRequest.php @@ -60,7 +60,7 @@ public function rules() if(isset($data[$field['id']]) && is_array($data[$field['id']])){ $data[$field['id']] = array_map(function ($val) use ($field) { $tmpop = collect($field[$field['type']]['options'])->first(function ($op) use ($val) { - return ($op['id'] === $val); + return ($op['id'] ?? $op['value'] === $val); }); return isset($tmpop['name']) ? $tmpop['name'] : ""; }, $data[$field['id']]); diff --git a/app/Http/Requests/UserFormRequest.php b/app/Http/Requests/UserFormRequest.php index 81b0da792..bb38ec166 100644 --- a/app/Http/Requests/UserFormRequest.php +++ b/app/Http/Requests/UserFormRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests; +use App\Http\Requests\Workspace\CustomDomainRequest; use App\Models\Forms\Form; use App\Rules\OneEmailPerLine; use Illuminate\Validation\Rule; @@ -126,7 +127,7 @@ public function rules() // Custom SEO 'seo_meta' => 'nullable|array', - 'custom_domain' => 'sometimes|nullable|regex:/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/' + 'custom_domain' => 'sometimes|nullable|regex:'. CustomDomainRequest::CUSTOM_DOMAINS_REGEX, ]; } diff --git a/app/Http/Requests/Workspace/CustomDomainRequest.php b/app/Http/Requests/Workspace/CustomDomainRequest.php index 82a3e6612..87c03839e 100644 --- a/app/Http/Requests/Workspace/CustomDomainRequest.php +++ b/app/Http/Requests/Workspace/CustomDomainRequest.php @@ -8,6 +8,7 @@ class CustomDomainRequest extends FormRequest { + const CUSTOM_DOMAINS_REGEX = '/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,20}$/'; public Workspace $workspace; public array $customDomains = []; @@ -32,7 +33,7 @@ function($attribute, $value, $fail) { $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)) { + if (!preg_match(self::CUSTOM_DOMAINS_REGEX, $domain)) { $errors[] = 'Invalid domain: ' . $domain; } }); diff --git a/app/Http/Resources/FormResource.php b/app/Http/Resources/FormResource.php index 34c04b10d..f75942a8a 100644 --- a/app/Http/Resources/FormResource.php +++ b/app/Http/Resources/FormResource.php @@ -50,12 +50,10 @@ public function toArray($request) 'notification_settings' => $this->notification_settings, 'removed_properties' => $this->removed_properties, 'last_edited_human' => $this->updated_at?->diffForHumans(), - 'seo_meta' => $this->seo_meta + 'seo_meta' => $this->seo_meta, ] : []; - $baseData = $this->getFilteredFormData(parent::toArray($request), $this->userIsFormOwner()); - - return array_merge($baseData, $ownerData, [ + return array_merge(parent::toArray($request), $ownerData, [ 'is_pro' => $this->workspaceIsPro(), 'workspace_id' => $this->workspace_id, 'workspace' => new WorkspaceResource($this->getWorkspace()), @@ -63,32 +61,11 @@ public function toArray($request) 'is_password_protected' => false, 'has_password' => $this->has_password, 'max_number_of_submissions_reached' => $this->max_number_of_submissions_reached, - 'form_pending_submission_key' => $this->form_pending_submission_key + 'form_pending_submission_key' => $this->form_pending_submission_key, + 'max_file_size' => $this->max_file_size / 1000000, ]); } - /** - * Filter form data to hide properties from users. - * - For relation fields, hides the relation information - */ - private function getFilteredFormData(array $data, bool $userIsFormOwner) - { - if ($userIsFormOwner) return $data; - - $properties = collect($data['properties'])->map(function($property){ - // Remove database details from relation - if ($property['type'] === 'relation') { - if (isset($property['relation'])) { - unset($property['relation']); - } - } - return $property; - }); - - $data['properties'] = $properties->toArray(); - return $data; - } - public function setCleanings(array $cleanings) { $this->cleanings = $cleanings; diff --git a/app/Http/Resources/FormSubmissionResource.php b/app/Http/Resources/FormSubmissionResource.php index 6018e9e7e..ad2b93268 100644 --- a/app/Http/Resources/FormSubmissionResource.php +++ b/app/Http/Resources/FormSubmissionResource.php @@ -50,7 +50,11 @@ private function generateFileLinks() return $file !== null && $file; })->map(function ($file) { return [ - 'file_url' => route('open.forms.submissions.file', [$this->form_id, $file]), + 'file_url' => \URL::signedRoute( + 'open.forms.submissions.file', + [$this->form_id, $file], + now()->addMinutes(10) + ), 'file_name' => $file, ]; }); diff --git a/app/Jobs/Form/StoreFormSubmissionJob.php b/app/Jobs/Form/StoreFormSubmissionJob.php index 8361f0419..198bb2c59 100644 --- a/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/app/Jobs/Form/StoreFormSubmissionJob.php @@ -164,14 +164,14 @@ private function storeFile(?string $value) return null; } - if(filter_var($value, FILTER_VALIDATE_URL) !== FALSE && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3 + if(filter_var($value, FILTER_VALIDATE_URL) !== false && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3 $fileName = basename($value); $path = FormController::ASSETS_UPLOAD_PATH . '/' . $fileName; $newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id); Storage::move($path, $newPath.'/'.$fileName); return $fileName; } - + if($this->isSkipForUpload($value)) { return $value; } diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index 4ad2424e4..53c403953 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -133,6 +133,7 @@ class Form extends Model implements CachableAttributes protected $cachableAttributes = [ 'is_pro', 'views_count', + 'max_file_size' ]; /** @@ -156,12 +157,12 @@ public function getShareUrlAttribute() if ($this->custom_domain) { return 'https://' . $this->custom_domain . '/forms/' . $this->slug; } - return url('/forms/' . $this->slug); + return front_url('/forms/' . $this->slug); } public function getEditUrlAttribute() { - return url('/forms/' . $this->slug . '/show'); + return front_url('/forms/' . $this->slug . '/show'); } public function getSubmissionsCountAttribute() @@ -234,6 +235,13 @@ public function getHasPasswordAttribute() return !empty($this->password); } + public function getMaxFileSizeAttribute() + { + return $this->remember('max_file_size', 15 * 60, function(): int { + return $this->workspace->max_file_size; + }); + } + protected function removedProperties(): Attribute { return Attribute::make( diff --git a/app/Models/Template.php b/app/Models/Template.php index 59c42e3e1..45fa38fcb 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -48,7 +48,7 @@ class Template extends Model public function getShareUrlAttribute() { - return url('/form-templates/'.$this->slug); + return front_url('/form-templates/'.$this->slug); } public function setDescriptionAttribute($value) diff --git a/app/Models/User.php b/app/Models/User.php index 0ec96987b..0d826f554 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -194,7 +194,10 @@ public function getJWTIdentifier() */ public function getJWTCustomClaims() { - return []; + return [ + 'ip' => \Hash::make(request()->ip()), + 'ua' => \Hash::make(request()->userAgent()), + ]; } public function getIsRiskyAttribute() diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 6543dc968..367602adf 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -14,9 +14,7 @@ class Workspace extends Model implements CachableAttributes 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', diff --git a/app/Notifications/ResetPassword.php b/app/Notifications/ResetPassword.php index 9edd12f3a..526053138 100644 --- a/app/Notifications/ResetPassword.php +++ b/app/Notifications/ResetPassword.php @@ -17,7 +17,7 @@ public function toMail($notifiable) { return (new MailMessage) ->line('You are receiving this email because we received a password reset request for your account.') - ->action('Reset Password', url('password/reset/'.$this->token).'?email='.urlencode($notifiable->email)) + ->action('Reset Password', front_url('password/reset/'.$this->token).'?email='.urlencode($notifiable->email)) ->line('If you did not request a password reset, no further action is required.'); } } diff --git a/app/Notifications/Subscription/FailedPaymentNotification.php b/app/Notifications/Subscription/FailedPaymentNotification.php index f50a93faa..dcb3970ee 100644 --- a/app/Notifications/Subscription/FailedPaymentNotification.php +++ b/app/Notifications/Subscription/FailedPaymentNotification.php @@ -36,6 +36,6 @@ public function toMail($notifiable) ->line(__('Please go to OpenForm, click on your name on the top right corner, and click on "Billing". You will then be able to update your card details. To avoid any service disruption, you can reply to this email whenever you updated your card details, and we\'ll manually attempt to charge your card.')) - ->action(__('Go to OpenForm'), url('/')); + ->action(__('Go to OpenForm'), front_url('/')); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index faee4fa37..989bcffe3 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -19,15 +19,6 @@ class RouteServiceProvider extends ServiceProvider */ public const HOME = '/home'; - /** - * The controller namespace for the application. - * - * When present, controller route declarations will automatically be prefixed with this namespace. - * - * @var string|null - */ - // protected $namespace = 'App\\Http\\Controllers'; - /** * Define your route model bindings, pattern filters, etc. * @@ -39,19 +30,9 @@ public function boot() $this->registerGlobalRouteParamConstraints(); $this->routes(function () { - - Route::prefix('api') - ->middleware('api') + Route::middleware('api') ->namespace($this->namespace) ->group(base_path('routes/api.php')); - - Route::middleware('web') - ->namespace($this->namespace) - ->group(base_path('routes/web.php')); - - Route::middleware('spa') - ->namespace($this->namespace) - ->group(base_path('routes/spa.php')); }); } diff --git a/app/Service/Forms/Webhooks/DiscordHandler.php b/app/Service/Forms/Webhooks/DiscordHandler.php index 559de29cf..f7529f5d8 100644 --- a/app/Service/Forms/Webhooks/DiscordHandler.php +++ b/app/Service/Forms/Webhooks/DiscordHandler.php @@ -27,7 +27,7 @@ protected function getWebhookData(): array $externalLinks[] = '[**🔗 Open Form**](' . $this->form->share_url . ')'; } if(Arr::get($settings, 'link_edit_form', true)){ - $editFormURL = url('forms/' . $this->form->slug . '/show'); + $editFormURL = front_url('forms/' . $this->form->slug . '/show'); $externalLinks[] = '[**✍️ Edit Form**](' . $editFormURL . ')'; } if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) { diff --git a/app/Service/Forms/Webhooks/SlackHandler.php b/app/Service/Forms/Webhooks/SlackHandler.php index 5b2faf607..f237efaba 100644 --- a/app/Service/Forms/Webhooks/SlackHandler.php +++ b/app/Service/Forms/Webhooks/SlackHandler.php @@ -27,7 +27,7 @@ protected function getWebhookData(): array $externalLinks[] = '*<' . $this->form->share_url . '|🔗 Open Form>*'; } if(Arr::get($settings, 'link_edit_form', true)){ - $editFormURL = url('forms/' . $this->form->slug . '/show'); + $editFormURL = front_url('forms/' . $this->form->slug . '/show'); $externalLinks[] = '*<' . $editFormURL . '|✍️ Edit Form>*'; } if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) { diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 000000000..e287b476e --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,11 @@ +
- - - - - - - - - - - - + + + + + + + +
diff --git a/resources/js/components/common/EditableDiv.vue b/client/components/global/EditableDiv.vue similarity index 100% rename from resources/js/components/common/EditableDiv.vue rename to client/components/global/EditableDiv.vue diff --git a/resources/js/components/common/Loader.vue b/client/components/global/Loader.vue similarity index 100% rename from resources/js/components/common/Loader.vue rename to client/components/global/Loader.vue diff --git a/client/components/global/Modal.vue b/client/components/global/Modal.vue new file mode 100644 index 000000000..d75780a51 --- /dev/null +++ b/client/components/global/Modal.vue @@ -0,0 +1,175 @@ + + + diff --git a/resources/js/components/Navbar.vue b/client/components/global/Navbar.vue similarity index 56% rename from resources/js/components/Navbar.vue rename to client/components/global/Navbar.vue index 526a35e07..c72714d2c 100644 --- a/resources/js/components/Navbar.vue +++ b/client/components/global/Navbar.vue @@ -3,44 +3,46 @@
- - notion tools logo - - - + + + + +
-