Initial commit

This commit is contained in:
Julien Nahum 2022-09-20 21:59:52 +02:00
commit f8e6cd4dd6
479 changed files with 77078 additions and 0 deletions

8
.babelrc Normal file
View File

@ -0,0 +1,8 @@
{
"presets": [
"@babel/preset-env"
],
"plugins": [
"@babel/plugin-syntax-dynamic-import"
]
}

15
.editorconfig Normal file
View File

@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{vue,js,json,html,scss,blade.php,yml}]
indent_size = 2

57
.env.example Normal file
View File

@ -0,0 +1,57 @@
APP_NAME="Laravel Vue Tailwind Spa"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_LOG_LEVEL=debug
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=postgres
DB_USERNAME=postgres
DB_PASSWORD=postgres
BROADCAST_DRIVER=log
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
JWT_TTL=1440
JWT_SECRET=
STRIPE_KEY=
STRIPE_SECRET=
MUX_WORKSPACE_ID=
MUX_API_TOKEN=

16
.eslintrc.js vendored Normal file
View File

@ -0,0 +1,16 @@
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'
}
}

9
.gitattributes vendored Normal file
View File

@ -0,0 +1,9 @@
* text=auto
*.css linguist-vendored
*.scss linguist-vendored
*.js linguist-vendored
CHANGELOG.md export-ignore
README.md export-ignore
.travis.yml export-ignore
.env.dusk.local export-ignore
.env.dusk.testing export-ignore

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
/node_modules
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.env.production
.env.backup
.env.testing
.env.dusk.local
.phpunit.result.cache
.idea/*
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
phpunit.dusk.xml
/public/dist
/public/build
/public/mix-manifest.json
/public/sitemap.xml
.DS_Store
public/.DS_Store
*.DS_Store
.vapor/
.env.production
.env.staging

13
.styleci.yml Normal file
View File

@ -0,0 +1,13 @@
php:
preset: laravel
disabled:
- no_unused_imports
finder:
not-name:
- index.php
- server.php
js:
finder:
not-name:
- webpack.mix.js
css: true

105
CHANGELOG.md Normal file
View File

@ -0,0 +1,105 @@
# Changelog
## 5.0.0 - 2020-10-31
- Upgrade to Laravel 8
- Update dependencies
- Fixed locale cookie fallback [#221](https://github.com/cretueusebiu/laravel-vue-spa/pull/221)
- Added scroll delay [#220](https://github.com/cretueusebiu/laravel-vue-spa/pull/220)
- Added redirect to intended url [#208](https://github.com/cretueusebiu/laravel-vue-spa/pull/208)
- Added middleware parameter [#124](https://github.com/cretueusebiu/laravel-vue-spa/pull/124)
## 4.7.0 - 2020-04-09
- Added `routes/spa.php` and `spa` middleware for serving the frontend [#249](https://github.com/cretueusebiu/laravel-vue-spa/pull/249)
- Added controllers for user and spa view to fix route caching [#248](https://github.com/cretueusebiu/laravel-vue-spa/pull/248)
## 4.6.0 - 2020-03-25
- Upgrade to Laravel 7
## 4.5.0 - 2019-09-29
- Upgrade to Laravel 6
## 4.4.0 - 2019-05-17
- Upgrade to Laravel 5.8
- Update npm dependencies
- Added email verification
- Removed yarn.lock in favor of package-lock.json
- Fixed `laravel-mix` (HMR, versioning, build cleanup), thanks to [TemaSM](https://github.com/TemaSM)
- Removed JS polyfills, if you want to support IE11 see [laravel-mix#436](https://github.com/JeffreyWay/laravel-mix/issues/436)
## 4.3.0 - 2018-10-21
- Fixed npm install
- Update fontawesome
## 4.2.0 - 2018-10-04
- Upgrade to Laravel 5.7
## 4.1.1 - 2018-05-10
- Fixed oauth popup
- Fixed session expired alert
- Update npm dependencies
## 4.1.0 - 2018-03-14
- Upgrade to Laravel 5.6
- Update npm dependencies
- Enabled Mix [esModule](https://github.com/JeffreyWay/laravel-mix/pull/1526#issuecomment-373044182) by default
- Lint with eslint-plugin-vue and eslint-config-standard
## 4.0.0 - 2018-01-23
- Brought back the `layout` property in pages that was removed in 3.0. By default it's set to `default` (`~/layouts/default.vue`).
## 3.0.1 - 2018-01-22
- Removed middleware from routes, since they don't work when you press the back button. Now you have to use the `middleware` property in pages.
## 3.0.0 - 2018-01-22
- Removed `layout` property from pages in favor of explicit layout components
- Improved middleware system, you can either define it on a route or directly on a component
- Reorganized components
- Change jwt token ttl to one day
- Fixed hot module replacement
## 2.2.1 - 2018-01-19
- Upgrade to Bootstrap 4
## 2.2.0 - 2018-01-16
- Added dynamic import for pages
- Added locale with dynamic import
- Import shakable fontawesome module
## 2.1.0 - 2018-01-10
- Simplifyd password reset
- Upgrade to Bootstrap 4 beta3
- Added oauth with popup window
## 2.0.0 - 2017-12-19
- Added support for Socialite
- Added dropdown to select the locale
- Added route middleware
- Added Font Awesome 5 icons
- Added user profile photo in navbar
- Added Chinese and Spanish translations
- Upgrade to Bootstrap 4 beta2
- Fixed router scroll behaviour
- Namespaced store modules
- Published project to Packagist
## 1.0.0 - 2017-09-02
- Upgrade to Laravel 5.5.
- Upgrade to Bootstrap 4 beta1.
- Rework routing and guards.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Cretu Eusebiu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# Laravel-Vue-Tailwind SPA
<a href="https://github.com/jhumanj/laravel-vue-tailwind-spa/actions"><img src="https://github.com/jhumanj/laravel-vue-tailwind-spa/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/jhumanj/laravel-vue-tailwind-spa"><img src="https://poser.pugx.org/jhumanj/laravel-vue-tailwind-spa/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/jhumanj/laravel-vue-tailwind-spa"><img src="https://poser.pugx.org/jhumanj/laravel-vue-tailwind-spa/v/stable.svg" alt="Latest Stable Version"></a>
> A Laravel-Vue-Tailwind SPA starter kit. Forked from [cretueusebiu/laravel-vue-spa](https://github.com/cretueusebiu/laravel-vue-spa).
<p align="center">
<img src="https://i.ibb.co/XCcPJXR/Capture-d-e-cran-2021-04-14-a-17-52-07.png">
</p>
## Features
- Laravel 8
- Vue + VueRouter + Vuex + VueI18n + ESlint
- Pages with dynamic import and custom layouts
- Login, register, email verification and password reset
- Authentication with JWT
- Socialite integration
- TailwindCSS v2
## Installation
- `composer create-project --prefer-dist JhumanJ/laravel-vue-tailwind-spa`
- Edit `.env` and set your database connection details
- (When installed via git clone or download, run `php artisan key:generate` and `php artisan jwt:secret`)
- `php artisan migrate`
- `npm install`
## Usage
#### Development
```bash
# Build and watch
npm run watch
```
#### Production
```bash
npm run production
```
## Socialite
This project comes with GitHub as an example for [Laravel Socialite](https://laravel.com/docs/5.8/socialite).
To enable the provider create a new GitHub application and use `https://example.com/api/oauth/github/callback` as the Authorization callback URL.
Edit `.env` and set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` with the keys form your GitHub application.
For other providers you may need to set the appropriate keys in `config/services.php` and redirect url in `OAuthController.php`.
## Email Verification
To enable email verification make sure that your `App\User` model implements the `Illuminate\Contracts\Auth\MustVerifyEmail` contract.
## Testing
```bash
# Run unit and feature tests
vendor/bin/phpunit
# Run Dusk browser tests
php artisan dusk
```
## Credits
- [cretueusebiu](https://github.com/cretueusebiu/) for creating the original [Laravel Vue SPA](https://github.com/cretueusebiu/laravel-vue-spa)
- [Tailwind Kit](https://www.tailwind-kit.com/) for all their Tailwind templates
## Changelog
Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently.

View File

@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use App\Models\Forms\FormStatistic;
use Illuminate\Console\Command;
use App\Models\Forms\FormView;
use App\Models\Forms\FormSubmission;
use Illuminate\Support\Facades\DB;
class CleanDatabase extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'forms:database-cleanup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Database Cleanup';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->cleanFormStatistics();
$this->line('Database Cleanup Success.');
}
/**
* Manage FormViews & FormSubmissions records
*/
private function cleanFormStatistics()
{
$this->line('Aggregating form views...');
$now = now();
$finalData = [];
// Form Views
FormView::select('form_id', DB::raw('DATE(created_at) as date'), DB::raw('count(*) as views'))
->where('created_at', '<', $now)
->orderBy('date')
->groupBy('form_id', 'date')
->get()->each(function ($row) use (&$finalData) {
$finalData[$row->form_id."-".$row->date] = [
'form_id' => $row->form_id,
'date' => $row->date,
'data' => [
'views' => $row->views,
'submissions' => 0
]
];
});
// Form Submissions
$this->line('Aggregating form submissions...');
FormSubmission::select('form_id', DB::raw('DATE(created_at) as date'), DB::raw('count(*) as submissions'))
->whereDate('created_at', '<=', now()->startOfDay())
->orderBy('date')
->groupBy('form_id', 'date')
->get()->each(function ($row) use (&$finalData) {
$key = $row->form_id."-".$row->date;
if (isset($finalData[$key])) {
$finalData[$key]['data']['submissions'] = $row->submissions;
} else {
$finalData[$key] = [
'form_id' => $row->form_id,
'date' => $row->date,
'data' => [
'views' => 0,
'submissions' => $row->submissions
]
];
}
});
if ($finalData) {
$this->line('Storing aggregated data...');
$created = 0;
$updated = 0;
// Insert into Form Statistic
foreach ($finalData as $row) {
$found = FormStatistic::where([['form_id', $row['form_id']], ['date', $row['date']]])->first();
if ($found !== null) { // If found update
$newData = $found->data;
$newData['views'] = $newData['views'] + $row['data']['views'];
$newData['submissions'] = $newData['submissions'] + $row['data']['submissions'];
$found->update(['data' => $newData]);
$updated++;
} else { // Otherwise create new
FormStatistic::create($row);
$created++;
}
}
$this->line($created.' form statistics records created.');
$this->line($updated.' form statistics records updated.');
// Delete Form Views those are migrated
$formViewRemovedCount = FormView::where('created_at', '<', $now)->delete();
$this->line($formViewRemovedCount.' form views records deleted.');
} else {
$this->line('No aggregate to store.');
}
}
}

41
app/Console/Kernel.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
//
];
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('forms:database-cleanup')->hourly();
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Events\Forms;
use App\Models\Forms\Form;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FormSubmitted
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $form;
public $data;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(Form $form, array $data)
{
$this->form = $form;
$this->data = $data;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Events\Models;
use App\Models\Forms\Form;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FormCreated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(public Form $form)
{}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Exception;
class EmailTakenException extends Exception
{
/**
* Render the exception as an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function render($request)
{
return response()->view('oauth.emailTaken', [], 400);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Exceptions;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Log;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that are not reported.
*
* @var array
*/
protected $dontReport = [
//
];
/**
* A list of the exception types that are not reported to Sentry.
*
* @var array
*/
protected $sentryDontReport = [
//
];
/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array
*/
protected $dontFlash = [
'password',
'password_confirmation',
];
/**
* Convert an authentication exception into a response.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Auth\AuthenticationException $exception
* @return \Illuminate\Http\Response
*/
protected function unauthenticated($request, AuthenticationException $exception)
{
return $request->expectsJson()
? response()->json(['message' => $exception->getMessage()], 401)
: redirect()->guest(url('/login'));
}
public function report(Throwable $exception)
{
if ($this->shouldReport($exception) ) {
if (app()->bound('sentry') && $this->sentryShouldReport($exception)) {
app('sentry')->captureException($exception);
Log::debug('Un-handled Exception: '.$exception->getMessage(), [
'exception' => $exception,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTrace(),
]);
}
}
parent::report($exception);
}
public function render($request, Throwable $e)
{
if ($this->shouldReport($e) && !in_array(\App::environment(),['testing'])) {
Log::channel('slack')->error($e);
}
return parent::render($request, $e);
}
private function sentryShouldReport(Throwable $e)
{
foreach ($this->sentryDontReport as $exceptionType) {
if ($e instanceof $exceptionType) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Illuminate\Validation\ValidationException;
class VerifyEmailException extends ValidationException
{
/**
* @param \App\User $user
* @return static
*/
public static function forUser($user)
{
return static::withMessages([
'email' => [__('You must :linkOpen verify :linkClose your email first.', [
'linkOpen' => '<a href="/email/resend?email='.urlencode($user->email).'">',
'linkClose' => '</a>',
])],
]);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Exceptions\Workspaces;
use App\Models\Workspace;
use Exception;
class WorkspaceAlreadyExisting extends Exception
{
public function __construct(public Workspace $workspace)
{
}
public function getErrorMessage()
{
$owner = $this->workspace->users()->first();
if (!$owner) {
return 'A user already connected that workspace to another NotionForms account. You or the current workspace
owner must have a NotionForms Enterprise subscription for you to add this Notion workspace. Please upgrade
with an Enterprise subscription, or contact us to get help.';
}
return '"'.$owner->name.'" already connected that workspace to his NotionForms account. In order to collaborate,
one of you must have a NotionForms Enterprise subscription. Please upgrade or contact us to get help.';
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions\Workspaces;
use Exception;
class WorkspaceLimit extends Exception
{
//
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
class FormSubmissionExport implements FromArray, WithHeadingRow
{
protected array $submissionData;
public function __construct(array $submissionData)
{
$headingRow = [];
$contentRow = [];
foreach ($submissionData as $i => $row) {
if($i==0){
$headingRow[] = array_keys($row);
}
$contentRow[] = array_values($row);
}
$this->submissionData = [
$headingRow,
$contentRow
];
}
public function array(): array
{
return $this->submissionData;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ImpersonationController extends Controller
{
public function __construct()
{
$this->middleware('admin');
}
public function impersonate($identifier) {
$user = null;
if (is_numeric($identifier)) {
$user = User::find($identifier);
} elseif (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
$user = User::whereEmail($identifier)->first();
} else {
// Find by form slug
$form = Form::whereSlug($identifier)->first();
if ($form) {
$user = $form->creator;
}
}
if (!$user) return $this->error([
'message'=> 'User not found.'
]);
// Be this user
$token = auth()->login($user);
return $this->success([
'token' => $token
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
class ForgotPasswordController extends Controller
{
use SendsPasswordResetEmails;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Get the response for a successful password reset link.
*
* @param \Illuminate\Http\Request $request
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*/
protected function sendResetLinkResponse(Request $request, $response)
{
return ['status' => trans($response)];
}
/**
* Get the response for a failed password reset link.
*
* @param \Illuminate\Http\Request $request
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*/
protected function sendResetLinkFailedResponse(Request $request, $response)
{
return response()->json(['email' => trans($response)], 400);
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Exceptions\VerifyEmailException;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
use AuthenticatesUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest')->except('logout');
}
/**
* Attempt to log the user into the application.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
protected function attemptLogin(Request $request)
{
$token = $this->guard()->attempt($this->credentials($request));
if (! $token) {
return false;
}
$user = $this->guard()->user();
if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) {
return false;
}
$this->guard()->setToken($token);
return true;
}
/**
* Get the needed authorization credentials from the request.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
protected function credentials(Request $request)
{
return [
$this->username() => strtolower($request->get($this->username())),
'password' => $request->password,
];
}
/**
* Send the response after the user was authenticated.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
protected function sendLoginResponse(Request $request)
{
$this->clearLoginAttempts($request);
$token = (string) $this->guard()->getToken();
$expiration = $this->guard()->getPayload()->get('exp');
return response()->json([
'token' => $token,
'token_type' => 'bearer',
'expires_in' => $expiration - time(),
]);
}
/**
* Get the failed login response instance.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*
* @throws \Illuminate\Validation\ValidationException
*/
protected function sendFailedLoginResponse(Request $request)
{
$user = $this->guard()->user();
if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) {
throw VerifyEmailException::forUser($user);
}
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
]);
}
/**
* Log the user out of the application.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function logout(Request $request)
{
$this->guard()->logout();
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Exceptions\EmailTakenException;
use App\Http\Controllers\Controller;
use App\Models\OAuthProvider;
use App\Models\User;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Laravel\Socialite\Facades\Socialite;
class OAuthController extends Controller
{
use AuthenticatesUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
config([
'services.github.redirect' => route('oauth.callback', 'github'),
]);
}
/**
* Redirect the user to the provider authentication page.
*
* @param string $provider
* @return \Illuminate\Http\RedirectResponse
*/
public function redirect($provider)
{
return [
'url' => Socialite::driver($provider)->stateless()->redirect()->getTargetUrl(),
];
}
/**
* Obtain the user information from the provider.
*
* @param string $driver
* @return \Illuminate\Http\Response
*/
public function handleCallback($provider)
{
$user = Socialite::driver($provider)->stateless()->user();
$user = $this->findOrCreateUser($provider, $user);
$this->guard()->setToken(
$token = $this->guard()->login($user)
);
return view('oauth/callback', [
'token' => $token,
'token_type' => 'bearer',
'expires_in' => $this->guard()->getPayload()->get('exp') - time(),
]);
}
/**
* @param string $provider
* @param \Laravel\Socialite\Contracts\User $sUser
* @return \App\Models\User
*/
protected function findOrCreateUser($provider, $user)
{
$oauthProvider = OAuthProvider::where('provider', $provider)
->where('provider_user_id', $user->getId())
->first();
if ($oauthProvider) {
$oauthProvider->update([
'access_token' => $user->token,
'refresh_token' => $user->refreshToken,
]);
return $oauthProvider->user;
}
if (User::where('email', $user->getEmail())->exists()) {
throw new EmailTakenException;
}
return $this->createUser($provider, $user);
}
/**
* @param string $provider
* @param \Laravel\Socialite\Contracts\User $sUser
* @return \App\Models\User
*/
protected function createUser($provider, $sUser)
{
$user = User::create([
'name' => $sUser->getName(),
'email' => $sUser->getEmail(),
'email_verified_at' => now(),
]);
$user->oauthProviders()->create([
'provider' => $provider,
'provider_user_id' => $sUser->getId(),
'access_token' => $sUser->token,
'refresh_token' => $sUser->refreshToken,
]);
return $user;
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Workspace;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class RegisterController extends Controller
{
use RegistersUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* The user has been registered.
*
* @param \Illuminate\Http\Request $request
* @param \App\User $user
* @return \Illuminate\Http\JsonResponse
*/
protected function registered(Request $request, User $user)
{
if ($user instanceof MustVerifyEmail) {
return response()->json(['status' => trans('verification.sent')]);
}
return response()->json($user);
}
/**
* Get a validator for an incoming registration request.
*
* @param array $data
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => 'required|max:255',
'email' => 'required|email:filter|max:255|unique:users',
'password' => 'required|min:6|confirmed',
'hear_about_us' => 'required|string'
]);
}
/**
* Create a new user instance after a valid registration.
*
* @param array $data
* @return \App\User
*/
protected function create(array $data)
{
$workspace = Workspace::create([
'name' => 'My Workspace',
'icon' => '🧪',
]);
$user = User::create([
'name' => $data['name'],
'email' => strtolower($data['email']),
'password' => bcrypt($data['password']),
'hear_about_us' => $data['hear_about_us']
]);
// Add relation with user
$user->workspaces()->sync([
$workspace->id => [
'role' => 'admin'
]
], false);
return $user;
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\Request;
class ResetPasswordController extends Controller
{
use ResetsPasswords;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Get the response for a successful password reset.
*
* @param \Illuminate\Http\Request $request
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*/
protected function sendResetResponse(Request $request, $response)
{
return ['status' => trans($response)];
}
/**
* Get the response for a failed password reset.
*
* @param \Illuminate\Http\Request $request
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*/
protected function sendResetFailedResponse(Request $request, $response)
{
return response()->json(['email' => trans($response)], 400);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
/**
* Get authenticated user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function current(Request $request)
{
return response()->json($request->user());
}
public function deleteAccount() {
$this->middleware('auth');
if (Auth::user()->admin) {
return $this->error([
'message' => 'Cannot delete an admin. Stay with us 🙏'
]);
}
Auth::user()->delete();
return $this->success([
'message' => 'User deleted.'
]);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\ValidationException;
class VerificationController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('throttle:6,1')->only('verify', 'resend');
}
/**
* Mark the user's email address as verified.
*
* @param \Illuminate\Http\Request $request
* @param \App\User $user
* @return \Illuminate\Http\JsonResponse
*/
public function verify(Request $request, User $user)
{
if (! URL::hasValidSignature($request)) {
return response()->json([
'status' => trans('verification.invalid'),
], 400);
}
if ($user->hasVerifiedEmail()) {
return response()->json([
'status' => trans('verification.already_verified'),
], 400);
}
$user->markEmailAsVerified();
event(new Verified($user));
return response()->json([
'status' => trans('verification.verified'),
]);
}
/**
* Resend the email verification notification.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function resend(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
$user = User::where('email', $request->email)->first();
if (is_null($user)) {
throw ValidationException::withMessages([
'email' => [trans('verification.user')],
]);
}
if ($user->hasVerifiedEmail()) {
throw ValidationException::withMessages([
'email' => [trans('verification.already_verified')],
]);
}
$user->sendEmailVerificationNotification();
return response()->json(['status' => trans('verification.sent')]);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Content;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ChangelogController extends Controller
{
const CANNY_ENDPOINT = 'https://canny.io/api/v1/';
public function index()
{
return \Cache::remember('changelog_entries', now()->addHour(), function () {
$response = \Http:: post(self::CANNY_ENDPOINT.'entries/list',[
'apiKey' => config('services.canny.api_key'),
'limit' => 3,
]);
return $response->json('entries');
});
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Content;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Laravel\Vapor\Http\Controllers\SignedStorageUrlController as Controller;
use Illuminate\Http\Request;
class SignedStorageUrlController extends Controller
{
/**
* Create a new signed URL.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
$this->ensureEnvironmentVariablesAreAvailable($request);
$bucket = $request->input('bucket') ?: $_ENV['AWS_BUCKET'];
$client = $this->storageClient();
$uuid = (string) Str::uuid();
$expiresAfter = config('vapor.signed_storage_url_expires_after', 5);
$signedRequest = $client->createPresignedRequest(
$this->createCommand($request, $client, $bucket, $key = ('tmp/'.$uuid)),
sprintf('+%s minutes', $expiresAfter)
);
$uri = $signedRequest->getUri();
return response()->json([
'uuid' => $uuid,
'bucket' => $bucket,
'key' => $key,
'url' => $uri->getScheme().'://'.$uri->getAuthority().$uri->getPath().'?'.$uri->getQuery(),
'headers' => $this->headers($request, $signedRequest),
], 201);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
public function success($data = [])
{
return response()->json(array_merge([
'type' => 'success'
], $data));
}
public function error($data = [], $statusCode = 400)
{
return response()->json(array_merge([
'type' => 'error'
], $data), $statusCode);
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreFormRequest;
use App\Http\Requests\UpdateFormRequest;
use App\Http\Requests\UploadAssetRequest;
use App\Http\Resources\FormResource;
use App\Models\Forms\Form;
use App\Models\Workspace;
use App\Service\Forms\FormCleaner;
use App\Service\Storage\StorageFileNameParser;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FormController extends Controller
{
const ASSETS_UPLOAD_PATH = 'assets/forms';
private FormCleaner $formCleaner;
public function __construct()
{
$this->middleware('auth');
$this->formCleaner = new FormCleaner();
}
public function index($workspaceId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('view', $workspace);
$this->authorize('viewAny', Form::class);
return FormResource::collection($workspace->forms);
}
/**
* Return all user forms, used for zapier
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function indexAll()
{
$forms = collect();
foreach (Auth::user()->workspaces as $workspace) {
$this->authorize('view', $workspace);
$this->authorize('viewAny', Form::class);
$forms = $forms->merge($workspace->forms);
}
return FormResource::collection($forms);
}
public function store(StoreFormRequest $request)
{
$this->authorize('create', Form::class);
$workspace = Workspace::findOrFail($request->get('workspace_id'));
$this->authorize('view', $workspace);
$formData = $this->formCleaner
->processRequest($request)
->simulateCleaning($workspace)
->getData();
$form = Form::create(array_merge($formData, [
'creator_id' => $request->user()->id
]));
return $this->success([
'message' => $this->formCleaner->hasCleaned() ? 'Form successfully created, but the Pro features you used will be disabled when sharing your form:' : 'Form created.',
'form_cleaning' => $this->formCleaner->getPerformedCleanings(),
'form' => new FormResource($form),
'users_first_form' => $request->user()->forms()->count() == 1
]);
}
public function update(UpdateFormRequest $request, string $id)
{
$form = Form::findOrFail($id);
$this->authorize('update', $form);
$formData = $this->formCleaner
->processRequest($request)
->simulateCleaning($form->workspace)
->getData();
// Set Removed Properties
$formData['removed_properties'] = array_merge($form->removed_properties, collect($form->properties)->filter(function ($field) use ($formData) {
return (!Str::of($field['type'])->startsWith('nf-') && !in_array($field['id'], collect($formData['properties'])->pluck("id")->toArray()));
})->toArray());
$form->update($formData);
return $this->success([
'message' => $this->formCleaner->hasCleaned() ? 'Form successfully updated, but the Pro features you used will be disabled when sharing your form:' : 'Form updated.',
'form_cleaning' => $this->formCleaner->getPerformedCleanings(),
'form' => new FormResource($form)
]);
}
public function destroy($id)
{
$form = Form::findOrFail($id);
$this->authorize('delete', $form);
$form->delete();
return $this->success([
'message' => 'Form was deleted.'
]);
}
public function duplicate($id)
{
$form = Form::findOrFail($id);
$this->authorize('update', $form);
// Create copy
$formCopy = $form->replicate();
$formCopy->title = 'Copy of '.$formCopy->title;
$formCopy->save();
return $this->success([
'message' => 'Form successfully duplicated.',
'new_form' => new FormResource($formCopy)
]);
}
public function regenerateLink($id, $option)
{
$form = Form::findOrFail($id);
$this->authorize('update', $form);
if ( $option == 'slug') {
$form->generateSlug();
} elseif ($option == 'uuid') {
$form->slug = Str::uuid();
}
$form->save();
return $this->success([
'message' => 'Form url successfully updated. Your new form url now is: '.$form->share_url.'.',
'form' => new FormResource($form)
]);
}
/**
* Upload a form asset
*/
public function uploadAsset(UploadAssetRequest $request)
{
$this->authorize('viewAny', Form::class);
$fileNameParser = StorageFileNameParser::parse($request->url);
// Make sure we retrieve the file in tmp storage, move it to persistent
$fileName = PublicFormController::TMP_FILE_UPLOAD_PATH.'/'.$fileNameParser->uuid;;
if (!Storage::disk('s3')->exists($fileName)) {
// File not found, we skip
return null;
}
$newPath = self::ASSETS_UPLOAD_PATH.'/'.$fileNameParser->getMovedFileName();
Storage::disk('s3')->move($fileName, $newPath);
return $this->success([
'message' => 'File uploaded.',
'url' => route("forms.assets.show", [$fileNameParser->getMovedFileName()])
]);
}
/**
* File uploads retrieval
*/
public function viewFile($id, $fileName)
{
$form = Form::findOrFail($id);
$this->authorize('view', $form);
$path = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $form->id).'/'.$fileName;
if (!Storage::disk('s3')->exists($path)) {
return $this->error([
'message' => 'File not found.'
]);
}
return redirect()->to(Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(5)));
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use Carbon\CarbonPeriod;
use App\Models\Forms\FormStatistic;
use Illuminate\Http\Request;
class FormStatsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function getFormStats(Request $request)
{
$form = $request->form; // Added by ProForm middleware
$this->authorize('view', $form);
$formStats = $form->statistics()->where('date','>',now()->subDays(29)->startOfDay())->get();
$periodStats = ["views" => [], "submissions" => []];
foreach (CarbonPeriod::create(now()->subDays(29), now()) as $dateObj) {
$date = $dateObj->format('d-m-Y');
$statisticData = $formStats->where('date', $dateObj->format('Y-m-d'))->first();
$periodStats["views"][$date] = $statisticData->data["views"] ?? 0;
$periodStats["submissions"][$date] = $statisticData->data["submissions"] ?? 0;
if($dateObj->toDateString() === now()->toDateString()){
$periodStats["views"][$date] += $form->views()->count();
$periodStats["submissions"][$date] += $form->submissions()->whereDate('created_at', '>=', now()->startOfDay())->count();
}
}
return $periodStats;
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Http\Resources\FormSubmissionResource;
use App\Models\Forms\Form;
use App\Exports\FormSubmissionExport;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel;
class FormSubmissionController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function submissions(string $id)
{
$form = Form::findOrFail((int) $id);
$this->authorize('view', $form);
return FormSubmissionResource::collection($form->submissions()->paginate(100));
}
public function export(string $id)
{
$form = Form::findOrFail((int) $id);
$this->authorize('view', $form);
$allRows = [];
foreach ($form->submissions->toArray() as $row) {
$formatter = (new FormSubmissionFormatter($form, $row['data']))
->outputStringsOnly()
->setEmptyForNoValue();
$tmp = $formatter->getCleanKeyValue();
$tmp['Create Date'] = date("Y-m-d H:i", strtotime($row['created_at']));
$allRows[] = $tmp;
}
$csvExport = (new FormSubmissionExport($allRows));
return Excel::download(
$csvExport,
$form->slug.'-submission-data.csv',
\Maatwebsite\Excel\Excel::CSV
);
}
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);
if (!Storage::disk('s3')->exists($fileName)) {
return $this->error([
'message' => 'File not found.',
], 404);
}
return redirect(
Storage::disk('s3')->temporaryUrl($fileName, now()->addMinute())
);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Forms\Integration;
use App\Http\Controllers\Controller;
use App\Http\Requests\Integration\StoreFormZapierWebhookRequest;
use App\Models\Integration\FormZapierWebhook;
use Illuminate\Http\Request;
use Spatie\WebhookServer\WebhookCall;
class FormZapierWebhookController extends Controller
{
/**
* Controller for Zappier webhook subscriptions.
*/
public function __construct() {
// $this->middleware('subscribed');
$this->middleware('auth');
}
public function store(StoreFormZapierWebhookRequest $request) {
$hook = $request->instanciateHook();
$this->authorize('store', $hook);
$hook->save();
return $this->success([
'message' => 'Webhook created.',
'hook' => $hook
]);
}
public function delete($id) {
$hook = FormZapierWebhook::findOrFail($id);
$this->authorize('store', $hook);
$hook->delete();
return $this->success([
'message' => 'Webhook deleted.',
]);
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Http\Requests\AnswerFormRequest;
use App\Http\Resources\FormResource;
use App\Jobs\Form\StoreFormSubmissionJob;
use App\Models\Forms\Form;
use App\Service\Forms\FormCleaner;
use App\Service\WorkspaceHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Auth;
class PublicFormController extends Controller
{
const FILE_UPLOAD_PATH = 'forms/?/submissions';
const TMP_FILE_UPLOAD_PATH = 'tmp/';
public function show(Request $request, string $slug)
{
$form = Form::whereSlug($slug)->firstOrFail();
if ($form->workspace == null) {
// Workspace deleted
return $this->error([
'message' => 'Form not found.'
], 404);
}
$formCleaner = new FormCleaner();
// Disable pro features if needed
$form->fill($formCleaner
->processForm($request, $form)
->performCleaning($form->workspace)
->getData()
);
// Increase form view counter if not login
if(!Auth::check()){
$form->views()->create();
}
$formResource = new FormResource($form);
$formResource->setCleanings($formCleaner->getPerformedCleanings());
return $formResource;
}
public function listUsers(Request $request)
{
// Check that form has user field
$form = $request->form;
if (!$form->has_user_field) {
return [];
}
// Use serializer
$workspace = $form->workspace;
return (new WorkspaceHelper($workspace))->getAllUsers();
}
public function showAsset($assetFileName)
{
$path = FormController::ASSETS_UPLOAD_PATH.'/'.$assetFileName;
if (!Storage::disk('s3')->exists($path)) {
return $this->error([
'message' => 'File not found.',
'file_name' => $assetFileName
]);
}
return redirect()->to(Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(5)));
}
public function answer(AnswerFormRequest $request)
{
$form = $request->form;
StoreFormSubmissionJob::dispatch($form, $request->validated());
return $this->success(array_merge([
'message' => 'Form submission saved.',
], $request->form->is_pro && $request->form->redirect_url ? [
'redirect' => true,
'redirect_url' => $request->form->redirect_url
] : [
'redirect' => false
]));
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class PasswordController extends Controller
{
/**
* Update the user's password.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function update(Request $request)
{
$this->validate($request, [
'password' => 'required|confirmed|min:6',
]);
$request->user()->update([
'password' => bcrypt($request->password),
]);
return response()->json(null, 204);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ProfileController extends Controller
{
/**
* Update the user's profile information.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function update(Request $request)
{
$user = $request->user();
$this->validate($request, [
'name' => 'required',
'email' => 'required|email|unique:users,email,'.$user->id,
]);
return tap($user)->update([
'name' => $request->name,
'email' => strtolower($request->email),
]);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;
class SitemapController extends Controller
{
/**
* Contains route name and the associated priority
*
* @var array
*/
protected $urls = [
['/', 1],
['/privacy-policy', 0.5],
['/terms-conditions', 0.5],
['/login', 0.4],
['/register', 0.4],
['/password/reset', 0.3],
];
public function getSitemap(Request $request)
{
$sitemap = Sitemap::create();
foreach ($this->urls as $url) {
$sitemap->add($this->createUrl($url[0], $url[1]));
}
return $sitemap->toResponse($request);
}
private function createUrl($url, $priority, $frequency = 'daily')
{
return Url::create($url)->setPriority($priority)->setChangeFrequency($frequency);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
class SpaController extends Controller
{
/**
* Get the SPA view.
*
* @return \Illuminate\Http\Response
*/
public function __invoke()
{
return view('spa');
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Laravel\Cashier\Subscription;
class SubscriptionController extends Controller
{
const SUBSCRIPTION_PLANS = ['monthly_2022', 'yearly_2022'];
const PRO_SUBSCRIPTION_NAME = 'default';
const ENTERPRISE_SUBSCRIPTION_NAME = 'enterprise';
const SUBSCRIPTION_NAMES = [
self::PRO_SUBSCRIPTION_NAME,
self::ENTERPRISE_SUBSCRIPTION_NAME
];
/**
* Returns stripe checkout URL
*
* $plan is constrained with regex in the api.php
*/
public function checkout($pricing, $plan, $trial = null)
{
$this->middleware('not-subscribed');
// Check User does not have a pending subscription
$user = Auth::user();
if ($user->subscriptions()->where('stripe_status', 'past_due')->first()) {
return $this->error([
'message' => 'You already have a past due subscription. Please verify your details in the billing page,
and contact us if the issue persists.'
]);
}
$checkoutBuilder = $user
->newSubscription($pricing, $this->getPricing($pricing)[$plan])
->allowPromotionCodes();
if ($trial != null) {
$checkoutBuilder->trialDays(3);
}
$checkout = $checkoutBuilder
->collectTaxIds()
->checkout([
'success_url' => url('/subscriptions/success'),
'cancel_url' => url('/subscriptions/error'),
]);
return $this->success([
'checkout_url' => $checkout->url
]);
}
public function billingPortal()
{
$this->middleware('auth');
if (!Auth::user()->has_customer_id) {
return $this->error([
"message" => "Please subscribe before accessing your billing portal."
]);
}
return $this->success([
'portal_url' => Auth::user()->billingPortalUrl(url('/home'))
]);
}
private function getPricing($product = 'pro')
{
return App::environment() == 'production' ? config('pricing.production.'.$product.'.pricing') : config('pricing.test.'.$product.'.pricing');
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Webhook;
use App\Notifications\Subscription\FailedPaymentNotification;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Http\Controllers\WebhookController;
use Stripe\Subscription as StripeSubscription;
class StripeController extends WebhookController
{
public function handleCustomerSubscriptionCreated(array $payload)
{
return parent::handleCustomerSubscriptionCreated($payload);
}
/**
* Override to add a sleep, and to detect plan upgrades
* @param array $payload
* @return \Symfony\Component\HttpFoundation\Response|void
*/
protected function handleCustomerSubscriptionUpdated(array $payload)
{
sleep(1);
if ($user = $this->getUserByStripeId($payload['data']['object']['customer'])) {
$data = $payload['data']['object'];
$subscription = $user->subscriptions()->firstOrNew(['stripe_id' => $data['id']]);
if (
isset($data['status']) &&
$data['status'] === StripeSubscription::STATUS_INCOMPLETE_EXPIRED
) {
$subscription->items()->delete();
$subscription->delete();
return;
}
$subscription->name = $subscription->name ?? $data['metadata']['name'] ?? $this->newSubscriptionName($payload);
$firstItem = $data['items']['data'][0];
$isSinglePrice = count($data['items']['data']) === 1;
// Price...
$subscription->stripe_price = $isSinglePrice ? $firstItem['price']['id'] : null;
// Name...
$subscription->name = $this->getSubscriptionName($data['plan']['product']);
// Quantity...
$subscription->quantity = $isSinglePrice && isset($firstItem['quantity']) ? $firstItem['quantity'] : null;
// Trial ending date...
if (isset($data['trial_end'])) {
$trialEnd = Carbon::createFromTimestamp($data['trial_end']);
if (!$subscription->trial_ends_at || $subscription->trial_ends_at->ne($trialEnd)) {
$subscription->trial_ends_at = $trialEnd;
}
}
// Cancellation date...
if (isset($data['cancel_at_period_end'])) {
if ($data['cancel_at_period_end']) {
$subscription->ends_at = $subscription->onTrial()
? $subscription->trial_ends_at
: Carbon::createFromTimestamp($data['current_period_end']);
} elseif (isset($data['cancel_at'])) {
$subscription->ends_at = Carbon::createFromTimestamp($data['cancel_at']);
} else {
$subscription->ends_at = null;
}
}
// Status...
if (isset($data['status'])) {
$subscription->stripe_status = $data['status'];
}
$subscription->save();
// Update subscription items...
if (isset($data['items'])) {
$prices = [];
foreach ($data['items']['data'] as $item) {
$prices[] = $item['price']['id'];
$subscription->items()->updateOrCreate([
'stripe_id' => $item['id'],
], [
'stripe_product' => $item['price']['product'],
'stripe_price' => $item['price']['id'],
'quantity' => $item['quantity'] ?? null,
]);
}
// Delete items that aren't attached to the subscription anymore...
$subscription->items()->whereNotIn('stripe_price', $prices)->delete();
}
}
return $this->successMethod();
}
protected function handleChargeFailed(array $payload)
{
if ($user = $this->getUserByStripeId($payload['data']['object']['customer'])) {
$user->notify(new FailedPaymentNotification());
}
return $this->successMethod();
}
private function getSubscriptionName(string $stripeProductId)
{
$config = App::environment() == 'production' ? config('pricing.production') : config('pricing.test');
foreach ($config as $plan => $data) {
if ($stripeProductId == $config[$plan]['product_id']) {
return $plan;
}
}
return 'default';
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Workspace;
use Illuminate\Http\Request;
use App\Service\WorkspaceHelper;
use Illuminate\Support\Facades\Auth;
class WorkspaceController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index()
{
$this->authorize('viewAny', Workspace::class);
return Auth::user()->workspaces;
}
public function listUsers(Request $request, $workspaceId)
{
$workspace = Workspace::findOrFail($workspaceId);
$this->authorize('view', $workspace);
return (new WorkspaceHelper($workspace))->getAllUsers();
}
public function delete($id)
{
$workspace = Workspace::findOrFail($id);
$this->authorize('delete', $workspace);
$id = $workspace->id;
$workspace->delete();
return $this->success([
'message' => 'Workspace deleted.',
'workspace_id' => $id
]);
}
public function create(Request $request)
{
$user = $request->user();
$this->validate($request, [
'name' => 'required'
]);
// Create workspace
$workspace = Workspace::create([
'name' => $request->name,
'icon' => ($request->emoji) ? $request->emoji : '',
]);
// Add relation with user
$user->workspaces()->sync([
$workspace->id => [
'role' => 'admin'
]
], false);
return $this->success([
'message' => 'Workspace created.',
'workspace_id' => $workspace->id
]);
}
}

85
app/Http/Kernel.php Normal file
View File

@ -0,0 +1,85 @@
<?php
namespace App\Http;
use App\Http\Middleware\EmbeddableForms;
use App\Http\Middleware\IsAdmin;
use App\Http\Middleware\IsNotSubscribed;
use App\Http\Middleware\IsSubscribed;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\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,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\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' => [
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Session\Middleware\StartSession::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'admin' => IsAdmin::class,
'subscribed' => IsSubscribed::class,
'not-subscribed' => IsNotSubscribed::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'pro-form' => \App\Http\Middleware\Form\ProForm::class,
'password-protected-form' => \App\Http\Middleware\Form\PasswordProtectedForm::class,
];
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request)
{
if (! $request->expectsJson()) {
return redirect('/login');
}
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Response;
class EmbeddableForms
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @return mixed
*/
public function handle($request, Closure $next)
{
if ($request->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;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Middleware\Form;
use App\Models\Forms\Form;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PasswordProtectedForm
{
const PASSWORD_HEADER_NAME = 'form-password';
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
if ($request->route('slug')) {
$form = Form::where('slug',$request->route('slug'))->firstOrFail();
$request->merge([
'form' => $form,
]);
$userIsFormOwner = Auth::check() && Auth::user()->workspaces()->find($form->workspace_id) !== null;
if (!$userIsFormOwner && $form->is_pro && $form->has_password) {
if($this->hasCorrectPassword($request, $form)){
return $next($request);
}
return response([
'status' => 'Unauthorized',
'message' => 'Form is password protected.',
], 403);
}
}
return $next($request);
}
public static function hasCorrectPassword(Request $request, Form $form)
{
return $request->headers->has(self::PASSWORD_HEADER_NAME) && $request->headers->get(self::PASSWORD_HEADER_NAME) == hash('sha256', $form->password);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware\Form;
use App\Models\Forms\Form;
use Closure;
use Illuminate\Http\Request;
class ProForm
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
if ($request->route('formId') && $form = Form::findOrFail($request->route('formId'))) {
if ($form->is_pro) {
$request->merge([
'form' => $form,
]);
return $next($request);
}
}
return response([
'status' => 'Unauthorized',
'message' => 'You need a subscription to access this content.',
], 403);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class IsAdmin
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($request->user() && !$request->user()->admin) {
// This user is not a paying customer...
if ($request->expectsJson()) {
return response([
'message' => 'You are not allowed.',
'type' => 'error',
], 403);
}
return redirect('home');
}
return $next($request);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class IsNotSubscribed
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($request->user() && $request->user()->subscribed()) {
// This user is a paying customer...
if ($request->expectsJson()) {
return response([
'message' => 'You are already subscribed to NotionForms Pro.',
'type' => 'error',
], 401);
}
return redirect('billing');
}
return $next($request);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class IsSubscribed
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($request->user() && !$request->user()->subscribed()) {
// This user is not a paying customer...
if ($request->expectsJson()) {
return response([
'message' => 'You are not subscribed to NotionForms Pro.',
'type' => 'error',
], 401);
}
return redirect('billing');
}
return $next($request);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
* @return mixed
*/
public function handle(Request $request, Closure $next, ...$guards)
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Already authenticated.'], 400);
} else {
return redirect(RouteServiceProvider::HOME);
}
}
}
return $next($request);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Middleware;
use Closure;
class SetLocale
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if ($locale = $this->parseLocale($request)) {
app()->setLocale($locale);
}
return $next($request);
}
/**
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function parseLocale($request)
{
$locales = config('app.locales');
$locale = $request->server('HTTP_ACCEPT_LANGUAGE');
$locale = substr($locale, 0, strpos($locale, ',') ?: strlen($locale));
if (array_key_exists($locale, $locales)) {
return $locale;
}
$locale = substr($locale, 0, 2);
if (array_key_exists($locale, $locales)) {
return $locale;
}
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array
*/
protected $except = [
'password',
'password_confirmation',
];
/**
* The route name where this shouldn't be applied
* @var string[]
*/
protected $exceptUrls = [
'/\/api\/forms\/(.*)\/answer/'
];
public function handle($request, \Closure $next)
{
// Check if URL matches
foreach ($this->exceptUrls as $urlRegex) {
$matches = null;
preg_match($urlRegex, $request->url(), $matches);
if (count($matches)) {
return $next($request);
}
}
return parent::handle($request, $next);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array
*/
public function hosts()
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
'stripe/webhook',
'vapor/signed-storage-url'
];
}

View File

@ -0,0 +1,203 @@
<?php
namespace App\Http\Requests;
use App\Models\Forms\Form;
use App\Rules\StorageFile;
use App\Service\Forms\FormLogicPropertyResolver;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Http\Request;
use App\Rules\ValidHCaptcha;
class AnswerFormRequest extends FormRequest
{
const MAX_FILE_SIZE_PRO = 5000000;
const MAX_FILE_SIZE_ENTERPRISE = 20000000;
public Form $form;
protected array $requestRules = [];
protected int $maxFileSize;
public function __construct(Request $request)
{
$this->form = $request->form;
$this->maxFileSize = self::MAX_FILE_SIZE_PRO;
$workspace = $this->form->workspace;
if ($workspace && $workspace->is_enterprise) {
$this->maxFileSize = self::MAX_FILE_SIZE_ENTERPRISE;
}
}
/**
* Validate form before use it
*
* @return bool
*/
public function authorize()
{
return !$this->form->is_closed && !$this->form->max_number_of_submissions_reached;
}
/**
* Get the validation rules that apply to the form.
*
* @return array
*/
public function rules()
{
foreach ($this->form->properties as $property) {
$rules = [];
if (!$this->form->is_pro) { // If not pro then not check logic
$property['logic'] = false;
}
// For get values instead of Id for select/multi select options
$data = $this->toArray();
$selectionFields = collect($this->form->properties)->filter(function ($pro) {
return in_array($pro['type'], ['select', 'multi_select']);
});
foreach ($selectionFields as $field){
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 isset($tmpop['name']) ? $tmpop['name'] : "";
}, $data[$field['id']]);
}
};
if (FormLogicPropertyResolver::isRequired($property, $data)) {
$rules[] = 'required';
// Required for checkboxes means true
if ($property['type'] == 'checkbox') {
$rules[] = 'accepted';
}
} else {
$rules[] = 'nullable';
}
// Clean id to escape "."
$propertyId = $property['id'];
if (in_array($property['type'], ['multi_select'])) {
$rules[] = 'array';
$this->requestRules[$propertyId.'.*'] = $this->getPropertyRules($property);
} else {
$rules = array_merge($rules, $this->getPropertyRules($property));
}
$this->requestRules[$propertyId] = $rules;
}
// Validate hCaptcha
if ($this->form->is_pro && $this->form->use_captcha) {
$this->requestRules['h-captcha-response'] = [new ValidHCaptcha()];
}
return $this->requestRules;
}
/**
* Renames validated fields (because field names are ids)
* @return array
*/
public function attributes()
{
$fields = [];
foreach ($this->form->properties as $property) {
$fields[$property['id']] = $property['name'];
}
return $fields;
}
/**
* Return validation rules for a given form property
* @param $property
*/
private function getPropertyRules($property): array
{
switch ($property['type']) {
case 'text':
case 'phone_number':
return ['string'];
case 'number':
return ['numeric'];
case 'select':
case 'multi_select':
if ($this->form->is_pro && ($property['allow_creation'] ?? false)) {
return ['string'];
}
return [Rule::in($this->getSelectPropertyOptions($property))];
case 'checkbox':
return ['boolean'];
case 'url':
if (isset($property['file_upload']) && $property['file_upload']) {
$this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize)];
return ['array'];
}
return ['url'];
case 'files':
$allowedFileTypes = [];
if($this->form->is_pro && !empty($property['allowed_file_types'])){
$allowedFileTypes = explode(",", $property['allowed_file_types']);
}
$this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize, $allowedFileTypes)];
return ['array'];
case 'email':
return ['email:filter'];
case 'date':
if (isset($property['date_range']) && $property['date_range']) {
$this->requestRules[$property['id'].'.*'] = ['date'];
return ['array'];
}
return ['date'];
default:
return [];
}
}
private function getSelectPropertyOptions($property): array
{
$type = $property['type'];
if (!isset($property[$type])) {
return [];
}
return array_column($property[$type]['options'], 'name');
}
protected function prepareForValidation()
{
// Escape all '\' in select options
$receivedData = $this->toArray();
$mergeData = [];
collect($this->form->properties)->filter(function ($property) {
return in_array($property['type'], ['select', 'multi_select']);
})->each(function ($property) use ($receivedData, &$mergeData) {
$receivedValue = $receivedData[$property['id']] ?? null;
if (!is_null($receivedValue)) {
if (is_array($receivedValue)) {
$mergeData[$property['id']] = collect($receivedValue)->map(function ($value) {
$value = Str::of($value);
return $value->replace(
["\e", "\f", "\n", "\r", "\t", "\v", "\\"],
["\\e", "\\f", "\\n", "\\r", "\\t", "\\v", "\\\\"]
)->toString();
})->toArray();
} else {
$receivedValue = Str::of($receivedValue);
$mergeData[$property['id']] = $receivedValue->replace(
["\e", "\f", "\n", "\r", "\t", "\v", "\\"],
["\\e", "\\f", "\\n", "\\r", "\\t", "\\v", "\\\\"]
)->toString();
}
}
});
$this->merge($mergeData);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Integration;
use App\Models\Forms\Form;
use App\Models\Integration\FormZapierWebhook;
use Illuminate\Foundation\Http\FormRequest;
class StoreFormZapierWebhookRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'form_slug' => 'required|exists:forms,slug',
'hook_url' => 'required|string|url'
];
}
public function instanciateHook() {
$form = Form::whereSlug($this->form_slug)->firstOrFail();
return new FormZapierWebhook([
'form_id' => $form->id,
'hook_url' => $this->hook_url,
]);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests;
use App\Models\Forms\Form;
use Illuminate\Validation\Rule;
class StoreFormRequest extends UserFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return array_merge(parent::rules(), [// Info about database
'workspace_id' => 'required|exists:workspaces,id',
]);
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Requests;
class UpdateFormRequest extends UserFormRequest
{
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use App\Rules\StorageFile;
use Illuminate\Foundation\Http\FormRequest;
class UploadAssetRequest extends FormRequest
{
const FORM_ASSET_MAX_SIZE = 5000000;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'url' => ['required',new StorageFile(self::FORM_ASSET_MAX_SIZE, [
'png',
'jpeg',
'jpg',
'bmp',
'gif'
])],
];
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Http\Requests;
use App\Models\Forms\Form;
use App\Rules\OneEmailPerLine;
use Illuminate\Validation\Rule;
use App\Rules\FormPropertyLogicRule;
/**
* Abstract class to validate create/update forms
*
* Class UserFormRequest
* @package App\Http\Requests
*/
abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
// Form Info
'title' => 'required|string|max:60',
'description' => 'nullable|string|max:2000',
'tags' => 'nullable|array',
// Notifications
'notifies' => 'boolean',
'notification_emails' => ['required_if:notifies,1', new OneEmailPerLine ],
'send_submission_confirmation' => 'boolean',
'notification_sender' => 'string|max:64',
'notification_subject' => 'string|max:200',
'notification_body' => 'string|nullable',
'notifications_include_submission' => 'boolean',
'webhook_url' => 'url|nullable',
'use_captcha' => 'boolean',
// Customization
'theme' => ['required',Rule::in(Form::THEMES)],
'width' => ['required',Rule::in(Form::WIDTHS)],
'cover_picture' => 'url|nullable',
'logo_picture' => 'url|nullable',
'dark_mode' => ['required',Rule::in(Form::DARK_MODE_VALUES)],
'color' => 'required|string',
'hide_title' => 'required|boolean',
'uppercase_labels' => 'required|boolean',
'no_branding' => 'required|boolean',
'transparent_background' => 'required|boolean',
'closes_at' => 'date|nullable',
'closed_text' => 'string|nullable',
// Custom Code
'custom_code' => 'string|nullable',
// Submission
'submit_button_text' => 'string|min:1|max:50',
're_fillable' => 'boolean',
're_fill_button_text' => 'string|min:1|max:50',
'submitted_text' => 'string|max:2000',
'redirect_url' => 'nullable|active_url|max:255',
'database_fields_update' => 'nullable|array',
'max_submissions_count' => 'integer|nullable|min:1',
'max_submissions_reached_text' => 'string|nullable',
// Properties
'properties' => 'required|array',
'properties.*.id' => 'required',
'properties.*.name' => 'required',
'properties.*.type' => 'required',
'properties.*.placeholder' => 'sometimes|nullable',
'properties.*.prefill' => 'sometimes|nullable',
'properties.*.help' => 'sometimes|nullable',
'properties.*.hidden' => 'boolean|nullable',
'properties.*.required' => 'boolean|nullable',
'properties.*.multiple' => 'boolean|nullable',
'properties.*.timezone' => 'sometimes|nullable',
'properties.*.width' => ['sometimes', Rule::in(['full','1/2','1/3','2/3','1/3','3/4','1/4'])],
'properties.*.allowed_file_types' => 'sometimes|nullable',
// Logic
'properties.*.logic' => ['array', 'nullable', new FormPropertyLogicRule()],
// Form blocks
'properties.*.content' => 'sometimes|nullable',
// Text field
'properties.*.multi_lines' => 'boolean|nullable',
'properties.*.max_char_limit' => 'integer|nullable|min:1|max:2000',
'properties.*.show_char_limit ' => 'boolean|nullable',
// Date field
'properties.*.with_time' => 'boolean|nullable',
'properties.*.date_range' => 'boolean|nullable',
// Select / Multi Select field
'properties.*.allow_creation' => 'boolean|nullable',
'properties.*.without_dropdown' => 'boolean|nullable',
// Advanced Options
'properties.*.generates_uuid' => 'boolean|nullable',
'properties.*.generates_auto_increment_id' => 'boolean|nullable',
// Security & Privacy
'can_be_indexed' => 'boolean',
'password' => 'sometimes|nullable',
];
}
/**
* Get the validation messages that apply to the request.
*
* @return array
*/
public function messages()
{
return [
'properties.*.name.required' => 'The form block number :position is missing a name.',
'properties.*.type.required' => 'The form block number :position is missing a type.',
'properties.*.max_char_limit.min' => 'The form block number :position max character limit must be at least 1 OR Empty',
'properties.*.max_char_limit.max' => 'The form block number :position max character limit may not be greater than 2000.',
];
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Http\Resources;
use App\Http\Middleware\Form\PasswordProtectedForm;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
class FormResource extends JsonResource
{
private Array $cleanings = [];
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
$userIsFormOwner = Auth::check() && Auth::user()->workspaces()->find($this->workspace_id) !== null;
if(!$userIsFormOwner && $this->doesMissPassword($request)){
return $this->getPasswordProtectedForm();
}
$ownerData = $userIsFormOwner ? [
'creator' => $this->creator,
'views_count' => $this->when($this->workspace->is_pro, $this->views_count),
'submissions_count' => $this->when($this->workspace->is_pro, $this->submissions_count),
'notifies' => $this->notifies,
'send_submission_confirmation' => $this->send_submission_confirmation,
'webhook_url' => $this->webhook_url,
'redirect_url' => $this->redirect_url,
'database_fields_update' => $this->database_fields_update,
'cleanings' => $this->cleanings,
'notification_sender' => $this->notification_sender,
'notification_subject' => $this->notification_subject,
'notification_body' => $this->notification_body,
'notifications_include_submission' => $this->notifications_include_submission,
'can_be_indexed' => $this->can_be_indexed,
'password' => $this->password,
'tags' => $this->tags,
'notification_emails' => $this->notification_emails,
] : [];
$baseData = $this->getFilteredFormData(parent::toArray($request), $userIsFormOwner);
return array_merge($baseData, $ownerData, [
'workspace_id' => $this->workspace_id,
'workspace' => new WorkspaceResource($this->workspace),
'is_closed' => $this->is_closed,
'is_password_protected' => false,
'has_password' => $this->has_password,
'max_number_of_submissions_reached' => $this->max_number_of_submissions_reached
]);
}
/**
* 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;
return $this;
}
private function doesMissPassword(Request $request)
{
if (!$this->is_pro || !$this->has_password) return false;
return !PasswordProtectedForm::hasCorrectPassword($request, $this->resource);
}
private function getPasswordProtectedForm()
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'custom_code' => $this->custom_code,
'dark_mode' => $this->dark_mode,
'transparent_background' => $this->transparent_background,
'color' => $this->color,
'is_password_protected' => true,
'has_password' => $this->has_password,
'width' => 'centered',
'properties' => []
];
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class FormSubmissionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
$this->generateFileLinks();
$this->addTimestamp();
return [
'data' => $this->data,
'form_id' => $this->form_id,
'id' => $this->id,
];
}
private function addTimestamp()
{
$this->data = array_merge($this->data, [
"created_at" => $this->created_at->toDateTimeString()
]);
}
/**
* Link to the file (generating signed s3 URL)
* @return void
*/
private function generateFileLinks()
{
$data = $this->data;
$formFields = collect($this->form->properties)->concat(collect($this->form->removed_properties));
$fileFields = $formFields->filter(function ($field) {
return $field['type'] == 'files';
});
foreach ($fileFields as $field) {
if (isset($data[$field['id']]) && !empty($data[$field['id']])) {
$data[$field['id']] = collect($data[$field['id']])->map(function ($file) {
return [
'file_url' => route('open.forms.submissions.file', [$this->form_id, $file]),
'file_name' => $file,
];
});
}
}
$this->data = $data;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
class WorkspaceResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'id' => $this->id,
'is_enterprise' => $this->is_enterprise,
'is_pro' => $this->is_pro,
];
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Jobs\Form;
use App\Events\Forms\FormSubmitted;
use App\Http\Controllers\Forms\PublicFormController;
use App\Http\Requests\AnswerFormRequest;
use App\Models\Forms\Form;
use App\Service\Storage\StorageFileNameParser;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class StoreFormSubmissionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(public Form $form, public array $submissionData)
{
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$formData = $this->getFormData();
$this->addHiddenPrefills($formData);
$this->form->submissions()->create([
'data' => $formData,
]);
FormSubmitted::dispatch($this->form, $formData);
}
/**
* Retrieve data from request object, and pre-format it if needed.
* - Replace notionforms id with notion field ids
* - Clean \ in select id values
* - Stores file and replace value with url
* - Generate auto increment id & unique id features for rich text field
*/
private function getFormData()
{
$data = $this->submissionData;
$finalData = [];
$properties = collect($this->form->properties);
// Do required transformation per type (e.g. file uploads)
foreach ($data as $answerKey => $answerValue) {
$field = $properties->where('id', $answerKey)->first();
if (!$field) {
continue;
}
if (
($field['type'] == 'url' && isset($field['file_upload']) && $field['file_upload'])
|| $field['type'] == 'files') {
if (is_array($answerValue)) {
$finalData[$field['id']] = [];
foreach ($answerValue as $file) {
$finalData[$field['id']][] = $this->storeFile($file);
}
} else {
$finalData[$field['id']] = $this->storeFile($answerValue);
}
} else {
if ($field['type'] == 'text' && isset($field['generates_uuid']) && $field['generates_uuid']) {
$finalData[$field['id']] = ($this->form->is_pro) ? Str::uuid() : "Please upgrade your OpenForm subscription to use our ID generation features";
} else {
if ($field['type'] == 'text' && isset($field['generates_auto_increment_id']) && $field['generates_auto_increment_id']) {
$finalData[$field['id']] = ($this->form->is_pro) ? (string) ($this->form->submissions_count + 1) : "Please upgrade your OpenForm subscription to use our ID generation features";
} else {
$finalData[$field['id']] = $answerValue;
}
}
}
}
return $finalData;
}
/**
* Custom Back-end Value formatting. Use case:
* - File uploads (move file from tmp storage to persistent)
*
* File can have 2 formats:
* - file_name-{uuid}.{ext}
* - {uuid}
*/
private function storeFile(?string $value)
{
if ($value == null) {
return null;
}
$fileNameParser = StorageFileNameParser::parse($value);
// Make sure we retrieve the file in tmp storage, move it to persistent
$fileName = PublicFormController::TMP_FILE_UPLOAD_PATH.'/'.$fileNameParser->uuid;
if (!Storage::disk('s3')->exists($fileName)) {
// File not found, we skip
return null;
}
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
$completeNewFilename = $newPath.'/'.$fileNameParser->getMovedFileName();
\Log::debug('Moving file to permanent storage.', [
'uuid' => $fileNameParser->uuid,
'destination' => $completeNewFilename,
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
]);
Storage::disk('s3')->move($fileName, $completeNewFilename);
return $fileNameParser->getMovedFileName();
}
/**
* Adds prefill from hidden fields
*
* @param array $formData
* @param AnswerFormRequest $request
*/
private function addHiddenPrefills(array &$formData): void
{
// Find hidden properties with prefill, set values
collect($this->form->properties)->filter(function ($property) {
return isset($property['hidden'])
&& isset($property['prefill'])
&& $property['hidden']
&& !is_null($property['prefill']);
})->each(function (array $property) use (&$formData) {
if ($property['type'] === 'date' && isset($property['prefill_today']) && $property['prefill_today']) {
$formData[$property['id']] = now();
} else {
$formData[$property['id']] = $property['prefill'];
}
});
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Listeners\Forms;
use App\Events\Models\FormCreated;
use App\Mail\Forms\FormCreationConfirmationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;
class FormCreationConfirmation implements ShouldQueue
{
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle(FormCreated $event)
{
Mail::to($event->form->creator)->send(new FormCreationConfirmationMail($event->form));
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Listeners\Forms;
use App\Events\Forms\FormSubmitted;
use App\Notifications\Forms\FormSubmissionNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Notification;
class NotifyFormSubmission implements ShouldQueue
{
use InteractsWithQueue;
/**
* Sends notification to pre-defined emails on form submissions
*
* @param object $event
* @return void
*/
public function handle(FormSubmitted $event)
{
if (!$event->form->notifies || !$event->form->is_pro) return;
$subscribers = collect(preg_split("/\r\n|\n|\r/", $event->form->notification_emails))->filter(function($email) {
return filter_var($email, FILTER_VALIDATE_EMAIL);
});
\Log::debug('Sending email notification',[
'recipients' => $subscribers->toArray(),
'form_id' => $event->form->id,
'form_slug' => $event->form->slug,
]);
$subscribers->each(function ($subscriber) use ($event) {
Notification::route('mail', $subscriber)->notify(new FormSubmissionNotification($event));
});
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Listeners\Forms;
use App\Events\Forms\FormSubmitted;
use App\Models\Forms\Form;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Spatie\WebhookServer\WebhookCall;
use App\Service\Forms\FormSubmissionFormatter;
class PostFormDataToWebhook implements ShouldQueue
{
use InteractsWithQueue;
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle(FormSubmitted $event)
{
$form = $event->form;
if (!$form->is_pro) return;
$data = $this->getWebhookData($event);
$this->sendSimpleWebhook($form, $data);
$this->sendZappierWebhooks($form, $data);
}
private function sendSimpleWebhook(Form $form, array $data) {
if ($form->webhook_url) {
\Log::debug('Sending data to webhook URL',[
'webhook_url' => $form->webhook_url,
'form_id' => $form->id,
'form_slug' => $form->slug,
]);
WebhookCall::create()
->url($form->webhook_url)
->doNotSign()
->payload($data)
->dispatch();
}
}
private function sendZappierWebhooks(Form $form, array $data) {
foreach ($form->zappierHooks as $hook) {
\Log::debug('Sending data to Zapier webhook',[
'form_id' => $form->id,
'form_slug' => $form->slug,
]);
$hook->triggerHook($data);
}
}
private function getWebhookData(FormSubmitted $event): array {
$formatter = (new FormSubmissionFormatter($event->form, $event->data));
$formattedData = [];
foreach ($formatter->getFieldsWithValue() as $field) {
$formattedData[$field['name']] = $field['value'];
}
return [
'form_title' => $event->form->title,
'form_slug' => $event->form->slug,
'submission' => $formattedData
];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Listeners\Forms;
use App\Events\Forms\FormSubmitted;
use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;
/**
* Sends a confirmation to form respondant that form was submitted
*
* Class SubmissionConfirmation
* @package App\Listeners\Forms
*/
class SubmissionConfirmation implements ShouldQueue
{
use InteractsWithQueue;
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle(FormSubmitted $event)
{
if (!$event->form->send_submission_confirmation) return;
$email = $this->getRespondentEmail($event);
if (!$email) return;
\Log::info('Sending submission confirmation',[
'recipient' => $email,
'form_id' => $event->form->id,
'form_slug' => $event->form->slug,
]);
Mail::to($email)->send(new SubmissionConfirmationMail($event));
}
private function getRespondentEmail(FormSubmitted $event)
{
// Make sure we only have one email field in the form
$emailFields = collect($event->form->properties)->filter(function($field) {
$hidden = $field['hidden']?? false;
return !$hidden && $field['type'] == 'email';
});
if ($emailFields->count() != 1) return null;
if (isset($event->data[$emailFields->first()['id']])) {
$email = $event->data[$emailFields->first()['id']];
if ($this->validateEmail($email)) return $email;
}
return null;
}
public static function validateEmail($email): bool {
return (boolean) filter_var($email, FILTER_VALIDATE_EMAIL);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Mail\Forms;
use App\Mail\OpenFormMail;
use App\Models\Forms\Form;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class FormCreationConfirmationMail extends OpenFormMail implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(public Form $form)
{
//
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this
->markdown('mail.form.created',[
'form' => $this->form,
])->subject('Your form was created!');
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Mail\Forms;
use App\Events\Forms\FormSubmitted;
use App\Mail\OpenFormMail;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(private FormSubmitted $event)
{}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$form = $this->event->form;
$formatter = (new FormSubmissionFormatter($form, $this->event->data))
->createLinks()
->outputStringsOnly();
return $this
->replyTo($form->creator->email)
->from($this->getFromEmail(), $form->notification_sender)
->subject($form->notification_subject)
->markdown('mail.form.confirmation-submission-notification',[
'fields' => $formatter->getFieldsWithValue(),
'form' => $form,
]);
}
private function getFromEmail()
{
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
return $originalFromAddress->first(). '+' . time() . '@' . $originalFromAddress->last();
}
}

13
app/Mail/OpenFormMail.php Normal file
View File

@ -0,0 +1,13 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
abstract class OpenFormMail extends Mailable
{
use Queueable, SerializesModels;
}

226
app/Models/Forms/Form.php Normal file
View File

@ -0,0 +1,226 @@
<?php
namespace App\Models\Forms;
use App\Events\Models\FormCreated;
use App\Models\Integration\FormZapierWebhook;
use App\Models\User;
use App\Models\Workspace;
use Database\Factories\FormFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
use Stevebauman\Purify\Facades\Purify;
use Illuminate\Support\Facades\DB;
class Form extends Model
{
const DARK_MODE_VALUES = ['auto', 'light', 'dark'];
const THEMES = ['default', 'simple', 'notion'];
const WIDTHS = ['centered', 'full'];
use HasFactory, HasSlug, SoftDeletes;
protected $fillable = [
'workspace_id',
'creator_id',
'properties',
'removed_properties',
// Notifications
'notifies',
'notification_emails',
'send_submission_confirmation',
'notification_sender',
'notification_subject',
'notification_body',
'notifications_include_submission',
// integrations
'webhook_url',
'title',
'description',
'tags',
// Customization
'theme',
'width',
'cover_picture',
'logo_picture',
'dark_mode',
'color',
'uppercase_labels',
'no_branding',
'hide_title',
'transparent_background',
// Custom Code
'custom_code',
// Submission
'submit_button_text',
'database_fields_update',
're_fillable',
're_fill_button_text',
'submitted_text',
'redirect_url',
'use_captcha',
'closes_at',
'closed_text',
'max_submissions_count',
'max_submissions_reached_text',
// Security & Privacy
'can_be_indexed',
'password'
];
protected $casts = [
'properties' => 'array',
'database_fields_update' => 'array',
'closes_at' => 'datetime',
'tags' => 'array',
'removed_properties' => 'array'
];
protected $appends = [
'share_url',
'is_pro'
];
protected $hidden = [
'workspace_id',
'notifies',
'webhook_url',
'send_submission_confirmation',
'redirect_url',
'database_fields_update',
'notification_sender',
'notification_subject',
'notification_body',
'notifications_include_submission',
'password',
'tags',
'notification_emails',
'removed_properties'
];
/**
* The event map for the model.
*
* @var array
*/
protected $dispatchesEvents = [
'created' => FormCreated::class,
];
public function getIsProAttribute()
{
return optional($this->workspace)->is_pro;
}
public function getShareUrlAttribute()
{
return url('/forms/'.$this->slug);
}
public function getSubmissionsCountAttribute()
{
return $this->submissions()->count();
}
public function getViewsCountAttribute()
{
return $this->views()->count() +
$this->statistics()->sum(DB::raw("cast(data->>'views' as integer)"));
}
public function setDescriptionAttribute($value)
{
// Strip out unwanted html
$this->attributes['description'] = Purify::clean($value);
}
public function setSubmittedTextAttribute($value)
{
// Strip out unwanted html
$this->attributes['submitted_text'] = Purify::clean($value);
}
public function getIsClosedAttribute()
{
return ($this->closes_at && now()->gt($this->closes_at));
}
public function getMaxNumberOfSubmissionsReachedAttribute()
{
return ($this->max_submissions_count && $this->max_submissions_count <= $this->submissions_count);
}
public function setClosedTextAttribute($value)
{
$this->attributes['closed_text'] = Purify::clean($value);
}
public function setMaxSubmissionsReachedTextAttribute($value)
{
$this->attributes['max_submissions_reached_text'] = Purify::clean($value);
}
public function getHasPasswordAttribute()
{
return !empty($this->password);
}
/**
* Relationships
*/
public function workspace()
{
return $this->belongsTo(Workspace::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'creator_id');
}
public function submissions()
{
return $this->hasMany(FormSubmission::class);
}
public function views()
{
return $this->hasMany(FormView::class);
}
public function statistics()
{
return $this->hasMany(FormStatistic::class);
}
public function zappierHooks()
{
return $this->hasMany(FormZapierWebhook::class);
}
/**
* Config/options
*/
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
->doNotGenerateSlugsOnUpdate()
->generateSlugsFrom('title')
->saveSlugsTo('slug');
}
public static function newFactory()
{
return FormFactory::new();
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models\Forms;
use App\Models\Forms\Form;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class FormStatistic extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'form_id',
'data',
'date'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'data' => 'array',
];
/**
* Relationships
*/
public function form()
{
return $this->belongsTo(Form::class);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models\Forms;
use App\Models\Forms\Form;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class FormSubmission extends Model
{
use HasFactory;
protected $fillable = [
'data'
];
protected $casts = [
'data' => 'array'
];
/**
* RelationShips
*/
public function form() {
return $this->belongsTo(Form::class);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Models\Forms;
use App\Models\Forms\Form;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class FormView extends Model
{
use HasFactory;
/**
* RelationShips
*/
public function form() {
return $this->belongsTo(Form::class);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models\Integration;
use App\Models\Forms\Form;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\WebhookServer\WebhookCall;
class FormZapierWebhook extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'form_zapier_webhooks';
protected $fillable = [
'form_id',
'hook_url',
];
/**
* Relationships
*/
public function form()
{
return $this->belongsTo(Form::class);
}
public function triggerHook(array $data) {
WebhookCall::create()
->url($this->hook_url)
->doNotSign()
->payload($data)
->dispatch();
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OAuthProvider extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'oauth_providers';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = ['id'];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'access_token', 'refresh_token',
];
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

209
app/Models/User.php Normal file
View File

@ -0,0 +1,209 @@
<?php
namespace App\Models;
use App\Http\Controllers\SubscriptionController;
use App\Models\Forms\Form;
use App\Models\Workspace;
use App\Notifications\ResetPassword;
use App\Notifications\VerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Rickycezar\Impersonate\Models\Impersonate;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject //, MustVerifyEmail
{
use Notifiable, HasFactory, Billable;
const ADMINS = ['julien@notionforms.io'];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'email',
'password',
'hear_about_us'
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password',
'remember_token',
'hear_about_us'
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
/**
* The accessors to append to the model's array form.
*
* @var array
*/
protected $appends = [
'photo_url',
'is_subscribed',
'has_enterprise_subscription',
'admin',
'has_customer_id',
'has_forms'
];
protected $withCount = ['workspaces'];
/**
* Get the profile photo URL attribute.
*
* @return string
*/
public function getPhotoUrlAttribute()
{
return vsprintf('https://www.gravatar.com/avatar/%s.jpg?s=200&d=%s', [
md5(strtolower($this->email)),
$this->name ? urlencode("https://ui-avatars.com/api/$this->name") : 'mp',
]);
}
public function getHasFormsAttribute()
{
return $this->workspaces()->whereHas('forms')->exists();
}
public function getIsSubscribedAttribute()
{
return $this->subscribed() || $this->subscribed(SubscriptionController::ENTERPRISE_SUBSCRIPTION_NAME);
}
public function getHasEnterpriseSubscriptionAttribute()
{
return $this->subscribed(SubscriptionController::ENTERPRISE_SUBSCRIPTION_NAME);
}
public function getHasCustomerIdAttribute()
{
return !is_null($this->stripe_id);
}
public function getAdminAttribute()
{
return in_array($this->email, self::ADMINS);
}
/**
* =================================
* Helper Related
* =================================
*/
/**
* Send the password reset notification.
*
* @param string $token
* @return void
*/
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPassword($token));
}
/**
* Send the email verification notification.
*
* @return void
*/
public function sendEmailVerificationNotification()
{
$this->notify(new VerifyEmail);
}
/**
* =================================
* Relationship
* =================================
*/
public function workspaces()
{
return $this->belongsToMany(Workspace::class);
}
public function forms()
{
return $this->hasMany(Form::class,'creator_id');
}
/**
* =================================
* Oauth Related
* =================================
*/
/**
* Get the oauth providers.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function oauthProviders()
{
return $this->hasMany(OAuthProvider::class);
}
/**
* @return int
*/
public function getJWTIdentifier()
{
return $this->getKey();
}
/**
* @return array
*/
public function getJWTCustomClaims()
{
return [];
}
public static function boot ()
{
parent::boot();
static::deleting(function(User $user) {
// Remove user's workspace if he's the only one with this workspace
foreach ($user->workspaces as $workspace) {
if ($workspace->users()->count() == 1) {
$workspace->delete();
}
}
});
}
public function scopeWithActiveSubscription($query)
{
return $query->whereHas('subscriptions', function($query) {
$query->where(function($q){
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
});
});
}
}

68
app/Models/Workspace.php Normal file
View File

@ -0,0 +1,68 @@
<?php
namespace App\Models;
use App\Models\Forms\Form;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Workspace extends Model
{
use HasFactory;
protected $fillable = [
'name',
'icon',
'user_id',
];
protected $appends = [
'is_pro',
'is_enterprise'
];
public function getIsProAttribute()
{
return true; // Temporary true for ALL
// Make sure at least one owner is pro
foreach ($this->owners as $owner) {
if ($owner->is_subscribed) {
return true;
}
}
return false;
}
public function getIsEnterpriseAttribute()
{
return true; // Temporary true for ALL
foreach ($this->owners as $owner) {
if ($owner->has_enterprise_subscription) {
return true;
}
}
return false;
}
/**
* Relationships
*/
public function users()
{
return $this->belongsToMany(User::class);
}
public function owners()
{
return $this->users()->wherePivot('role', 'admin');
}
public function forms()
{
return $this->hasMany(Form::class);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Notifications\Forms;
use App\Events\Forms\FormSubmitted;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class FormSubmissionNotification extends Notification implements ShouldQueue
{
use Queueable;
public FormSubmitted $event;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(FormSubmitted $event)
{
$this->event = $event;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
$formatter = (new FormSubmissionFormatter($this->event->form, $this->event->data))
->showHiddenFields()
->createLinks()
->outputStringsOnly();
return (new MailMessage)
->replyTo($notifiable->routes['mail'])
->from($this->getFromEmail(), config('app.name'))
->subject('New form submission for "'.$this->event->form->title.'"')
->markdown('mail.form.submission-notification', [
'fields' => $formatter->getFieldsWithValue(),
'form' => $this->event->form,
]);
}
private function getFromEmail()
{
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
return $originalFromAddress->first(). '+' . time() . '@' . $originalFromAddress->last();
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\ResetPassword as Notification;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPassword extends Notification
{
/**
* Build the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
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))
->line('If you did not request a password reset, no further action is required.');
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Notifications\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class FailedPaymentNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
return (new MailMessage)
->subject('Your Payment Failed')
->greeting(__('We tried to charge your card for your OpenForm subscription but the payment but did not work.'))
->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('/'));
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Notifications;
use Carbon\Carbon;
use Illuminate\Auth\Notifications\VerifyEmail as Notification;
use Illuminate\Support\Facades\URL;
class VerifyEmail extends Notification
{
/**
* Get the verification URL for the given notifiable.
*
* @param mixed $notifiable
* @return string
*/
protected function verificationUrl($notifiable)
{
$url = URL::temporarySignedRoute(
'verification.verify', Carbon::now()->addMinutes(60), ['user' => $notifiable->id]
);
return str_replace('/api', '', $url);
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Policies;
use App\Models\Forms\Form;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class FormPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $user
* @return mixed
*/
public function viewAny(User $user)
{
return true;
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $user
* @param \App\Models\Forms\Form $form
* @return mixed
*/
public function view(User $user, Form $form)
{
return $user->workspaces()->find($form->workspace_id)->exists();
}
/**
* Determine whether the user can create models.
*
* @param \App\Models\User $user
* @return mixed
*/
public function create(User $user)
{
return true;
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $user
* @param \App\Models\Forms\Form $form
* @return mixed
*/
public function update(User $user, Form $form)
{
return $user->workspaces()->find($form->workspace_id)->exists();
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\Forms\Form $form
* @return mixed
*/
public function delete(User $user, Form $form)
{
return $user->workspaces()->find($form->workspace_id)->exists();
}
/**
* Determine whether the user can restore the model.
*
* @param \App\Models\User $user
* @param \App\Models\Forms\Form $form
* @return mixed
*/
public function restore(User $user, Form $form)
{
return $user->workspaces()->find($form->workspace_id)->exists();
}
/**
* Determine whether the user can permanently delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\Forms\Form $form
* @return mixed
*/
public function forceDelete(User $user, Form $form)
{
return $user->workspaces()->find($form->workspace_id)->exists();
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Policies\Integration;
use App\Models\Integration\FormZapierWebhook;
use App\Models\User;
use App\Policies\FormPolicy;
use Illuminate\Auth\Access\HandlesAuthorization;
class FormZapierWebhookPolicy
{
use HandlesAuthorization;
protected FormPolicy $formPolicy;
public function __construct()
{
$this->formPolicy = new FormPolicy();
}
public function store(User $user, FormZapierWebhook $webhook)
{
return $this->formPolicy->update($user, $webhook->form); // && $user->is_subscribed;
}
public function delete(User $user, FormZapierWebhook $webhook)
{
return $this->formPolicy->update($user, $webhook->form); // && $user->is_subscribed;
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Policies;
use App\Models\Workspace;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class WorkspacePolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $user
* @return mixed
*/
public function viewAny(User $user)
{
return true;
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $user
* @param \App\Models\Workspace $workspace
* @return mixed
*/
public function view(User $user, Workspace $workspace)
{
return $user->workspaces()->find($workspace->id)!==null;
}
/**
* Determine whether the user can create models.
*
* @param \App\Models\User $user
* @return mixed
*/
public function create(User $user)
{
return false;
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $user
* @param \App\Models\Workspace $workspace
* @return mixed
*/
public function update(User $user, Workspace $workspace)
{
return false;
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\Workspace $workspace
* @return mixed
*/
public function delete(User $user, Workspace $workspace)
{
return !$workspace->owners->where('id',$user->id)->isEmpty() && $user->workspaces()->count() > 1;
}
/**
* Determine whether the user can restore the model.
*
* @param \App\Models\User $user
* @param \App\Models\Workspace $workspace
* @return mixed
*/
public function restore(User $user, Workspace $workspace)
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\Workspace $workspace
* @return mixed
*/
public function forceDelete(User $user, Workspace $workspace)
{
return false;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Providers;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
use Laravel\Dusk\DuskServiceProvider;
use Illuminate\Support\Facades\Validator;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
JsonResource::withoutWrapping();
Cashier::calculateTaxes();
if ($this->app->runningUnitTests()) {
Schema::defaultStringLength(191);
}
Validator::includeUnvalidatedArrayKeys();
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
if ($this->app->environment('local', 'testing') && class_exists(DuskServiceProvider::class)) {
$this->app->register(DuskServiceProvider::class);
}
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Providers;
use App\Models\Forms\Form;
use App\Models\Integration\FormZapierWebhook;
use App\Models\Workspace;
use App\Models\User;
use App\Policies\FormPolicy;
use App\Policies\Integration\FormZapierWebhookPolicy;
use App\Policies\WorkspacePolicy;
use App\Policies\UserPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
/**
* The policy mappings for the application.
*
* @var array
*/
protected $policies = [
Form::class => FormPolicy::class,
Workspace::class => WorkspacePolicy::class,
FormZapierWebhook::class => FormZapierWebhookPolicy::class
];
/**
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{
$this->registerPolicies();
\Illuminate\Support\Facades\Gate::define('viewMailcoach', function ($user = null) {
return optional($user)->admin;
});
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Providers;
use App\Events\Forms\FormSubmitted;
use App\Events\Models\FormCreated;
use App\Listeners\Auth\RegisteredListener;
use App\Listeners\Forms\FormCreationConfirmation;
use App\Listeners\Forms\NotifyFormSubmission;
use App\Listeners\Forms\PostFormDataToWebhook;
use App\Listeners\Forms\SubmissionConfirmation;
use App\Notifications\Forms\FormCreatedNotification;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
/**
* The event listener mappings for the application.
*
* @var array
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
FormCreated::class => [
FormCreationConfirmation::class
],
FormSubmitted::class => [
NotifyFormSubmission::class,
PostFormDataToWebhook::class,
SubmissionConfirmation::class,
]
];
/**
* Register any events for your application.
*
* @return void
*/
public function boot()
{
//
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
// Horizon::night();
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*
* @return void
*/
protected function gate()
{
Gate::define('viewHorizon', function ($user) {
return in_array($user->email, [
'julien@notionforms.io'
]);
});
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Jhumanj\LaravelModelStats\LaravelModelStats;
use Jhumanj\LaravelModelStats\ModelStatsServiceProvider as Provider;
class ModelStatsServiceProvider extends Provider
{
/**
* Register the LaravelModelStats gate.
*
* This gate determines who can access ModelStats in non-local environments.
*
* @return void
*/
protected function gate(): void
{
Gate::define('viewModelStats', function ($user) {
return in_array($user->email, [
'julien@notionforms.io',
]);
});
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Providers;
use App\Service\HtmlPurifier\HTMLPurifier_URIScheme_notion;
use Illuminate\Support\ServiceProvider;
class PurifySetupProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
\HTMLPurifier_URISchemeRegistry::instance()->register('notion', new HTMLPurifier_URIScheme_notion());
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
/**
* The path to the "home" route for your application.
*
* This is used by Laravel authentication to redirect users after login.
*
* @var string
*/
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.
*
* @return void
*/
public function boot()
{
$this->configureRateLimiting();
$this->registerGlobalRouteParamConstraints();
$this->routes(function () {
Route::prefix('api')
->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'));
});
}
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
});
}
protected function registerGlobalRouteParamConstraints() {
Route::pattern('workspaceId', '[0-9]+');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class VaporUiServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
$this->gate();
}
/**
* Register the Vapor UI gate.
*
* This gate determines who can access Vapor UI in non-local environments.
*
* @return void
*/
protected function gate()
{
Gate::define('viewVaporUI', function ($user) {
return $user->admin;
});
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
}

Some files were not shown because too many files have changed in this diff Show More