first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

2
common/.gitattributes vendored Executable file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

View File

@@ -0,0 +1,32 @@
<?php
namespace Common\Admin;
use Common\Core\BaseController;
use Common\Logging\Schedule\ScheduleLogItem;
class AdminSetupAlertsController extends BaseController
{
public function __construct()
{
$this->middleware('isAdmin');
}
public function index()
{
$alerts = [];
if (!ScheduleLogItem::scheduleRanInLast30Minutes()) {
$alerts[] = [
'id' => 'cronNotSetup',
'title' => 'There is an issue with CRON schedule',
'description' =>
'The CRON schedule has not run in the last 30 minutes. If you did not set it up yet, see the documentation <a class="underline font-semibold" target="_blank" href="https://support.vebto.com/hc/articles/21/23/169/automated-tasks-cron-jobs">here</a>.',
];
}
return $this->success([
'alerts' => $alerts,
]);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Admin\Analytics\Actions;
interface BuildAnalyticsReport
{
/**
* Get data for admin area analytics page from active provider.
* (Demo or Google Analytics currently)
*/
public function execute(array $params): array;
}

View File

@@ -0,0 +1,136 @@
<?php
namespace Common\Admin\Analytics\Actions;
use App\Models\User;
use Common\Database\Metrics\MetricDateRange;
use Illuminate\Support\Arr;
class BuildDemoAnalyticsReport implements BuildAnalyticsReport
{
protected MetricDateRange $dateRange;
public function execute(array $params): array
{
$this->dateRange = $params['dateRange'];
return [
'pageViews' => $this->buildPageviewsMetric(),
'browsers' => [
'granularity' => $this->dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => $this->buildBrowsersMetric(),
],
],
],
'locations' => [
'granularity' => $this->dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => $this->buildLocationsMetric(),
],
],
],
'devices' => [
'granularity' => $this->dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => $this->buildDevicesMetric(),
],
],
],
'platforms' => [
'granularity' => $this->dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => $this->buildPlatformsMetric(),
],
],
],
];
}
public function buildPageviewsMetric(): array
{
$current = (new DemoTrend(
User::query(),
dateRange: $this->dateRange,
))->count();
$previous = (new DemoTrend(
User::query(),
dateRange: $this->dateRange,
))->count();
return [
'granularity' => $this->dateRange->granularity,
'total' => array_sum(Arr::pluck($current, 'value')),
'datasets' => [
[
'label' => __('Current period'),
'data' => $current,
],
[
'label' => __('Previous period'),
'data' => $previous,
],
],
];
}
public function buildBrowsersMetric(): array
{
return [
['label' => 'Chrome', 'value' => random_int(300, 500)],
['label' => 'Firefox', 'value' => random_int(200, 400)],
['label' => 'IE', 'value' => random_int(100, 150)],
['label' => 'Edge', 'value' => random_int(100, 200)],
['label' => 'Safari', 'value' => random_int(200, 300)],
];
}
public function buildDevicesMetric(): array
{
return [
['label' => 'Mobile', 'value' => random_int(300, 500)],
['label' => 'Tablet', 'value' => random_int(200, 400)],
['label' => 'Desktop', 'value' => random_int(100, 150)],
];
}
public function buildPlatformsMetric(): array
{
return [
['label' => 'Windows', 'value' => random_int(300, 500)],
['label' => 'Linux', 'value' => random_int(200, 400)],
['label' => 'iOS', 'value' => random_int(100, 150)],
['label' => 'Android', 'value' => random_int(100, 150)],
];
}
public function buildLocationsMetric(): array
{
$data = [
['label' => 'United States', 'code' => 'US', 'value' => random_int(300, 500)],
['label' => 'India', 'code' => 'IN', 'value' => random_int(100, 300)],
['label' => 'Russia', 'code' => 'RU', 'value' => random_int(250, 400)],
['label' => 'Germany', 'code' => 'DE', 'value' => random_int(200, 500)],
['label' => 'France', 'code' => 'FR', 'value' => random_int(150, 300)],
['label' => 'Japan', 'code' => 'JP', 'value' => random_int(150, 300)],
['label' => 'United Kingdom', 'code' => 'GB', 'value' => random_int(300, 400)],
['label' => 'Canada', 'code' => 'CA', 'value' => random_int(100, 150)],
];
$total = array_sum(Arr::pluck($data, 'value'));
return array_map(function($item) use($total) {
$item['percentage'] = round($item['value'] / $total * 100, 2);
return $item;
}, $data);
}
}

View File

@@ -0,0 +1,331 @@
<?php
namespace Common\Admin\Analytics\Actions;
use Carbon\Carbon;
use Common\Database\Metrics\MetricDateRange;
use Common\Database\Metrics\Traits\GeneratesTrendResults;
use Google\Analytics\Data\V1beta\BetaAnalyticsDataClient;
use Google\Analytics\Data\V1beta\DateRange;
use Google\Analytics\Data\V1beta\Dimension;
use Google\Analytics\Data\V1beta\Metric;
use Google\Analytics\Data\V1beta\OrderBy;
use Google\Analytics\Data\V1beta\OrderBy\DimensionOrderBy;
use Google\Analytics\Data\V1beta\OrderBy\MetricOrderBy;
use Google\Analytics\Data\V1beta\Row;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class BuildGoogleAnalyticsReport implements BuildAnalyticsReport
{
use GeneratesTrendResults;
protected BetaAnalyticsDataClient $client;
protected MetricDateRange $dateRange;
public function __construct()
{
$this->client = new BetaAnalyticsDataClient([
'credentials' => storage_path('laravel-analytics/certificate.json'),
]);
}
public function execute(array $params = []): array
{
$this->dateRange = $params['dateRange'] ?? new MetricDateRange();
return $this->buildReport();
}
protected function buildReport(): array
{
return [
'pageViews' => [
'datasets' => $this->buildPageViewsMetric(),
'granularity' => $this->dateRange->granularity,
],
'browsers' => [
'granularity' => $this->dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => $this->buildTopBrowsersMetric(),
],
],
],
'locations' => [
'granularity' => $this->dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => $this->buildTopLocationsMetric(),
],
],
],
'devices' => [
'granularity' => $this->dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => $this->buildTopDevicesMetric(),
],
],
],
'platforms' => [
'granularity' => $this->dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => $this->buildTopPlatformsMetric(),
],
],
],
];
}
protected function buildTopBrowsersMetric(int $maxResults = 10): Collection
{
$rows = $this->performQuery($this->dateRange, [
'dimensions' => ['browser'],
'metrics' => ['sessions'],
'sort' => ['direction' => 'desc', 'metric' => 'sessions'],
]);
$topBrowsers = $rows->map(function (Row $row) {
return [
'label' => $row->getDimensionValues()[0]->getValue(),
'value' => $row->getMetricValues()[0]->getValue(),
];
});
if ($topBrowsers->count() <= $maxResults) {
return $topBrowsers;
}
return $topBrowsers->take($maxResults - 1)->push([
'label' => __('Others'),
'value' => $topBrowsers->splice($maxResults - 1)->sum('value'),
]);
}
private function buildTopLocationsMetric(): Collection
{
$maxResults = 6;
$rows = $this->performQuery($this->dateRange, [
'metrics' => ['sessions'],
'dimensions' => ['country'],
'sort' => [
'direction' => 'desc',
'metric' => 'sessions',
],
]);
$locations = $rows->map(function (Row $row) {
return [
'label' => $row->getDimensionValues()[0]->getValue(),
'value' => $row->getMetricValues()[0]->getValue(),
];
});
$total = $locations->sum('value');
$locations = $locations->map(function ($location) use ($total) {
$location['percentage'] = round(
(100 * $location['value']) / $total,
1,
);
return $location;
});
if ($locations->count() <= $maxResults) {
return $locations;
}
return $locations->take($maxResults - 1)->push([
'label' => __('Other'),
'value' => $locations->splice($maxResults - 1)->sum('value'),
]);
}
protected function buildTopDevicesMetric(int $maxResults = 10): Collection
{
$rows = $this->performQuery($this->dateRange, [
'dimensions' => ['deviceCategory'],
'metrics' => ['sessions'],
'sort' => ['direction' => 'desc', 'metric' => 'sessions'],
]);
$devices = $rows->map(function (Row $row) {
return [
'label' => __(
ucfirst($row->getDimensionValues()[0]->getValue()),
),
'value' => $row->getMetricValues()[0]->getValue(),
];
});
if ($devices->count() <= $maxResults) {
return $devices;
}
return $devices->take($maxResults - 1)->push([
'label' => __('Others'),
'value' => $devices->splice($maxResults - 1)->sum('value'),
]);
}
protected function buildTopPlatformsMetric(int $maxResults = 10): Collection
{
$rows = $this->performQuery($this->dateRange, [
'dimensions' => ['operatingSystem'],
'metrics' => ['sessions'],
'sort' => ['direction' => 'desc', 'metric' => 'sessions'],
]);
$platforms = $rows->map(
fn(Row $row) => [
'label' => $row->getDimensionValues()[0]->getValue(),
'value' => $row->getMetricValues()[0]->getValue(),
],
);
if ($platforms->count() <= $maxResults) {
return $platforms;
}
return $platforms->take($maxResults - 1)->push([
'label' => __('Others'),
'value' => $platforms->splice($maxResults - 1)->sum('value'),
]);
}
private function buildPageViewsMetric(): array
{
$this->getPageViews($this->dateRange->getPreviousPeriod());
return [
[
'label' => __('Current period'),
'data' => $this->getPageViews($this->dateRange),
],
[
'label' => __('Previous period'),
'data' => $this->getPageViews(
$this->dateRange->getPreviousPeriod(),
),
],
];
}
private function getPageViews(MetricDateRange $dateRange): Collection
{
$format = $this->dateRange->getGroupingFormat();
$results = $this->fetchVisitorsAndPageViews($dateRange)
// Google Analytics will return views at one minute granularity always.
// Group these by date format based on selected granularity instead.
// e.g. "day" granularity will equal "YYYY-MM-DD" => ["value" => 1]
->groupBy(fn($item) => $item['date']->format($format))
// reduce each granularity group to a single value
->map(function (Collection $dateGroup) {
return $dateGroup->reduce(
function ($result, $item) {
$result['value'] += $item['value'];
return $result;
},
[
'date' => $dateGroup[0]['date'],
'value' => 0,
],
);
})
->mapWithKeys(
fn($item) => [
$item['date']->format($format) => $this->formatTrendResult(
$dateRange->granularity,
$item['date'],
$item['value'],
),
],
)
->sortKeys()
->all();
$mergedResults = array_replace(
$this->getAllPossibleDateResults($dateRange),
$results,
);
return collect($mergedResults)->values();
}
protected function fetchVisitorsAndPageViews(
MetricDateRange $dateRange,
): Collection {
$rows = $this->performQuery($dateRange, [
'dimensions' => ['dateHourMinute', 'pageTitle'], // YYYYMMDDHHMM
'metrics' => ['totalUsers', 'screenPageViews'],
]);
return $rows->map(function (Row $dateRow) {
return [
'date' => Carbon::createFromFormat(
'YmdHi',
$dateRow->getDimensionValues()[0]->getValue(),
),
'pageTitle' => $dateRow->getDimensionValues()[1]->getValue(),
'visitors' => (int) $dateRow->getMetricValues()[0]->getValue(),
'value' => (int) $dateRow->getMetricValues()[1]->getValue(),
];
});
}
protected function performQuery(
MetricDateRange $dateRange,
array $options,
): Collection {
$orderBy = null;
if (isset($options['sort'])) {
$sortOptions = [];
if (Arr::get($options, 'sort.direction') == 'desc') {
$sortOptions['desc'] = true;
}
if ($metricName = Arr::get($options, 'sort.metric')) {
$sortOptions['metric'] = (new MetricOrderBy())->setMetricName(
$metricName,
);
}
if ($dimensionName = Arr::get($options, 'sort.dimensions')) {
$sortOptions[
'dimension'
] = (new DimensionOrderBy())->setDimensionName($dimensionName);
}
$orderBy = new OrderBy($sortOptions);
}
$propertyId = config('services.google.analytics_property_id');
$response = $this->client->runReport([
'property' => "properties/$propertyId",
'dateRanges' => [$this->getGoogleDateRange($dateRange)],
'dimensions' => array_map(
fn($name) => new Dimension(['name' => $name]),
$options['dimensions'],
),
'metrics' => array_map(
fn($name) => new Metric(['name' => $name]),
$options['metrics'],
),
'orderBys' => $orderBy ? [$orderBy] : null,
]);
return collect($response->getRows() ?: []);
}
protected function getGoogleDateRange(MetricDateRange $range): DateRange
{
return (new DateRange())
->setStartDate($range->start->toDateString())
->setEndDate($range->end->toDateString());
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Common\Admin\Analytics\Actions;
class BuildNullAnalyticsReport implements BuildAnalyticsReport
{
public function execute(array $params): array
{
$dateRange = $params['dateRange'];
return [
'pageViews' => [
'granularity' => $dateRange->granularity,
'datasets' => [
[
'label' => __('Current period'),
'data' => [],
],
[
'label' => __('Previous period'),
'data' => [],
],
],
],
'browsers' => [
'granularity' => $dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => [],
],
],
],
'locations' => [
'granularity' => $dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => [],
],
],
],
'devices' => [
'granularity' => $dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => [],
],
],
],
'platforms' => [
'granularity' => $dateRange->granularity,
'datasets' => [
[
'label' => __('Sessions'),
'data' => [],
],
],
],
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Common\Admin\Analytics\Actions;
use Common\Database\Metrics\Trend;
class DemoTrend extends Trend
{
protected function aggregate(string $function): array
{
$data = array_map(function($item) {
$item['value'] = random_int(100, 500);
return $item;
}, $this->getAllPossibleDateResults($this->dateRange));
return array_values($data);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Common\Admin\Analytics\Actions;
interface GetAnalyticsHeaderDataAction
{
/**
* Get analytics header data.
*/
public function execute(array $params): array;
}

View File

@@ -0,0 +1,72 @@
<?php namespace Common\Admin\Analytics;
use Carbon\CarbonImmutable;
use Common\Admin\Analytics\Actions\BuildAnalyticsReport;
use Common\Admin\Analytics\Actions\BuildNullAnalyticsReport;
use Common\Admin\Analytics\Actions\GetAnalyticsHeaderDataAction;
use Common\Core\BaseController;
use Common\Database\Metrics\MetricDateRange;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class AnalyticsController extends BaseController
{
public function __construct(
protected Request $request,
protected BuildAnalyticsReport $getDataAction,
protected GetAnalyticsHeaderDataAction $getHeaderDataAction,
) {
}
public function report()
{
$this->authorize('index', 'ReportPolicy');
$types = explode(',', $this->request->get('types', 'visitors,header'));
$dateRange = $this->getDateRange();
$cacheKey = sprintf(
'%s-%s',
$dateRange->getCacheKey(),
implode(',', $types),
);
$response = [];
$reportParams = ['dateRange' => $dateRange];
if (in_array('visitors', $types)) {
try {
$response['visitorsReport'] = Cache::remember(
"adminReport.main.$cacheKey",
CarbonImmutable::now()->addDay(),
fn() => $this->getDataAction->execute($reportParams),
);
} catch (Exception $e) {
$response['visitorsReport'] = app(
BuildNullAnalyticsReport::class,
)->execute($reportParams);
}
}
if (in_array('header', $types)) {
$response['headerReport'] = Cache::remember(
"adminReport.header.$cacheKey",
CarbonImmutable::now()->addDay(),
fn() => $this->getHeaderDataAction->execute($reportParams),
);
}
return $this->success($response);
}
protected function getDateRange(): MetricDateRange
{
$startDate = $this->request->get('startDate');
$endDate = $this->request->get('endDate');
$timezone = $this->request->get('timezone', config('app.timezone'));
return new MetricDateRange(
start: $startDate,
end: $endDate,
timezone: $timezone,
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Common\Admin\Analytics;
use Common\Admin\Analytics\Actions\BuildAnalyticsReport;
use Common\Admin\Analytics\Actions\BuildDemoAnalyticsReport;
use Common\Admin\Analytics\Actions\BuildGoogleAnalyticsReport;
use Common\Admin\Analytics\Actions\BuildNullAnalyticsReport;
use Illuminate\Support\ServiceProvider;
class AnalyticsServiceProvider extends ServiceProvider
{
/**
* Register bindings in the container.
*
* @return void
*/
public function register()
{
$this->app->singleton(BuildAnalyticsReport::class, function () {
if (config('common.site.demo')) {
return new BuildDemoAnalyticsReport();
} else {
return $this->getGoogleAnalyticsData();
}
});
}
private function getGoogleAnalyticsData()
{
try {
return new BuildGoogleAnalyticsReport();
} catch (\Exception $e) {
return new BuildNullAnalyticsReport();
}
}
}

View File

@@ -0,0 +1,97 @@
<?php namespace Common\Admin\Appearance;
use Common\Admin\Appearance\Themes\CssTheme;
use Common\Settings\DotEnvEditor;
use Common\Settings\Settings;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\File;
class AppearanceSaver
{
const CUSTOM_CSS_PATH = 'custom-code/custom-styles.css';
const CUSTOM_HTML_PATH = 'custom-code/custom-html.html';
public function __construct(
protected Settings $settings,
protected DotEnvEditor $envEditor,
) {
}
public function save(array $values): void
{
foreach ($values['appearance'] as $groupName => $groupValues) {
if ($groupName === 'env') {
$this->saveEnvSettings($groupValues);
} elseif ($groupName === 'custom_code') {
$this->saveCustomCode($groupValues);
} elseif ($groupName === 'themes') {
$this->syncThemes($groupValues['all']);
}
}
if (!empty($values['settings'])) {
//generate and store favicon
if (isset($values['settings']['branding']['favicon'])) {
$path = $values['settings']['branding']['favicon'];
unset($values['settings']['branding']['favicon']);
app(GenerateFavicon::class)->execute($path);
}
$this->settings->save($values['settings']);
}
}
private function saveEnvSettings(array $settings): void
{
foreach ($settings as $key => $value) {
$this->envEditor->write([
$key => $value,
]);
}
}
private function saveCustomCode(array $settings): void
{
foreach ($settings as $key => $value) {
$path =
$key === 'css' ? self::CUSTOM_CSS_PATH : self::CUSTOM_HTML_PATH;
if (!File::exists(public_path('storage/custom-code'))) {
File::makeDirectory(public_path('storage/custom-code'));
}
File::put(public_path("storage/$path"), trim($value));
}
}
private function syncThemes(array $themes): void
{
$dbThemes = CssTheme::get();
// delete themes that were removed in appearance editor
$dbThemes->each(function (CssTheme $theme) use ($themes) {
if (
!Arr::first(
$themes,
fn($current) => $current['id'] === $theme['id'],
)
) {
$theme->delete();
}
});
// update changed themes and create new ones
foreach ($themes as $theme) {
$existing = $dbThemes->find($theme['id']);
$newValue = Arr::except($theme, ['id', 'updated_at']);
if (!$existing) {
CssTheme::create(
array_merge($newValue, ['user_id' => Auth::id()]),
);
} else {
$existing->fill($newValue)->save();
}
}
}
}

View File

@@ -0,0 +1,127 @@
<?php namespace Common\Admin\Appearance;
use Common\Admin\Appearance\Themes\CssTheme;
use Common\Core\Prerender\MetaTags;
use Common\Settings\Settings;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
class AppearanceValues
{
/**
* ENV values to include.
*/
const ENV_KEYS = ['app_name'];
public function get(): array
{
// split values into db settings and appearance specific settings, to avoid naming collisions
$values = [
'settings' => app(Settings::class)->getUnflattened(),
'appearance' => [],
];
// add env settings
$values['appearance']['env'] = [];
foreach (self::ENV_KEYS as $key) {
$values['appearance']['env'][$key] = config(
str_replace('_', '.', $key),
);
}
$values['appearance']['themes'] = [
'all' => CssTheme::get(),
'selectedThemeId' => null,
];
// add custom code
$values['appearance']['custom_code'] = [
'css' => $this->getCustomCodeValue(
AppearanceSaver::CUSTOM_CSS_PATH,
),
'html' => $this->getCustomCodeValue(
AppearanceSaver::CUSTOM_HTML_PATH,
),
];
$values['appearance']['seo'] = $this->prepareSeoValues();
$defaults = [];
$defaultSettings = collect(config('common.default-settings'))
->mapWithKeys(fn($item) => [$item['name'] => $item['value']])
->toArray();
$defaults['settings'] = settings()->getUnflattened(
false,
$defaultSettings,
);
$defaults['appearance']['themes'] = config('common.themes');
return [
'values' => $values,
'defaults' => $defaults,
];
}
private function prepareSeoValues(): array
{
$flat = [];
$seoConfig = config('seo');
if (!$seoConfig) {
return [];
}
$seo = Arr::except($seoConfig, 'common');
$seo = array_filter($seo, function ($config) {
return is_array($config);
});
// resource groups meta tags for artist, movie, track etc.
foreach ($seo as $resourceName => $resource) {
// resource has config for each verb (show, index etc.)
foreach ($resource as $verbName => $verb) {
// verb has a list of meta tags (og:title, description etc.)
if (is_array($verb)) {
foreach ($verb as $metaTag) {
$property = Arr::get($metaTag, 'property');
if (!in_array($property, MetaTags::EDITABLE_TAGS)) {
continue;
}
$name = str_replace(
'og:',
'',
"$resourceName / $verbName / $property",
);
$name = str_replace('-', ' ', $name);
$key = "seo.$resourceName.$verbName.$property";
$defaultValue = $metaTag['content'];
$flat[] = [
'name' => $name,
'key' => $key,
'value' => app(Settings::class)->get(
$key,
$defaultValue,
),
'defaultValue' => $defaultValue,
];
}
}
}
}
return $flat;
}
private function getCustomCodeValue($path): string
{
try {
return File::get(public_path("storage/$path"));
} catch (Exception $e) {
return '';
}
}
}

View File

@@ -0,0 +1,32 @@
<?php namespace Common\Admin\Appearance\Controllers;
use Common\Admin\Appearance\AppearanceSaver;
use Common\Admin\Appearance\AppearanceValues;
use Common\Core\BaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AppearanceController extends BaseController
{
public function __construct(
protected Request $request,
protected AppearanceValues $values,
protected AppearanceSaver $saver,
) {
}
public function save()
{
$this->authorize('update', 'AppearancePolicy');
$this->saver->save($this->request->get('changes'));
return $this->success();
}
public function getValues(): JsonResponse
{
$this->authorize('update', 'AppearancePolicy');
return $this->success($this->values->get());
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Common\Admin\Appearance\Controllers;
use Common\Core\BaseController;
use Exception;
use Illuminate\Support\Facades\File;
class SeoTagsController extends BaseController
{
public function show(string $names)
{
$this->authorize('update', 'AppearancePolicy');
$names = explode(',', $names);
$response = [];
foreach ($names as $name) {
try {
$customView = storage_path(
"app/editable-views/seo-tags/$name.blade.php",
);
$response[$name] = [
'custom' => file_exists($customView)
? file_get_contents($customView)
: null,
'original' => file_get_contents(
resource_path("views/seo/$name/seo-tags.blade.php"),
),
];
} catch (Exception $e) {
//
}
}
return $this->success($response);
}
public function update(string $name)
{
$this->authorize('update', 'AppearancePolicy');
$data = $this->validate(request(), [
'tags' => 'required|string',
]);
$directory = storage_path('app/editable-views/seo-tags');
File::ensureDirectoryExists($directory);
file_put_contents("$directory/$name.blade.php", $data['tags']);
return $this->success();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Common\Admin\Appearance\Events;
class AppearanceSettingSaved
{
/**
* @var string
*/
public $type;
/**
* @var string
*/
public $key;
/**
* @var string
*/
public $value;
/**
* @var string
*/
public $previousValue;
public function __construct(
string $type,
string $key,
string $value,
string $previousValue = null
) {
//
$this->type = $type;
$this->key = $key;
$this->value = $value;
$this->previousValue = $previousValue;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Common\Admin\Appearance;
use Common\Settings\Settings;
use Illuminate\Support\Facades\File;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class GenerateFavicon
{
const FAVICON_DIR = 'favicon';
protected string $absoluteFaviconDir;
protected array $sizes = [
[72, 72],
[96, 96],
[128, 128],
[144, 144],
[152, 152],
[192, 192],
[384, 384],
[512, 512],
];
protected string $initialFilePath;
public function __construct()
{
$this->absoluteFaviconDir = public_path(self::FAVICON_DIR);
}
public function execute(string $filePath): void
{
if (str_starts_with($filePath, 'http') || !file_exists($filePath)) {
return;
}
File::ensureDirectoryExists($this->absoluteFaviconDir);
$this->initialFilePath = $filePath;
foreach ($this->sizes as $size) {
$this->generateFaviconForSize($size);
}
$this->generateFaviconForSize([16, 16], public_path(), 'favicon.ico');
$uri = self::FAVICON_DIR . '/icon-144x144.png?v=' . time();
app(Settings::class)->save(['branding.favicon' => $uri]);
}
private function generateFaviconForSize(
array $size,
string $dir = null,
string $name = null,
): void {
$manager = new ImageManager(new Driver());
$img = $manager->read($this->initialFilePath);
$img->coverDown($size[0], $size[1]);
$dir = $dir ?? $this->absoluteFaviconDir;
$name = $name ?? "icon-$size[0]x$size[1].png";
$img->toPng()->save("$dir/$name");
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Common\Admin\Appearance\Themes;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
class CrupdateCssTheme
{
public function execute(array $data, CssTheme $cssTheme = null): ?CssTheme
{
if (!$cssTheme) {
$cssTheme = CssTheme::newInstance([
'user_id' => Auth::id(),
'values' => $data['is_dark']
? config('common.themes.dark')
: config('common.themes.light'),
]);
}
$attributes = Arr::only($data, [
'name',
'is_dark',
'default_dark',
'default_light',
'values',
]);
$cssTheme->fill($attributes)->save();
return $cssTheme;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Common\Admin\Appearance\Themes;
use Auth;
use Common\Core\BaseFormRequest;
use Illuminate\Validation\Rule;
class CrupdateCssThemeRequest extends BaseFormRequest
{
public function rules(): array
{
$required = $this->getMethod() === 'POST' ? 'required' : '';
$ignore = $this->getMethod() === 'PUT' ? $this->route('css_theme')->id : '';
$userId = $this->route('css_theme') ? $this->route('css_theme')->user_id : Auth::id();
return [
'name' => [
$required, 'string', 'min:3',
Rule::unique('css_themes')->where('user_id', $userId)->ignore($ignore)
],
'is_dark' => 'boolean',
'default_dark' => 'boolean',
'default_light' => 'boolean',
'colors' => 'array',
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Common\Admin\Appearance\Themes;
use Illuminate\Database\Eloquent\Model;
class CssTheme extends Model
{
protected $guarded = ['id'];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'is_dark' => 'boolean',
'default_dark' => 'boolean',
'default_light' => 'boolean',
'font' => 'json',
];
const MODEL_TYPE = 'css_theme';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
public function setValuesAttribute($value)
{
if ($value && is_array($value)) {
$this->attributes['values'] = json_encode($value);
}
}
public function getValuesAttribute($value): array
{
if ($value && is_string($value)) {
return json_decode($value, true);
} else {
return [];
}
}
public function getCssVariables(): string
{
// don't decode from json
$values = $this->attributes['values'] ?? '';
$values = preg_replace('/"/', '', $values);
$values = preg_replace('/\\\/', '', $values);
$values = preg_replace('/[{}]/', '', $values);
$values = preg_replace('/, ?--/', ';--', $values);
if ($family = $this->getFontFamily()) {
$values .= ";--be-font-family: $family";
}
return $values;
}
public function getFontFamily(): string|null
{
return $this->font['family'] ?? null;
}
public function isGoogleFont(): bool
{
return $this->font['google'] ?? false;
}
public function getHtmlThemeColor()
{
if ($this->is_dark) {
return $this->values['--be-background-alt'];
} else {
return $this->values['--be-primary'];
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Common\Admin\Appearance\Themes;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Illuminate\Http\Request;
class CssThemeController extends BaseController
{
/**
* @var CssTheme
*/
private $cssTheme;
/**
* @var Request
*/
private $request;
public function __construct(CssTheme $cssTheme, Request $request)
{
$this->cssTheme = $cssTheme;
$this->request = $request;
}
public function index()
{
$userId = $this->request->get('userId');
$this->authorize('index', [CssTheme::class, $userId]);
$builder = $this->cssTheme->newQuery();
if ($userId) {
$builder->where('user_id', $userId);
}
$dataSource = new Datasource($this->cssTheme, $this->request->all());
$pagination = $dataSource->paginate();
return $this->success(['pagination' => $pagination]);
}
public function show(CssTheme $cssTheme)
{
$this->authorize('show', $cssTheme);
return $this->success(['theme' => $cssTheme]);
}
public function store(CrupdateCssThemeRequest $request)
{
$this->authorize('store', CssTheme::class);
$cssTheme = app(CrupdateCssTheme::class)->execute($request->all());
return $this->success(['theme' => $cssTheme]);
}
public function update(CssTheme $cssTheme, CrupdateCssThemeRequest $request)
{
$this->authorize('store', $cssTheme);
$cssTheme = app(CrupdateCssTheme::class)->execute($request->all(), $cssTheme);
return $this->success(['theme' => $cssTheme]);
}
public function destroy(CssTheme $cssTheme)
{
$this->authorize('destroy', $cssTheme);
$cssTheme->delete();
return $this->success();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Common\Admin\Appearance\Themes;
use Common\Auth\BaseUser;
use Common\Core\Policies\BasePolicy;
class CssThemePolicy extends BasePolicy
{
public function index(BaseUser $user, $userId = null)
{
return $user->hasPermission('cssTheme.view') || $user->id === (int) $userId;
}
public function show(BaseUser $user, CssTheme $cssTheme)
{
return $user->hasPermission('cssTheme.view') || $cssTheme->user_id === $user->id;
}
public function store(BaseUser $user)
{
return $user->hasPermission('cssTheme.create');
}
public function update(BaseUser $user, CssTheme $cssTheme)
{
return $user->hasPermission('cssTheme.update') || $cssTheme->user_id === $user->id;
}
public function destroy(BaseUser $user, CssTheme $theme)
{
if ($theme->default_dark && app(CssTheme::class)->where('default_dark', true)->count() < 2) {
return $this->deny("Default dark theme can't be deleted");
}
if ($theme->default_light && app(CssTheme::class)->where('default_light', true)->count() < 2) {
return $this->deny("Default light theme can't be deleted");
}
if (app(CssTheme::class)->count() <= 1) {
return $this->deny("All themes can't be deleted");
}
return $user->hasPermission('cssTheme.delete') || $theme->user_id === $user->id;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Common\Admin;
use Artisan;
use Cache;
use Common\Core\BaseController;
use Common\Settings\Setting;
use Illuminate\Http\Request;
class CacheController extends BaseController
{
/**
* @var Request
*/
private $request;
/**
* @param Request $request
*/
public function __construct(Request $request)
{
$this->request = $request;
$this->middleware('isAdmin');
}
public function flush()
{
Cache::flush();
return $this->success();
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Common\Admin;
use App\Models\User;
use Common\Core\BaseController;
use Illuminate\Contracts\Auth\StatefulGuard;
use Laravel\Fortify\LoginRateLimiter;
class ImpersonateUserController extends BaseController
{
public function __construct(
protected StatefulGuard $guard,
protected LoginRateLimiter $limiter,
) {
$this->middleware('isAdmin');
}
public function impersonate(int $userId)
{
$impersonated = User::findOrFail($userId);
if ($impersonated->id === $this->guard->id()) {
return $this->error(__('You are already logged in as this user.'));
}
$impersonatorId = $this->guard->id();
$this->logout();
$this->guard->login($impersonated, true);
request()
->session()
->regenerate();
$this->limiter->clear(request());
session()->put('impersonator_id', $impersonatorId);
return $this->success([
'user' => $impersonated,
]);
}
public function stopImpersonating()
{
$this->logout();
session()->forget('impersonator_id');
return $this->success();
}
protected function logout(): void
{
$this->guard->logout();
if (request()->hasSession()) {
request()
->session()
->invalidate();
request()
->session()
->regenerateToken();
}
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace Common\Admin\Sitemap;
use Carbon\Carbon;
use Common\Core\Contracts\AppUrlGenerator;
use Common\Pages\CustomPage;
use File;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class BaseSitemapGenerator
{
protected int $queryChunkSize = 1000;
protected string $currentDateTimeString;
protected int $currentResourceSitemapCount = 0;
protected string | null $currentXml = null;
protected int $currentLineCount = 0;
public function __construct()
{
@ini_set('memory_limit', '160M');
@ini_set('max_execution_time', 7200);
$this->currentDateTimeString = Carbon::now()->toDateTimeString();
}
protected function getAppQueries(): array
{
return [];
}
protected function getAppStaticUrls(): array
{
return [];
}
public function generate(): void
{
$index = [];
$queries = array_merge(
[
app(CustomPage::class)->select(['id', 'title', 'slug']),
],
$this->getAppQueries(),
);
foreach ($queries as $query) {
$resourceName = str_replace(
'_',
'-',
$query->getModel()->getTable(),
);
$index[$resourceName] = $this->createSitemapForResource(
$query,
$resourceName,
);
}
$this->makeStaticMap();
$this->makeIndex($index);
}
protected function createSitemapForResource(
Builder $model,
string $name
): int {
$model
->orderBy('id')
->chunk($this->queryChunkSize, function ($records) use ($name) {
$modelName = ucfirst(Str::snake($records->first()::MODEL_TYPE));
$methodName = "add{$modelName}Line";
foreach ($records as $record) {
// allow extending class to override line adding for specific model
if (method_exists($this, $methodName)) {
$this->$methodName(
$this->getModelUrl($record),
$this->getModelUpdatedAt($record),
$name,
);
} else {
$this->addNewLine(
$this->getModelUrl($record),
$this->getModelUpdatedAt($record),
$name,
);
}
}
});
if ($this->currentLineCount) {
$this->save("$name-sitemap-{$this->currentResourceSitemapCount}");
}
$numberOfSitemapsGenerated = $this->currentResourceSitemapCount;
$this->currentResourceSitemapCount = 0;
$this->currentLineCount = 0;
return $numberOfSitemapsGenerated;
}
protected function addNewLine(string $url, string $updatedAt, string $name)
{
if (!$this->currentXml) {
$this->startNewXmlFile();
}
if ($this->currentLineCount === 50000) {
$this->save("$name-sitemap-{$this->currentResourceSitemapCount}");
$this->startNewXmlFile();
}
$updatedAt = $this->formatDate($updatedAt);
$line =
"\t" .
"<url>\n\t\t<loc>" .
htmlspecialchars($url) .
"</loc>\n\t\t<lastmod>" .
$updatedAt .
"</lastmod>\n\t\t<changefreq>weekly</changefreq>\n\t\t<priority>1.00</priority>\n\t</url>\n";
$this->currentXml .= $line;
$this->currentLineCount++;
}
protected function save(string $fileName)
{
$this->currentXml .= "\n</urlset>";
File::ensureDirectoryExists(public_path('storage/sitemaps'));
File::put(
public_path("storage/sitemaps/$fileName.xml"),
$this->currentXml,
);
$this->currentXml = null;
$this->currentLineCount = 0;
$this->currentResourceSitemapCount++;
}
protected function startNewXmlFile()
{
$this->currentXml =
'<?xml version="1.0" encoding="UTF-8"?>' .
"\n" .
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' .
"\n";
}
protected function makeStaticMap(): void
{
$urls = array_merge(
['', 'login', 'register', 'contact'],
$this->getAppStaticUrls(),
);
$urls = array_map(function ($data) {
if (is_string($data)) {
return [
'path' => $data,
'updated_at' => $this->currentDateTimeString,
];
} else {
return $data;
}
}, $urls);
foreach ($urls as $url) {
$this->addNewLine(
url($url['path']),
$url['updated_at'],
'static-urls',
);
}
$this->save('static-urls-sitemap');
}
protected function makeIndex(array $index): void
{
$baseUrl = url('storage/sitemaps');
$string =
'<?xml version="1.0" encoding="UTF-8"?>' .
"\n" .
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' .
"\n";
foreach ($index as $resourceName => $resourceSitemapCount) {
// sitemap count starts from 1
for ($i = 0; $i <= $resourceSitemapCount - 1; $i++) {
$url = "{$baseUrl}/{$resourceName}-sitemap-$i.xml";
$string .=
"\t<sitemap>\n" .
"\t\t<loc>$url</loc>\n" .
"\t\t<lastmod>{$this->formatDate()}</lastmod>\n" .
"\t</sitemap>\n";
}
}
$string .= "\t<sitemap>\n\t\t<loc>{$baseUrl}/static-urls-sitemap.xml</loc>\n\t\t<lastmod>{$this->formatDate()}</lastmod>\n\t</sitemap>\n";
$string .= '</sitemapindex>';
File::put(public_path('storage/sitemaps/sitemap-index.xml'), $string);
}
protected function getModelUrl(Model $model): string
{
$resourceName = Str::camel(class_basename($model));
return app(AppUrlGenerator::class)->$resourceName($model);
}
protected function formatDate(string $date = null): string
{
if (!$date) {
$date = $this->currentDateTimeString;
}
return date('Y-m-d\TH:i:sP', strtotime($date));
}
protected function getModelUpdatedAt(Model $model): string
{
return !$model->updated_at ||
$model->updated_at === '0000-00-00 00:00:00'
? $this->currentDateTimeString
: $model->updated_at;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Common\Admin\Sitemap;
use Common\Core\BaseController;
use Illuminate\Http\JsonResponse;
class SitemapController extends BaseController
{
public function __construct()
{
$this->middleware('isAdmin');
}
public function generate(): JsonResponse
{
$sitemap = class_exists('App\Services\SitemapGenerator')
? app('App\Services\SitemapGenerator')
: app(BaseSitemapGenerator::class);
$sitemap->generate();
return $this->success([]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Common\Auth\Actions;
use App\Models\User;
use Common\Auth\Events\UserCreated;
use Common\Auth\Permissions\Traits\SyncsPermissions;
use Common\Auth\Roles\Role;
use Illuminate\Support\Arr;
class CreateUser
{
use SyncsPermissions;
public function execute(array $params): User
{
if (
!settings('require_email_confirmation') &&
!array_key_exists('email_verified_at', $params)
) {
$params['email_verified_at'] = now();
}
$geoData = geoip(getIp());
$params['language'] = $params['language'] ?? config('app.locale');
$params['country'] =
$params['country'] ?? ($geoData['iso_code'] ?? null);
$params['timezone'] =
$params['timezone'] ?? ($geoData['timezone'] ?? null);
$user = User::create(Arr::except($params, ['roles', 'permissions']));
if (array_key_exists('roles', $params)) {
$user->roles()->attach($params['roles']);
}
// if no roles were attached, assign default role
if ($user->roles()->count() === 0) {
$defaultRole = app(Role::class)->getDefaultRole();
if ($defaultRole) {
$user->roles()->attach($defaultRole->id);
}
}
if (array_key_exists('permissions', $params)) {
$this->syncPermissions($user, $params['permissions']);
}
event(new UserCreated($user, $params));
return $user;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Common\Auth\Actions;
use App\Models\User;
use Common\Auth\ActiveSession;
use Common\Auth\Ban;
use Common\Auth\Events\UsersDeleted;
use Common\Billing\Subscription;
use Common\Csv\CsvExport;
use Common\Domains\Actions\DeleteCustomDomains;
use Common\Domains\CustomDomain;
use Common\Files\Actions\Deletion\PermanentlyDeleteEntries;
use Common\Pages\CustomPage;
class DeleteUsers
{
public function execute(array $ids): int
{
$users = User::whereIn('id', $ids)->get();
$users->each(function (User $user) {
$user->social_profiles()->delete();
$user->roles()->detach();
$user->notifications()->delete();
$user->permissions()->detach();
if ($user->subscribed()) {
$user->subscriptions->each(function (
Subscription $subscription,
) {
$subscription->cancelAndDelete();
});
}
$user->delete();
$entryIds = $user
->entries(['owner' => true])
->pluck('file_entries.id');
app(PermanentlyDeleteEntries::class)->execute($entryIds);
});
// delete domains
$domainIds = app(CustomDomain::class)
->whereIn('user_id', $ids)
->pluck('id');
app(DeleteCustomDomains::class)->execute($domainIds->toArray());
// delete custom pages
CustomPage::whereIn('user_id', $ids)->delete();
// delete sessions
ActiveSession::whereIn('user_id', $ids)->delete();
// csv exports
CsvExport::whereIn('user_id', $ids)->delete();
// bans
Ban::where('bannable_type', User::MODEL_TYPE)
->whereIn('bannable_id', $ids)
->delete();
event(new UsersDeleted($users));
return $users->count();
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Common\Auth\Actions;
use App\Models\User;
use Common\Database\Datasource\Datasource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
class PaginateUsers
{
public function execute(array $params): AbstractPaginator
{
$query = User::with(['roles', 'permissions']);
if ($roleId = Arr::get($params, 'roleId')) {
$relation = $query->getModel()->roles();
$query
->leftJoin(
$relation->getTable(),
$relation->getQualifiedParentKeyName(),
'=',
$relation->getQualifiedForeignPivotKeyName(),
)
->where(
$relation->getQualifiedRelatedPivotKeyName(),
'=',
$roleId,
);
$query->select(['users.*', 'user_role.created_at as created_at']);
}
if ($roleName = Arr::get($params, 'roleName')) {
$query->whereHas(
'roles',
fn(Builder $q) => $q->where('roles.name', $roleName),
);
}
if ($permission = Arr::get($params, 'permission')) {
$query
->whereHas(
'permissions',
fn(Builder $query) => $query
->where('name', $permission)
->orWhere('name', 'admin'),
)
->orWhereHas(
'roles',
fn(Builder $query) => $query->whereHas(
'permissions',
fn(Builder $query) => $query
->where('name', $permission)
->orWhere('name', 'admin'),
),
);
}
return (new Datasource($query, $params))->paginate();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Common\Auth\Actions;
use App\Models\User;
use Common\Auth\Permissions\Traits\SyncsPermissions;
use Illuminate\Support\Arr;
class UpdateUser
{
use SyncsPermissions;
public function execute(User $user, array $params): User
{
$user->fill(Arr::except($params, ['roles', 'permissions']))->save();
// make sure roles and permission are not removed
// if they are not specified at all in params
if (array_key_exists('roles', $params)) {
$user->roles()->sync($params['roles']);
}
if (array_key_exists('permissions', $params)) {
$this->syncPermissions($user, Arr::get($params, 'permissions'));
}
return $user->load(['roles', 'permissions']);
}
}

17
common/Auth/ActiveSession.php Executable file
View File

@@ -0,0 +1,17 @@
<?php
namespace Common\Auth;
use Illuminate\Database\Eloquent\Model;
class ActiveSession extends Model
{
protected $guarded = ['id'];
const MODEL_TYPE = 'active_session';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

32
common/Auth/Ban.php Executable file
View File

@@ -0,0 +1,32 @@
<?php
namespace Common\Auth;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Ban extends Model
{
protected $guarded = ['id'];
protected $casts = [
'expired_at' => 'datetime',
];
const MODEL_TYPE = 'ban';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
protected static function booted(): void
{
static::created(function (Ban $ban) {});
}
public function createdBy(): MorphTo
{
return $this->morphTo('created_by');
}
}

485
common/Auth/BaseUser.php Executable file
View File

@@ -0,0 +1,485 @@
<?php namespace Common\Auth;
use App\Models\User;
use Common\Auth\Notifications\VerifyEmailWithOtp;
use Common\Auth\Permissions\Permission;
use Common\Auth\Permissions\Traits\HasPermissionsRelation;
use Common\Auth\Roles\Role;
use Common\Auth\Traits\HasAvatarAttribute;
use Common\Auth\Traits\HasDisplayNameAttribute;
use Common\Billing\Billable;
use Common\Billing\Models\Product;
use Common\Core\BaseModel;
use Common\Files\FileEntry;
use Common\Files\FileEntryPivot;
use Common\Files\Traits\SetsAvailableSpaceAttribute;
use Common\Notifications\NotificationSubscription;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\MustVerifyEmail;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Scout\Searchable;
abstract class BaseUser extends BaseModel implements
HasLocalePreference,
AuthenticatableContract,
AuthorizableContract,
CanResetPasswordContract
{
use Searchable,
Notifiable,
Billable,
TwoFactorAuthenticatable,
SetsAvailableSpaceAttribute,
HasPermissionsRelation,
HasAvatarAttribute,
HasDisplayNameAttribute,
Authenticatable,
Authorizable,
CanResetPassword,
MustVerifyEmail;
const MODEL_TYPE = 'user';
protected $guarded = ['id'];
protected $hidden = [
'password',
'remember_token',
'pivot',
'legacy_permissions',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
];
protected $casts = [
'id' => 'integer',
'available_space' => 'integer',
'email_verified_at' => 'datetime',
'unread_notifications_count' => 'integer',
];
protected $appends = ['display_name', 'has_password', 'model_type'];
protected bool $billingEnabled = true;
protected $gravatarSize;
public function preferredLocale()
{
return $this->language;
}
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->billingEnabled = (bool) settings('billing.enable');
}
public function toArray(bool $showAll = false): array
{
if (
(!$showAll && !Auth::id()) ||
(Auth::id() !== $this->id &&
!Auth::user()?->hasPermission('users.update'))
) {
$this->hidden = array_merge($this->hidden, [
'first_name',
'last_name',
'avatar_url',
'gender',
'email',
'card_brand',
'has_password',
'confirmed',
'stripe_id',
'roles',
'permissions',
'card_last_four',
'created_at',
'updated_at',
'available_space',
'email_verified_at',
'timezone',
'confirmation_code',
'subscriptions',
]);
}
return parent::toArray();
}
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'user_role');
}
public function routeNotificationForSlack()
{
return config('services.slack.webhook_url');
}
public function scopeWhereNeedsNotificationFor(
Builder $query,
string $notifId,
) {
return $query->whereHas('notificationSubscriptions', function (
Builder $builder,
) use ($notifId) {
if (Str::contains($notifId, '*')) {
return $builder->where(
'notif_id',
'like',
str_replace('*', '%', $notifId),
);
} else {
return $builder->where('notif_id', $notifId);
}
});
}
public function notificationSubscriptions(): HasMany
{
return $this->hasMany(NotificationSubscription::class);
}
public function entries(array $options = ['owner' => true]): BelongsToMany
{
$query = $this->morphToMany(
FileEntry::class,
'model',
'file_entry_models',
'model_id',
'file_entry_id',
)
->using(FileEntryPivot::class)
->withPivot('owner', 'permissions');
if (Arr::get($options, 'owner')) {
$query->wherePivot('owner', true);
}
return $query
->withTimestamps()
->orderBy('file_entry_models.created_at', 'asc');
}
public function activeSessions(): HasMany
{
return $this->hasMany(ActiveSession::class);
}
public function lastLogin(): HasOne
{
return $this->hasOne(ActiveSession::class)
->latest()
->select(['id', 'user_id', 'session_id', 'created_at']);
}
public function followedUsers(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'follows',
'follower_id',
'followed_id',
)->compact();
}
public function followers(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'follows',
'followed_id',
'follower_id',
)->compact();
}
public function social_profiles(): HasMany
{
return $this->hasMany(SocialProfile::class);
}
public function bans(): MorphMany
{
return $this->morphMany(Ban::class, 'bannable');
}
/**
* Check if user has a password set.
*/
public function getHasPasswordAttribute(): bool
{
return isset($this->attributes['password']) &&
$this->attributes['password'];
}
protected function password(): Attribute
{
return Attribute::make(
set: function ($value) {
if (!$value) {
return null;
}
if (Hash::isHashed($value)) {
return $value;
}
return Hash::make($value);
},
);
}
protected function availableSpace(): Attribute
{
return Attribute::make(
set: fn($value) => !is_null($value) ? (int) $value : null,
);
}
protected function otpCodes(): HasMany
{
return $this->hasMany(OtpCode::class);
}
public function emailVerificationOtpIsValid(string $code): bool
{
$otp = $this->otpCodes()
->where('type', OtpCode::TYPE_EMAIL_VERIFICATION)
->first();
if (!$otp || $otp->code !== $code || $otp->isExpired()) {
return false;
}
return true;
}
protected function emailVerifiedAt(): Attribute
{
return Attribute::make(
set: function ($value) {
if ($value === true) {
return now();
} elseif ($value === false) {
return null;
}
return $value;
},
);
}
public function sendEmailVerificationNotification(): void
{
$otp = OtpCode::createForEmailVerification($this->id);
$this->notify(new VerifyEmailWithOtp($otp->code));
}
public function markEmailAsVerified(): bool
{
$this->otpCodes()
->where('type', OtpCode::TYPE_EMAIL_VERIFICATION)
->delete();
return $this->forceFill([
'email_verified_at' => $this->freshTimestamp(),
])->save();
}
public function loadPermissions($force = false): self
{
if (!$force && $this->relationLoaded('permissions')) {
return $this;
}
$query = Permission::join(
'permissionables',
'permissions.id',
'permissionables.permission_id',
);
// Might have a guest user. In this case user ID will be -1,
// but we still want to load guest role permissions below
if ($this->exists) {
$query->where([
'permissionable_id' => $this->id,
'permissionable_type' => $this->getMorphClass(),
]);
}
if ($this->roles->pluck('id')->isNotEmpty()) {
$query->orWhere(function (Builder $builder) {
return $builder
->whereIn('permissionable_id', $this->roles->pluck('id'))
->where(
'permissionable_type',
$this->roles->first()->getMorphClass(),
);
});
}
if ($this->exists && ($plan = $this->getSubscriptionProduct())) {
$query->orWhere(function (Builder $builder) use ($plan) {
return $builder
->where('permissionable_id', $plan->id)
->where('permissionable_type', $plan->getMorphClass());
});
}
$permissions = $query
->select([
'permissions.id',
'name',
'permissionables.restrictions',
'permissionable_type',
])
->get()
->sortBy(function ($value) {
if ($value['permissionable_type'] === $this->getMorphClass()) {
return 1;
} elseif (
$value['permissionable_type'] === Product::MODEL_TYPE
) {
return 2;
} else {
return 3;
}
})
->groupBy('id')
// merge restrictions from all permissions
->map(function (Collection $group) {
return $group->reduce(function (
Permission $carry,
Permission $permission,
) {
return $carry->mergeRestrictions($permission);
}, $group[0]);
});
$this->setRelation('permissions', $permissions->values());
return $this;
}
public function getSubscriptionProduct(): ?Product
{
if (!$this->billingEnabled) {
return null;
}
$subscription = $this->subscriptions->first();
if ($subscription && $subscription->valid()) {
return $subscription->product;
} else {
return Product::where('free', true)->first();
}
}
public function scopeCompact(Builder $query): Builder
{
return $query->select(
'users.id',
'users.avatar',
'users.email',
'users.first_name',
'users.last_name',
'users.username',
);
}
public function sendPasswordResetNotification(mixed $token)
{
ResetPassword::$createUrlCallback = function ($user, $token) {
return url("password/reset/$token");
};
$this->notify(new ResetPassword($token));
}
public static function findAdmin(): ?self
{
return (new static())
->newQuery()
->whereHas('permissions', function (Builder $query) {
$query->where('name', 'admin');
})
->first();
}
public function refreshApiToken($tokenName): string
{
$this->tokens()
->where('name', $tokenName)
->delete();
$newToken = $this->createToken($tokenName);
$this->withAccessToken($newToken->accessToken);
return $newToken->plainTextToken;
}
public function isBanned(): bool
{
if (!$this->getAttributeValue('banned_at')) {
return false;
}
$bannedUntil = $this->bans->first()->expired_at;
return !$bannedUntil || $bannedUntil->isFuture();
}
public function resolveRouteBinding($value, $field = null): ?self
{
if ($value === 'me') {
$value = Auth::id();
}
return $this->where('id', $value)->firstOrFail();
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'username' => $this->username,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at'];
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->display_name,
'description' => $this->email,
'image' => $this->avatar,
'model_type' => self::MODEL_TYPE,
];
}
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Common\Auth\Commands;
use Common\Auth\Ban;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class DeleteExpiredBansCommand extends Command
{
protected $signature = 'bans:deleteExpired';
protected $description = 'Unban users whose ban date has expired.';
public function handle(): int
{
$bans = Ban::query()
->where('expired_at', '<=', Carbon::now()->format('Y-m-d H:i:s'))
->get();
$bans->each(function ($ban) {
$ban->created_by->fill(['banned_at' => null])->save();
$ban->delete();
});
$this->info("Unbanned {$bans->count()} users.");
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Common\Auth\Commands;
use Common\Auth\OtpCode;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class DeleteExpiredOtpCodesCommand extends Command
{
protected $signature = 'otp:deleteExpired';
protected $description = 'Delete one time passwords that have expired.';
public function handle(): int
{
OtpCode::query()
->where('expires_at', '<', Carbon::now())
->delete();
$this->info('Expired OTP codes have been deleted.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Common\Auth\Controllers;
use Auth;
use Common\Core\BaseController;
use Illuminate\Http\Request;
class AccessTokenController extends BaseController
{
public function __construct(protected Request $request)
{
$this->middleware(['auth']);
}
public function store()
{
$this->validate($this->request, [
'tokenName' => 'required|string|min:3|max:100',
]);
$token = Auth::user()->createToken($this->request->get('tokenName'));
return $this->success([
'token' => $token->accessToken,
'plainTextToken' => $token->plainTextToken,
]);
}
public function destroy(string $tokenId)
{
Auth::user()
->tokens()
->where('id', $tokenId)
->delete();
return $this->success();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Common\Auth\Controllers;
use App\Models\User;
use Common\Core\BaseController;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
class BanController extends BaseController
{
public function store(User $user)
{
$this->authorize('destroy', [User::class, [$user->id]]);
if ($user->hasPermission('admin')) {
abort(403, 'Admin users can\'t be suspended');
}
$data = $this->validate(request(), [
'ban_until' => 'nullable|date|after:now',
'comment' => 'nullable|string|max:255',
'permanent' => 'boolean',
]);
$user->bans()->create([
'expired_at' => $data['permanent']
? null
: Arr::get($data, 'ban_until'),
'comment' => Arr::get($data, 'comment'),
'created_by_type' => User::MODEL_TYPE,
'created_by_id' => Auth::id(),
]);
$user->fill(['banned_at' => now()])->save();
return $this->success(['user' => $user]);
}
public function destroy(User $user)
{
$this->authorize('destroy', [User::class, [$user->id]]);
$user->bans()->delete();
$user->fill(['banned_at' => null])->save();
return $this->success(['user' => $user]);
}
}

View File

@@ -0,0 +1,45 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Auth;
use Common\Core\BaseController;
class EmailVerificationController extends BaseController
{
public function __construct()
{
$this->middleware('auth');
}
public function validateOtp()
{
$code = request('code');
$user = Auth::user();
if (!$code || !$user->emailVerificationOtpIsValid($code)) {
$msg = __(
'The security code you entered is invalid or has expired',
);
return $this->error($msg, [
'code' => $msg,
]);
}
$user->markEmailAsVerified();
return $this->success();
}
public function resendVerificationEmail()
{
$data = $this->validate(request(), ['email' => 'required|email']);
$user = User::where('email', $data['email'])->firstOrFail();
$this->authorize('update', $user);
$user->sendEmailVerificationNotification();
return $this->success();
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Common\Auth\Controllers;
use Common\Auth\Fortify\ValidateLoginCredentials;
use Common\Core\BaseController;
use Common\Core\Bootstrap\MobileBootstrapData;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Fortify\Contracts\EmailVerificationNotificationSentResponse;
use Laravel\Fortify\Contracts\RegisterResponse;
use Laravel\Fortify\Fortify;
class MobileAuthController extends BaseController
{
public function login(Request $request)
{
$this->validate($request, [
Fortify::username() => 'required|string',
'password' => 'required|string',
'token_name' => 'required|string|min:3|max:100',
]);
$validator = app(ValidateLoginCredentials::class);
$user = $validator->execute($request);
if (!$user) {
$validator->throwFailedAuthenticationException(
$request,
trans('auth.failed'),
);
}
if (settings('single_device_login')) {
Auth::logoutOtherDevices($request->get('password'));
}
Auth::login($user);
$bootstrapData = app(MobileBootstrapData::class)
->init()
->refreshToken($request->get('token_name'))
->get();
return $this->success($bootstrapData);
}
public function register(
Request $request,
CreatesNewUsers $creator,
): RegisterResponse {
event(new Registered(($user = $creator->create($request->all()))));
Auth::login($user);
return app(RegisterResponse::class);
}
public function sendEmailVerificationNotification()
{
$this->middleware('auth');
if (
request()
->user()
->hasVerifiedEmail()
) {
return request()->wantsJson()
? new JsonResponse('', 204)
: redirect()->intended(
Fortify::redirects('email-verification'),
);
}
request()
->user()
->sendEmailVerificationNotification();
return app(EmailVerificationNotificationSentResponse::class);
}
}

View File

@@ -0,0 +1,166 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Common\Auth\Oauth;
use Common\Core\BaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
class SocialAuthController extends BaseController
{
public function __construct(protected Oauth $oauth)
{
$this->middleware('auth', [
'only' => ['connect', 'disconnect'],
]);
$this->middleware('guest', [
'only' => ['login'],
]);
}
/**
* Connect specified social account to currently logged-in user.
*/
public function connect(string $provider)
{
if (!settings("social.$provider.enable")) {
abort(403);
}
return $this->oauth->redirect($provider);
}
/**
* Handles case where user is trying to log in with social account whose email
* already exists in database. Request password for local account in that case.
*/
public function connectWithPassword(): JsonResponse
{
// get data for this social login persisted in session
$data = $this->oauth->getPersistedData();
if (!$data) {
return $this->error(__('There was an issue. Please try again.'));
}
if (
!request()->has('password') ||
!Auth::validate([
'email' => $data['profile']->email,
'password' => request('password'),
])
) {
return $this->error(__('Specified credentials are not valid'), [
'password' => __('This password is not correct.'),
]);
}
return $this->success($this->oauth->createUserFromOAuthData($data));
}
public function retrieveProfile(string $providerName)
{
return $this->oauth->retrieveProfileOnly($providerName);
}
/**
* Disconnect specified social account from currently logged-in user.
*/
public function disconnect(string $provider)
{
$this->oauth->disconnect($provider);
return $this->success();
}
/**
* Login with specified social provider.
*/
public function login(string $provider)
{
if (!settings("social.$provider.enable")) {
abort(403);
}
return $this->oauth->loginWith($provider);
}
public function loginCallback(string $provider)
{
if ($handler = Session::get(Oauth::OAUTH_CALLBACK_HANDLER_KEY)) {
return app($handler)->execute($provider);
}
$externalProfile = null;
try {
$externalProfile = $this->oauth->socializeWith(
$provider,
request('tokenFromApi'),
request('secretFromApi'),
);
} catch (Exception $e) {
Log::error($e);
}
if (!$externalProfile) {
return $this->oauth->getErrorResponse(
__('Could not retrieve social sign in account.'),
);
}
// TODO: use new "OAUTH_CALLBACK_HANDLER_KEY" functionality to handle this, remove "tokenFromApi" stuff from this handler
if (Session::get(Oauth::RETRIEVE_PROFILE_ONLY_KEY)) {
Session::forget(Oauth::RETRIEVE_PROFILE_ONLY_KEY);
return $this->oauth->returnProfileData($externalProfile);
}
$existingProfile = $this->oauth->getExistingProfile($externalProfile);
// if user is already logged in, attach returned social account to logged-in user
if (Auth::check()) {
return $this->oauth->attachProfileToExistingUser(
Auth::user(),
$externalProfile,
$provider,
);
}
// if we have already created a user for this social account, log user in
if ($existingProfile?->user) {
$this->oauth->updateSocialProfileData(
$existingProfile,
$provider,
$externalProfile,
);
return $this->oauth->logUserIn($existingProfile->user, $provider);
}
// if user is trying to log in with envato and does not have any valid purchases, bail
if (
$provider === 'envato' &&
empty($externalProfile->user['purchases'])
) {
return $this->oauth->getErrorResponse(
'You do not have any supported purchases.',
);
}
// need to request password from user in order to connect accounts
$user = User::where('email', $externalProfile->email)->first();
if ($user?->password) {
$this->oauth->persistSocialProfileData([
'service' => $provider,
'profile' => $externalProfile,
]);
return $this->oauth->getPopupResponse('REQUEST_PASSWORD');
}
// if we have email and didn't create an account for this profile yet, do it now
return $this->oauth->createUserFromOAuthData([
'profile' => $externalProfile,
'service' => $provider,
]);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Common\Auth\Controllers;
use Common\Core\BaseController;
use Illuminate\Support\Facades\Auth;
class TwoFactorQrCodeController extends BaseController
{
public function show()
{
return $this->success([
'svg' => Auth::user()->twoFactorQrCodeSvg(),
'secret' => decrypt(Auth::user()->two_factor_secret),
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Common\Auth\Events\UserAvatarChanged;
use Common\Core\BaseController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class UserAvatarController extends BaseController
{
public function __construct(
protected Request $request,
protected User $user,
) {
}
public function store(User $user)
{
$this->authorize('update', $user);
$this->validate($this->request, [
'file' => 'required_without:url|image|max:1500',
'url' => 'required_without:file|string|max:250',
]);
// delete old user avatar
if ($user->getRawOriginal('avatar')) {
Storage::disk('public')->delete($user->getRawOriginal('avatar'));
}
// store new avatar on public disk
$path =
$this->request->get('url') ??
$this->request
->file('file')
->storePublicly('avatars', ['disk' => 'public']);
// attach avatar to user model
$user->avatar = $path;
$user->save();
event(new UserAvatarChanged($user));
return $this->success(['user' => $user]);
}
public function destroy(User $user)
{
$this->authorize('update', $user);
if ($user->getRawOriginal('avatar')) {
Storage::disk('public')->delete($user->getRawOriginal('avatar'));
}
$user->avatar = null;
$user->save();
event(new UserAvatarChanged($user));
return $this->success();
}
}

View File

@@ -0,0 +1,105 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Auth;
use Common\Auth\Actions\CreateUser;
use Common\Auth\Actions\DeleteUsers;
use Common\Auth\Actions\PaginateUsers;
use Common\Auth\Actions\UpdateUser;
use Common\Auth\Requests\CrupdateUserRequest;
use Common\Core\BaseController;
class UserController extends BaseController
{
public function __construct()
{
$this->middleware('auth', ['except' => ['show']]);
}
public function index()
{
$this->authorize('index', User::class);
$pagination = (new PaginateUsers())->execute(request()->all());
return $this->success(['pagination' => $pagination]);
}
public function show(User $user)
{
$relations = array_filter(explode(',', request('with', '')));
$relations = array_merge(['roles', 'social_profiles'], $relations);
if (settings('envato.enable')) {
$relations[] = 'purchase_codes';
}
if (Auth::id() === $user->id) {
$relations[] = 'tokens';
$user->makeVisible([
'two_factor_confirmed_at',
'two_factor_recovery_codes',
]);
if ($user->two_factor_confirmed_at) {
$user->two_factor_recovery_codes = $user->recoveryCodes();
$user->syncOriginal();
}
}
$user->load($relations);
$this->authorize('show', $user);
return $this->success(['user' => $user]);
}
public function store(CrupdateUserRequest $request)
{
$this->authorize('store', User::class);
$user = (new CreateUser())->execute($request->validated());
return $this->success(['user' => $user], 201);
}
public function update(User $user, CrupdateUserRequest $request)
{
$this->authorize('update', $user);
$user = (new UpdateUser())->execute($user, $request->validated());
return $this->success(['user' => $user]);
}
public function destroy(string $ids)
{
$userIds = explode(',', $ids);
$shouldDeleteCurrentUser = request('deleteCurrentUser');
$this->authorize('destroy', [User::class, $userIds]);
$users = User::whereIn('id', $userIds)->get();
// guard against current user or admin user deletion
foreach ($users as $user) {
if (!$shouldDeleteCurrentUser && $user->id === Auth::id()) {
return $this->error(
__('Could not delete currently logged in user: :email', [
'email' => $user->email,
]),
);
}
if ($user->hasPermission('admin')) {
return $this->error(
__('Could not delete admin user: :email', [
'email' => $user->email,
]),
);
}
}
(new DeleteUsers())->execute($users->pluck('id')->toArray());
return $this->success();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Common\Auth\Controllers;
use App\Models\User;
use Common\Core\BaseController;
class UserFollowedUsersController extends BaseController
{
public function index(User $user)
{
$this->authorize('show', $user);
$pagination = $user
->followedUsers()
->withCount(['followers'])
->paginate(request('perPage', 20));
return $this->success(['pagination' => $pagination]);
}
public function ids(User $user)
{
$this->authorize('show', $user);
$ids = $user->followedUsers()->pluck('id');
return $this->success(['ids' => $ids]);
}
}

View File

@@ -0,0 +1,47 @@
<?php namespace Common\Auth\Controllers;
use App\Models\User;
use Auth;
use Common\Core\BaseController;
class UserFollowersController extends BaseController
{
public function __construct()
{
$this->middleware('auth')->except(['index']);
}
public function index(User $user)
{
$this->authorize('show', $user);
$pagination = $user
->followers()
->withCount(['followers'])
->simplePaginate(request('perPage') ?? 20);
return $this->success(['pagination' => $pagination]);
}
public function follow(User $userToFollow)
{
if ($userToFollow->id !== Auth::user()->id) {
Auth::user()
->followedUsers()
->sync([$userToFollow->id], false);
}
return $this->success();
}
public function unfollow(User $userToFollow)
{
if ($userToFollow->id != Auth::user()->id) {
Auth::user()
->followedUsers()
->detach($userToFollow->id);
}
return $this->success();
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Common\Auth\Controllers;
use Common\Auth\ActiveSession;
use Common\Core\BaseController;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Support\Facades\Auth;
use Jenssegers\Agent\Agent;
class UserSessionsController extends BaseController
{
public function __construct()
{
$this->middleware('auth');
}
public function index()
{
$sessions = Auth::user()
->activeSessions()
->orderBy('updated_at', 'desc')
->limit(30)
->get()
->map(function (ActiveSession $session) {
$agent = new Agent(null, $session->user_agent);
$location = geoip($session->ip_address);
$isCurrentDevice = $session->session_id
? $session->session_id ===
request()
->session()
->getId()
: $session->token ===
Auth::user()->currentAccessToken()->token;
return [
'id' => $session->id,
'platform' => $agent->platform(),
'device_type' => $agent->deviceType(),
'browser' => $agent->browser(),
'country' => $location->country,
'city' => $location->city,
'ip_address' => config('common.site.demo')
? 'Hidden on demo site'
: $session->ip_address,
'is_current_device' => $isCurrentDevice,
'last_active' => $session->updated_at,
];
})
->values();
return $this->success(['sessions' => $sessions]);
}
public function LogoutOtherSessions(StatefulGuard $guard)
{
$data = $this->validate(request(), [
'password' => 'required',
]);
$guard->logoutOtherDevices($data['password']);
ActiveSession::where('user_id', $guard->id())
->whereNotNull('session_id')
->where(
'session_id',
'!=',
request()
->session()
->getId(),
)
->delete();
return $this->success();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Auth\Events;
use App\Models\User;
class SocialConnected
{
public function __construct(public User $user, public string $socialName)
{
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Auth\Events;
use App\Models\User;
class SocialLogin
{
public function __construct(public User $user, public string $socialName)
{
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Common\Auth\Events;
use App\Models\User;
class UserAvatarChanged
{
/**
* @var User
*/
public $user;
/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Auth\Events;
use App\Models\User;
class UserCreated
{
public function __construct(public User $user, public array $data)
{
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Common\Auth\Events;
use Illuminate\Database\Eloquent\Collection;
class UsersDeleted
{
public Collection $users;
public function __construct(Collection $users)
{
$this->users = $users;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Common\Auth\Fortify;
use App\Models\User;
use Closure;
use Common\Auth\Actions\CreateUser;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class FortifyRegisterUser implements CreatesNewUsers
{
use PasswordValidationRules;
public function create(array $input): User
{
if (settings('registration.disable')) {
abort(404);
}
$appRules = config('common.registration-rules') ?? [];
$commonRules = [
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
function (string $attribute, mixed $value, Closure $fail) {
if (!self::emailIsValid($value)) {
$fail(__('This domain is blacklisted.'));
}
},
],
'password' => $this->passwordRules(),
'token_name' => 'string|min:3|max:50',
];
foreach ($appRules as $key => $rules) {
$commonRules[$key] = array_map(function ($rule) {
if (str_contains($rule, '\\')) {
$namespace = "\\$rule";
return new $namespace();
}
return $rule;
}, $rules);
}
$data = Validator::make($input, $commonRules)->validate();
return (new CreateUser())->execute($data);
}
public static function emailIsValid(string $email): bool
{
$blacklistedDomains = explode(
',',
settings('auth.domain_blacklist', ''),
);
if ($blacklistedDomains) {
$domain = explode('@', $email)[1] ?? null;
if ($domain && in_array($domain, $blacklistedDomains)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Common\Auth\Fortify;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->instance(LoginResponseContract::class, new LoginResponse());
$this->app->instance(
TwoFactorLoginResponseContract::class,
new TwoFactorLoginResponse(),
);
$this->app->instance(
LogoutResponseContract::class,
new LogoutResponse(),
);
$this->app->instance(
RegisterResponseContract::class,
new RegisterResponse(),
);
}
public function boot()
{
Fortify::createUsersUsing(FortifyRegisterUser::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
RateLimiter::for('login', function (Request $request) {
$email = (string) $request->email;
return Limit::perMinute(5)->by($email . $request->ip());
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by(
$request->session()->get('login.id'),
);
});
Fortify::authenticateUsing(function (Request $request) {
return (new ValidateLoginCredentials())->execute($request);
});
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Common\Auth\Fortify;
use Common\Core\Bootstrap\BootstrapData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
class LoginResponse implements LoginResponseContract
{
public function toResponse($request): JsonResponse
{
if ($request->get('password') && settings('single_device_login')) {
Auth::logoutOtherDevices($request->get('password'));
}
$data = app(BootstrapData::class)
->init()
->getEncoded();
return response()->json([
'bootstrapData' => $data,
'two_factor' => false,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Common\Auth\Fortify;
use Common\Core\Bootstrap\BootstrapData;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
class LogoutResponse implements LogoutResponseContract
{
public function toResponse($request): JsonResponse
{
$data = app(BootstrapData::class)
->init()
->getEncoded();
session()->forget('impersonator_id');
return response()->json([
'bootstrapData' => $data,
'status' => 'success',
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Common\Auth\Fortify;
use Laravel\Fortify\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*/
protected function passwordRules(): array
{
$password = new Password();
$password->length(5);
return ['required', 'string', $password, 'confirmed'];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Common\Auth\Fortify;
use Common\Core\Bootstrap\BootstrapData;
use Common\Core\Bootstrap\MobileBootstrapData;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
class RegisterResponse implements RegisterResponseContract
{
public function toResponse($request): JsonResponse
{
$response = [
'status' => $request->user()->hasVerifiedEmail()
? 'success'
: 'needs_email_verification',
];
// for mobile
if ($request->has('token_name')) {
$bootstrapData = app(MobileBootstrapData::class)->init();
$bootstrapData->refreshToken($request->get('token_name'));
$response['bootstrapData'] = $bootstrapData->get();
// for web
} else {
$bootstrapData = app(BootstrapData::class)->init();
$response['bootstrapData'] = $bootstrapData->getEncoded();
}
return response()->json($response);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Common\Auth\Fortify;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param mixed $user
* @param array $input
* @return void
*/
public function reset($user, array $input)
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user
->forceFill([
'password' => $input['password'],
])
->save();
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Common\Auth\Fortify;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
class TwoFactorLoginResponse extends LoginResponse implements
TwoFactorLoginResponseContract
{
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Common\Auth\Fortify;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
public function update($user, array $input)
{
Validator::make(
$input,
[
'current_password' => [
'required',
'string',
'current_password:web',
],
'password' => $this->passwordRules(),
],
[
'current_password.current_password' => __(
'The provided password does not match your current password.',
),
],
)->validateWithBag('updatePassword');
$user
->forceFill([
'password' => $input['password'],
])
->save();
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Common\Auth\Fortify;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\LoginRateLimiter;
class ValidateLoginCredentials
{
public function execute(Request $request): ?User
{
$user = User::where('email', $request->email)->first();
if (!FortifyRegisterUser::emailIsValid($request->email)) {
$this->throwFailedAuthenticationException(
$request,
__('This domain is blacklisted.'),
);
}
if ($user?->isBanned()) {
$comment = $user->bans()->first()->comment;
$this->throwFailedAuthenticationException(
$request,
$comment
? __('Banned: :reason', ['reason' => $comment])
: __('This user is banned.'),
);
}
if ($user && Hash::check($request->password, $user->password)) {
return $user;
}
return null;
}
public function throwFailedAuthenticationException(
Request $request,
string $message,
) {
app(LoginRateLimiter::class)->increment($request);
throw ValidationException::withMessages([
Fortify::username() => [$message],
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Common\Auth\Jobs;
use Common\Auth\Roles\Role;
use Common\Csv\BaseCsvExportJob;
use Illuminate\Bus\Queueable;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class ExportRolesCsv extends BaseCsvExportJob
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var int
*/
protected $requesterId;
public function __construct(int $requesterId)
{
$this->requesterId = $requesterId;
}
public function cacheName(): string
{
return 'roles';
}
protected function generateLines()
{
$selectCols = [
'id',
'name',
'description',
'type',
'internal',
'created_at',
'updated_at',
];
Role::select($selectCols)->chunkById(100, function (Collection $chunk) {
$chunk->each(function (Role $role) {
$data = $role->toArray();
$this->writeLineToCsv($data);
});
});
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Common\Auth\Jobs;
use App\Models\User;
use Common\Csv\BaseCsvExportJob;
use Illuminate\Bus\Queueable;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class ExportUsersCsv extends BaseCsvExportJob
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var int
*/
protected $requesterId;
public function __construct(int $requesterId)
{
$this->requesterId = $requesterId;
}
public function cacheName(): string
{
return 'users';
}
protected function generateLines()
{
$selectCols = [
'id',
'email',
'username',
'first_name',
'last_name',
'avatar',
'created_at',
'language',
'country',
'timezone',
];
User::select($selectCols)->chunkById(100, function (Collection $chunk) {
$chunk->each(function (User $user) {
$data = $user->toArray();
unset($data['display_name'], $data['has_password']);
$this->writeLineToCsv($data);
});
});
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Common\Auth\Jobs;
use Common\Auth\ActiveSession;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class LogActiveSessionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(protected array $data)
{
}
public function handle(): void
{
$sessionId = $this->data['session_id'] ?? null;
$token = $this->data['token'] ?? null;
$existingSession = app(ActiveSession::class)
->when(
$sessionId,
fn($query) => $query->where('session_id', $sessionId),
)
->when($token, fn($query) => $query->where('token', $token))
->where('user_id', $this->data['user_id'])
->first();
if ($existingSession) {
$existingSession->touch('updated_at');
} else {
$this->createNewSession();
}
}
protected function createNewSession()
{
ActiveSession::create([
'session_id' => $this->data['session_id'] ?? null,
'token' => $this->data['token'] ?? null,
'user_id' => $this->data['user_id'],
'ip_address' => $this->data['ip_address'] ?? null,
'user_agent' => $this->data['user_agent'] ?? null,
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Common\Auth\Middleware;
use Closure;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\Request;
class ForbidBannedUser
{
public function __construct(protected StatefulGuard $guard)
{
}
public function handle(Request $request, Closure $next)
{
if ($request->user() && $request->user()->isBanned()) {
$this->guard->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
abort(403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Common\Auth\Middleware;
use Illuminate\Auth\Middleware\Authenticate;
class OptionalAuthenticate extends Authenticate
{
// prevent authentication exception if user is not logged in at all. This will be handled in policies instead
protected function unauthenticated($request, array $guards)
{
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Common\Auth\Middleware;
use Closure;
use Illuminate\Http\Request;
class VerifyApiAccessMiddleware
{
public function handle(Request $request, Closure $next)
{
$model = $request->user() ?: app('guestRole');
if (!requestIsFromFrontend() && !$model->hasPermission('api.access')) {
abort(401);
}
return $next($request);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Common\Auth\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\HtmlString;
class VerifyEmailWithOtp extends Notification
{
public function __construct(public string $otp)
{
}
public function via($notifiable)
{
return ['mail'];
}
public function toMail($notifiable): MailMessage
{
$accountSettingsUrl = url('account-settings');
$pStyle =
'line-height: 30px; text-align: left; font-weight: normal; font-style: normal; letter-spacing: 0.48px; color: #718096';
$title = __('Your :site security code is:', [
'site' => config('app.name'),
]);
$accountSettingsTxt = __('Account settings');
return (new MailMessage())
->subject(
__('Your :site security code is :code', [
'site' => config('app.name'),
'code' => $this->otp,
]),
)
->greeting(new HtmlString("<h1 style=\"$pStyle\">$title</h1>"))
->line(
new HtmlString(
'<b style="font-size: 48px; font-style: normal; font-weight: bold; padding: 20px 0; line-height: 54px; color: #3d4852">' .
$this->otp .
'</b>',
),
)
->line(
__(
'If you did not request this code, please go to your :link and change your password right away.',
[
'link' => "<a href=\"$accountSettingsUrl\">$accountSettingsTxt</a>",
],
),
)
->line(
__('This code will expire in :minutes minutes.', [
'minutes' => 30,
]),
);
}
}

344
common/Auth/Oauth.php Executable file
View File

@@ -0,0 +1,344 @@
<?php namespace Common\Auth;
use App\Models\User;
use Carbon\Carbon;
use Common\Auth\Actions\CreateUser;
use Common\Auth\Events\SocialConnected;
use Common\Auth\Events\SocialLogin;
use Common\Core\Bootstrap\BootstrapData;
use Common\Core\Bootstrap\MobileBootstrapData;
use Illuminate\Contracts\View\View as ViewContract;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\View;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\One\User as OneUser;
use Laravel\Socialite\Two\User as TwoUser;
class Oauth
{
const OAUTH_CALLBACK_HANDLER_KEY = 'oauthCallbackHandler';
const RETRIEVE_PROFILE_ONLY_KEY = 'retrieveProfileOnly';
private array $validProviders = ['google', 'facebook', 'twitter', 'envato'];
public function loginWith(string $provider)
{
if (Auth::user()) {
return View::make('common::oauth/popup')->with(
'status',
'ALREADY_LOGGED_IN',
);
}
return $this->redirect($provider);
}
public function redirect(string $providerName)
{
$this->validateProvider($providerName);
return Socialite::driver($providerName)->redirect();
}
/**
* Retrieve user details from specified social account without logging user in or connecting accounts.
*/
public function retrieveProfileOnly(string $providerName)
{
$this->validateProvider($providerName);
Session::put([Oauth::RETRIEVE_PROFILE_ONLY_KEY => true]);
$driver = Socialite::driver($providerName);
// get user profile url from facebook
if ($providerName === 'facebook') {
$driver->scopes(['user_link']);
}
return $driver->redirect();
}
/**
* Disconnect specified social account from currently logged-in user.
*/
public function disconnect(string $provider): void
{
$this->validateProvider($provider);
Auth::user()
->social_profiles()
->where('service_name', $provider)
->delete();
}
/**
* Get user profile from specified social provider or throw 404 if it's invalid.
*/
public function socializeWith(
string $provider,
?string $token,
?string $secret,
) {
$this->validateProvider($provider);
if ($token && $secret) {
$user = Socialite::driver($provider)->userFromTokenAndSecret(
$token,
$secret,
);
} elseif ($token) {
$user = Socialite::driver($provider)->userFromToken($token);
} else {
$user = Socialite::with($provider)->user();
}
return $user;
}
/**
* Return existing social profile from database for specified external social profile.
*/
public function getExistingProfile(mixed $profile): ?SocialProfile
{
if (!$profile) {
return null;
}
return SocialProfile::where(
'user_service_id',
$this->getUsersIdentifierOnService($profile),
)
->with('user')
->first();
}
/**
* Create a new user from given social profile and log him in.
*/
public function createUserFromOAuthData(array $data)
{
$profile = $data['profile'];
$service = $data['service'];
$user = User::where('email', $profile->email)->first();
//create a new user if one does not exist with specified email
if (!$user) {
$img = str_replace('http://', 'https://', $profile->avatar);
$user = (new CreateUser())->execute([
'email' => $profile->email,
'avatar' => $img,
'email_verified_at' => now(),
]);
}
//save this social profile data, so we can log in the user easily next time
$user
->social_profiles()
->create(
$this->transformSocialProfileData(
$service,
$profile,
$user->id,
),
);
return $this->logUserIn($user, $service);
}
public function updateSocialProfileData(
SocialProfile $profile,
string $service,
$data,
User|null $user = null,
): void {
$data = $this->transformSocialProfileData(
$service,
$data,
$user->id ?? $profile->user_id,
);
$profile->fill($data)->save();
}
public function attachProfileToExistingUser(
User $user,
mixed $profile,
string $service,
) {
$payload = $this->transformSocialProfileData(
$service,
$profile,
$user->id,
);
//if this social account is already attached to some user
//we will re-attach it to specified user
if ($existing = $this->getExistingProfile($profile)) {
$this->updateSocialProfileData(
$existing,
$service,
$profile,
$user,
);
//if social account is not attached to any user, we will
//create a model for it and attach it to specified user
} else {
$user->social_profiles()->create($payload);
}
$response = [
'bootstrapData' => app(BootstrapData::class)
->init()
->getEncoded(),
];
event(new SocialConnected($user, $service));
return request()->expectsJson()
? $response
: $this->getPopupResponse('SUCCESS', $response);
}
private function transformSocialProfileData(
string $service,
TwoUser|OneUser $data,
int $userId,
): array {
return [
'service_name' => $service,
'user_service_id' => $this->getUsersIdentifierOnService($data),
'user_id' => $userId,
'username' => $data->name,
'access_token' => $data->token ?? null,
'refresh_token' => $data->refreshToken ?? null,
'access_expires_at' =>
isset($data->expiresIn) && $data->expiresIn
? Carbon::now()->addSeconds($data->expiresIn)
: null,
];
}
public function returnProfileData($externalProfile)
{
$normalizedProfile = [
'id' => $externalProfile->id,
'name' => $externalProfile->name,
'email' => $externalProfile->email,
'avatar' => $externalProfile->avatar,
'profileUrl' => $externalProfile->profileUrl,
];
if (request()->expectsJson()) {
return ['profile' => $normalizedProfile];
} else {
return $this->getPopupResponse('SUCCESS_PROFILE_RETRIEVE', [
'profile' => $normalizedProfile,
]);
}
}
/**
* Log given user into the app and return
* a view to close popup in front end.
*/
public function logUserIn(User $user, string $serviceName)
{
Auth::loginUsingId($user->id, true);
if (request('tokenForDevice')) {
$response = app(MobileBootstrapData::class)
->init()
->refreshToken(request('tokenForDevice'))
->get();
} else {
$response = [
'bootstrapData' => app(BootstrapData::class)
->init()
->getEncoded(),
];
}
event(new SocialLogin($user, $serviceName));
if (request()->expectsJson()) {
return $response;
} else {
return $this->getPopupResponse('SUCCESS', $response);
}
}
public function getErrorResponse(string $message)
{
if (request()->wantsJson()) {
return response()->json(['message' => $message], 500);
} else {
return $this->getPopupResponse('ERROR', [
'errorMessage' => $message,
]);
}
}
/**
* Get oauth data persisted in current session.
*
* @param string $key
* @return mixed
*/
public function getPersistedData($key = null)
{
//test session when not logged, what if multiple users log in at same time etc
$data = Session::get('social_profile');
if (!$key) {
return $data;
}
if ($key && isset($data[$key])) {
return $data[$key];
}
}
/**
* Store specified social profile information in the session
* for use in subsequent social login process steps.
*/
public function persistSocialProfileData(array $data): void
{
foreach ($data as $key => $value) {
Session::put("social_profile.$key", $value);
}
}
/**
* Check if provider user want to login with is valid, if not throw 404
*/
private function validateProvider(string $provider): void
{
if (!in_array($provider, $this->validProviders)) {
abort(404);
}
}
/**
* Get users unique identifier on social service from given profile.
*/
private function getUsersIdentifierOnService(mixed $profile): int|string
{
return $profile->id ?? $profile->email;
}
public function getPopupResponse(string $status, $data = null): ViewContract
{
$view = View::make('common::oauth/popup')->with('status', $status);
if ($data) {
$view->with('data', json_encode($data));
}
return $view;
}
}

36
common/Auth/OtpCode.php Executable file
View File

@@ -0,0 +1,36 @@
<?php
namespace Common\Auth;
use Illuminate\Database\Eloquent\Model;
class OtpCode extends Model
{
const TYPE_EMAIL_VERIFICATION = 'email_verification';
protected $guarded = ['id'];
protected $casts = [
'expires_at' => 'datetime',
];
public $timestamps = false;
public function isExpired(): bool
{
return now()->gte($this->expires_at);
}
public static function createForEmailVerification(int $userId)
{
self::where('user_id', $userId)
->where('type', static::TYPE_EMAIL_VERIFICATION)
->delete();
return static::create([
'user_id' => $userId,
'type' => static::TYPE_EMAIL_VERIFICATION,
'code' => random_int(100000, 999999),
'expires_at' => now()->addMinutes(30),
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Common\Auth\Permissions;
use Arr;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class Permission extends Model
{
protected $guarded = ['id'];
protected $casts = [
'id' => 'integer',
'advanced' => 'integer',
];
protected $hidden = ['pivot', 'permissionable_type'];
const MODEL_TYPE = 'permission';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
/**
* @param string|array $value
* @return Collection
*/
public function getRestrictionsAttribute($value)
{
// if loading permissions via parent (user, role, plan) return restrictions
// stored on pivot table, otherwise return restrictions stored on permission itself
$value = $this->pivot ? $this->pivot->restrictions : $value;
if ( ! $value) $value = [];
return collect(is_string($value) ? json_decode($value, true) : $value)->values();
}
public function setRestrictionsAttribute($value)
{
if ($value && is_array($value)) {
$this->attributes['restrictions'] = json_encode(array_values($value));
}
}
public function getRestrictionValue(string $name): int | bool | null
{
$restriction = $this->restrictions->first(function($restriction) use($name) {
return $restriction['name'] === $name;
});
return Arr::get($restriction, 'value') ?? null;
}
/**
* Merge restrictions from specified permission into this permission.
*/
public function mergeRestrictions(Permission $permission = null): self
{
if ($permission) {
$permission->restrictions->each(function($restriction) {
$exists = $this->restrictions->first(function($r) use($restriction) {
return $r['name'] === $restriction['name'];
});
if ( ! $exists) {
$this->restrictions->push($restriction);
}
});
}
return $this;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Common\Auth\Permissions\Policies;
use Common\Auth\Permissions\Permission;
use Common\Auth\BaseUser;
use Illuminate\Http\Request;
use Illuminate\Auth\Access\HandlesAuthorization;
class PermissionPolicy
{
use HandlesAuthorization;
/**
* @var Request
*/
private $request;
public function __construct(Request $request)
{
$this->request = $request;
}
public function index(BaseUser $user)
{
return $user->hasPermission('permission.view');
}
public function show(BaseUser $user, Permission $permission)
{
return $user->hasPermission('permission.view');
}
public function store(BaseUser $user)
{
return $user->hasPermission('permission.create');
}
public function update(BaseUser $user)
{
return $user->hasPermission('permission.update');
}
public function destroy(BaseUser $user)
{
return $user->hasPermission('permission.delete');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Common\Auth\Permissions\Traits;
use Common\Auth\Permissions\Permission;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
trait HasPermissionsRelation
{
public function permissions(): MorphToMany
{
return $this->morphToMany(Permission::class, 'permissionable')
->withPivot('restrictions')
->select('name', 'permissions.id', 'permissions.restrictions');
}
public function hasPermission(string $name): bool
{
return !is_null($this->getPermission($name)) ||
!is_null($this->getPermission('admin'));
}
public function hasExactPermission(string $name): bool
{
return !is_null($this->getPermission($name));
}
public function getPermission(string $name): Permission|null
{
if (method_exists($this, 'loadPermissions')) {
$this->loadPermissions();
}
foreach ($this->permissions as $permission) {
if ($permission->name === $name) {
return $permission;
}
}
return null;
}
public function getRestrictionValue(
string $permissionName,
string $restriction,
): int|bool|null {
$permission = $this->getPermission($permissionName);
return $permission?->getRestrictionValue($restriction);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Common\Auth\Permissions\Traits;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
trait SyncsPermissions
{
public function syncPermissions(
Model $model,
array|Collection $permissions
): void {
$permissionIds = collect($permissions)->mapWithKeys(function (
$permission
) {
$restrictions = Arr::get($permission, 'restrictions', []);
return [
$permission['id'] => [
'restrictions' => collect($restrictions)
->filter(function ($restriction) {
return isset($restriction['value']);
})
->map(function ($restriction) {
return [
'name' => $restriction['name'],
'value' => $restriction['value'],
];
}),
],
];
});
$model->permissions()->sync($permissionIds);
}
}

View File

@@ -0,0 +1,37 @@
<?php namespace Common\Auth\Requests;
use Common\Core\BaseFormRequest;
class CrupdateUserRequest extends BaseFormRequest
{
public function rules(): array
{
$except = $this->getMethod() === 'PUT' ? $this->route('user')->id : '';
$rules = [
'email' => "email|min:3|max:255|unique:users,email,$except",
'password' => 'min:3|max:255',
'avatar' => 'string|max:255|nullable',
'email_verified_at' => '', // can be date string or boolean
// alpha and space/dash
'first_name' =>
'string|min:2|max:255|nullable|regex:/^[\pL\s\-]+$/u',
'last_name' =>
'string|min:2|max:255|nullable|regex:/^[\pL\s\-]+$/u',
'permissions' => 'array',
'roles' => 'array',
'roles.*' => 'int',
'available_space' => 'nullable|min:0',
'country' => 'nullable|string|max:255',
'language' => 'nullable|string|max:255',
'timezone' => 'nullable|string|max:255',
];
if ($this->method() === 'POST') {
$rules['email'] = 'required|' . $rules['email'];
$rules['password'] = 'required|' . $rules['password'];
}
return $rules;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Common\Auth\Roles;
use Common\Auth\Permissions\Traits\SyncsPermissions;
use Illuminate\Support\Arr;
class CrupdateRole
{
use SyncsPermissions;
/**
* @var Role
*/
private $role;
/**
* @param Role $role
*/
public function __construct(Role $role)
{
$this->role = $role;
}
/**
* @param array $data
* @param Role $role
* @return Role
*/
public function execute($data, $role = null)
{
if (!$role) {
$role = $this->role->newInstance([]);
}
$attributes = [
'name' => $data['name'],
'description' => $data['description'] ?? null,
'default' => $data['default'] ?? false,
'guests' => $data['guests'] ?? false,
'type' => $data['type'] ?? 'sitewide',
];
$role->fill($attributes)->save();
// always sync permissions, detach all if "null" is given as permissions
$this->syncPermissions($role, Arr::get($data, 'permissions', []));
return $role;
}
}

70
common/Auth/Roles/Role.php Executable file
View File

@@ -0,0 +1,70 @@
<?php namespace Common\Auth\Roles;
use App\Models\User;
use Common\Auth\Permissions\Traits\HasPermissionsRelation;
use Common\Core\BaseModel;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Role extends BaseModel
{
use HasPermissionsRelation;
const MODEL_TYPE = 'role';
protected $guarded = ['id'];
protected $hidden = ['pivot', 'legacy_permissions'];
protected $casts = [
'id' => 'integer',
'default' => 'boolean',
'guests' => 'boolean',
'internal' => 'boolean',
];
/**
* Get default role for assigning to new users.
*/
public function getDefaultRole(): ?Role
{
return $this->where('default', 1)->first();
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_role')->withPivot(
'created_at',
);
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'model_type' => self::MODEL_TYPE,
];
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'type', 'created_at', 'updated_at'];
}
public static function getModelTypeAttribute(): string
{
return Role::MODEL_TYPE;
}
}

View File

@@ -0,0 +1,146 @@
<?php namespace Common\Auth\Roles;
use App\Models\User;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Illuminate\Http\Request;
class RolesController extends BaseController
{
/**
* @var User
*/
private $user;
/**
* @var Role
*/
private $role;
/**
* @var Request
*/
private $request;
public function __construct(Request $request, Role $role, User $user)
{
$this->role = $role;
$this->user = $user;
$this->request = $request;
}
public function show(Role $role)
{
$this->authorize('show', Role::class);
$role->load(['permissions']);
return $this->success(['role' => $role]);
}
public function index()
{
$this->authorize('index', Role::class);
$pagination = (new Datasource(
$this->role,
request()->all(),
))->paginate();
return $this->success(['pagination' => $pagination]);
}
public function store()
{
$this->authorize('store', Role::class);
$this->validate($this->request, [
'name' => 'required|unique:roles|min:2|max:255',
'default' => 'nullable|boolean',
'guests' => 'nullable|boolean',
'permissions' => 'nullable|array',
]);
$role = app(CrupdateRole::class)->execute($this->request->all());
return $this->success(['role' => $role], 201);
}
public function update(int $id)
{
$this->authorize('update', Role::class);
$this->validate($this->request, [
'name' => "min:2|max:255|unique:roles,name,$id",
'default' => 'boolean',
'guests' => 'boolean',
'permissions' => 'array',
]);
$role = $this->role->findOrFail($id);
$role = app(CrupdateRole::class)->execute($this->request->all(), $role);
return $this->success(['role' => $role]);
}
public function destroy(int $id)
{
$role = $this->role->findOrFail($id);
$this->authorize('destroy', $role);
$role->users()->detach();
$role->delete();
return $this->success([], 204);
}
public function addUsers(int $roleId)
{
$this->authorize('update', Role::class);
$this->validate($this->request, [
'userIds' => 'required|array|min:1|max:25',
'userIds.*' => 'required|int',
]);
$role = $this->role->findOrFail($roleId);
$users = $this->user
->with('roles')
->whereIn('id', $this->request->get('userIds'))
->get(['email', 'id']);
if ($users->isEmpty()) {
return $this->error(
__('Could not attach specified users to role.'),
);
}
//filter out users that are already attached to this role
$users = $users->filter(function ($user) use ($roleId) {
return !$user->roles->contains('id', $roleId);
});
$role->users()->attach($users->pluck('id')->toArray());
return $this->success(['users' => $users]);
}
public function removeUsers(int $roleId)
{
$this->authorize('update', Role::class);
$this->validate($this->request, [
'userIds' => 'required|array|min:1',
'userIds.*' => 'required|integer',
]);
$role = $this->role->findOrFail($roleId);
$role->users()->detach($this->request->get('userIds'));
return $this->success();
}
}

View File

@@ -0,0 +1,36 @@
<?php namespace Common\Auth\Roles;
use App\Models\User;
use Common\Core\BaseController;
class UserRolesController extends BaseController
{
public function attach(int $userId)
{
$user = User::findOrFail($userId);
$this->authorize('update', $user);
$data = $this->validate(request(), [
'roles' => 'array',
'roles.*' => 'integer|exists:roles,id',
]);
$user->roles()->attach($data['roles']);
return $this->success();
}
public function detach(int $userId)
{
$user = User::findOrFail($userId);
$this->authorize('update', $user);
$data = $this->validate(request(), [
'roles' => 'array',
]);
return $user->roles()->detach($data['roles']);
}
}

25
common/Auth/SocialProfile.php Executable file
View File

@@ -0,0 +1,25 @@
<?php namespace Common\Auth;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class SocialProfile extends Model
{
protected $guarded = ['id'];
protected $casts = [
'access_expires_at' => 'datetime',
];
const MODEL_TYPE = 'social_profile';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Common\Auth\Traits;
use Common\Auth\BaseUser;
use Storage;
use Str;
trait HasAvatarAttribute
{
public function getAvatarAttribute(?string $value)
{
// absolute link
if ($value && Str::contains($value, '//')) {
// change google/twitter avatar imported via social login size
$value = str_replace(
'.jpg?sz=50',
".jpg?sz=$this->gravatarSize",
$value,
);
if ($this->gravatarSize > 50) {
// twitter
$value = str_replace('_normal.jpg', '.jpg', $value);
}
return $value;
}
// relative link (for new and legacy urls)
if ($value) {
return Storage::disk('public')->url(
str_replace('storage/', '', $value),
);
}
// gravatar
$hash = md5(trim(strtolower($this->email)));
return "https://www.gravatar.com/avatar/$hash?s={$this->gravatarSize}&d=retro";
}
/**
* @param number $size
* @return BaseUser
*/
public function setGravatarSize($size)
{
$this->gravatarSize = $size;
return $this;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Common\Auth\Traits;
trait HasDisplayNameAttribute
{
/**
* Compile user display name from available attributes.
*
* @return string
*/
public function getDisplayNameAttribute()
{
if ($this->username) {
return $this->username;
} else if ($this->first_name && $this->last_name) {
return $this->first_name.' '.$this->last_name;
} else if ($this->first_name) {
return $this->first_name;
} else if ($this->last_name) {
return $this->last_name;
} else {
return explode('@', $this->email)[0];
}
}
}

View File

@@ -0,0 +1,30 @@
<?php namespace Common\Auth\Validators;
use App;
use App\Models\User;
use Common\Settings\Settings;
class EmailVerifiedValidator {
/**
* Check if user with specified email has verified his email address.
*
* @param string $attribute
* @param string $value
* @param array $parameters
* @return bool
*/
public function validate($attribute, $value, $parameters) {
$settings = App::make(Settings::class);
//don't need to validate email, bail
if ( ! $settings->get('require_email_confirmation')) return true;
//if email address is not taken yet, bail
if ( ! $user = User::where('email', $value)->first()) return true;
//check if specified email is verified
/** @var User $user */
return (bool) $user->hasVerifiedEmail();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Common\Auth\Validators;
use Closure;
use Illuminate\Contracts\Validation\InvokableRule;
use Illuminate\Support\Facades\Hash;
class HashIsValid implements InvokableRule
{
public function __construct(protected string $hashedValue)
{
}
public function __invoke($attribute, mixed $value, $fail)
{
if (!Hash::check($value, $this->hashedValue)) {
return $fail('The :attribute is not valid')->translate();
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Common\Auth\Validators;
use Illuminate\Contracts\Validation\InvokableRule;
use Illuminate\Support\Facades\Hash;
class PasswordIsValid implements InvokableRule
{
public bool $implicit = true;
public function __construct(protected string $password)
{
}
public function __invoke($attribute, $value, $fail)
{
if (!Hash::check($value, $this->password)) {
$fail('Password does not match.')->translate();
}
}
}

68
common/Billing/Billable.php Executable file
View File

@@ -0,0 +1,68 @@
<?php namespace Common\Billing;
use Carbon\Carbon;
use Common\Billing\Models\Price;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use LogicException;
/**
* Trait Billable
* @property-read Collection|Subscription[] $subscriptions
*/
trait Billable
{
public function subscribe(
string $gateway,
string $gatewayId,
string $status,
Price $price,
): Subscription {
if (Subscription::where('gateway_id', $gatewayId)->exists()) {
throw new LogicException(__('This subscription ID already exists'));
}
if ($price->interval === 'year') {
$renewsAt = Carbon::now()->addYears($price->interval_count);
} elseif ($price->interval === 'week') {
$renewsAt = Carbon::now()->addWeeks($price->interval_count);
} else {
$renewsAt = Carbon::now()->addMonths($price->interval_count);
}
$subscription = $this->subscriptions()->create([
'price_id' => $price->id,
'product_id' => $price->product_id,
'ends_at' => null,
'renews_at' => $renewsAt,
'gateway_name' => $gateway,
'gateway_id' => $gatewayId,
'gateway_status' => $status,
]);
$this->load('subscriptions');
return $subscription;
}
public function subscribed(): bool
{
$subscription = $this->subscriptions->first(function (
Subscription $sub,
) {
return $sub->valid();
});
return !is_null($subscription);
}
public function subscriptions(): HasMany
{
// always return subscriptions that are not attached to any gateway last
return $this->hasMany(Subscription::class, 'user_id')->orderBy(
DB::raw('FIELD(gateway_name, "none")'),
'asc',
);
}
}

View File

@@ -0,0 +1,5 @@
<?php namespace Common\Billing;
class GatewayException extends \Exception {
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Common\Billing\Gateways\Actions;
use Common\Billing\Gateways\Paypal\Paypal;
use Common\Billing\Gateways\Stripe\Stripe;
use Common\Billing\Models\Product;
class SyncProductOnEnabledGateways
{
public function __construct(
protected Stripe $stripe,
protected Paypal $paypal
) {
}
public function execute(Product $product): void
{
@ini_set('max_execution_time', 300);
if ($this->stripe->isEnabled()) {
$this->stripe->syncPlan($product);
}
if ($this->paypal->isEnabled()) {
$this->paypal->syncPlan($product);
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Common\Billing\Gateways\Contracts;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Common\Billing\Subscription;
interface CommonSubscriptionGatewayActions
{
public function isEnabled(): bool;
/**
* Sync plan from local database with the gateway
*/
public function syncPlan(Product $product): bool;
public function deletePlan(Product $product): bool;
public function changePlan(
Subscription $subscription,
Product $newProduct,
Price $newPrice,
): bool;
public function cancelSubscription(
Subscription $subscription,
bool $atPeriodEnd = true,
): bool;
public function resumeSubscription(
Subscription $subscription,
array $gatewayParams = [],
): bool;
public function isSubscriptionIncomplete(Subscription $subscription): bool;
public function isSubscriptionPastDue(Subscription $subscription): bool;
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Common\Billing\Gateways\Paypal;
use Carbon\Carbon;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
trait InteractsWithPaypalRestApi
{
protected string|null $accessToken = null;
protected Carbon|null $tokenExpires = null;
public function paypal(): PendingRequest
{
$baseUrl = settings('billing.paypal_test_mode')
? 'https://api-m.sandbox.paypal.com/v1'
: 'https://api-m.paypal.com/v1';
if (
!$this->accessToken ||
$this->tokenExpires->lessThan(Carbon::now())
) {
$clientId = config('services.paypal.client_id');
$secret = config('services.paypal.secret');
$response = Http::withBasicAuth($clientId, $secret)
->throw()
->asForm()
->post("$baseUrl/oauth2/token", [
'grant_type' => 'client_credentials',
]);
if (!$response->successful()) {
$response->throw();
}
$this->accessToken = $response['access_token'];
$this->tokenExpires = Carbon::now()->addSeconds(
$response['expires_in'],
);
}
return Http::withToken($this->accessToken)->baseUrl($baseUrl);
}
}

View File

@@ -0,0 +1,70 @@
<?php namespace Common\Billing\Gateways\Paypal;
use Common\Billing\Gateways\Contracts\CommonSubscriptionGatewayActions;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Common\Billing\Subscription;
use Common\Settings\Settings;
class Paypal implements CommonSubscriptionGatewayActions
{
use InteractsWithPaypalRestApi;
public function __construct(
protected Settings $settings,
protected PaypalPlans $plans,
public PaypalSubscriptions $subscriptions,
) {
}
public function isSubscriptionIncomplete(Subscription $subscription): bool
{
return $this->subscriptions->isIncomplete($subscription);
}
public function isSubscriptionPastDue(Subscription $subscription): bool
{
return $this->subscriptions->isPastDue($subscription);
}
public function isEnabled(): bool
{
return (bool) app(Settings::class)->get('billing.paypal.enable');
}
public function syncPlan(Product $product): bool
{
return $this->plans->sync($product);
}
public function deletePlan(Product $product): bool
{
return $this->plans->delete($product);
}
public function changePlan(
Subscription $subscription,
Product $newProduct,
Price $newPrice,
): bool {
return $this->subscriptions->changePlan(
$subscription,
$newProduct,
$newPrice,
);
}
public function cancelSubscription(
Subscription $subscription,
bool $atPeriodEnd = true,
): bool {
return $this->subscriptions->cancel($subscription, $atPeriodEnd);
}
public function resumeSubscription(
Subscription $subscription,
array $gatewayParams = [],
): bool {
return $this->subscriptions->resume($subscription, $gatewayParams);
}
}

View File

@@ -0,0 +1,33 @@
<?php namespace Common\Billing\Gateways\Paypal;
use Common\Billing\Subscription;
use Common\Core\BaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
class PaypalController extends BaseController
{
public function __construct(
protected Request $request,
protected Subscription $subscription,
protected Paypal $paypal,
) {
$this->middleware('auth');
}
public function storeSubscriptionDetailsLocally(): Response|JsonResponse
{
$data = $this->validate($this->request, [
'paypal_subscription_id' => 'required|string',
]);
$this->paypal->subscriptions->sync(
$data['paypal_subscription_id'],
Auth::id(),
);
return $this->success();
}
}

View File

@@ -0,0 +1,99 @@
<?php namespace Common\Billing\Gateways\Paypal;
use Common\Billing\GatewayException;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Illuminate\Support\Str;
class PaypalPlans
{
use InteractsWithPaypalRestApi;
public function sync(Product $product): bool
{
$product->load('prices');
// there's only one global product on PayPal and not one per plan as on stripe
$productId = config('services.paypal.product_id');
$response = $this->paypal()->get("catalogs/products/$productId");
if (!$response->successful()) {
$this->paypal()->post('catalogs/products', [
'id' => $productId,
'name' => config('services.paypal.product_name'),
'type' => 'DIGITAL',
]);
}
// create any local product prices (plans) on PayPal, that don't exist there already
$product->prices->each(function (Price $price) use ($product) {
if (
!$price->paypal_id ||
!$this->planExistsOnPaypal($price->paypal_id)
) {
$this->create($product, $price);
}
});
return true;
}
protected function planExistsOnPaypal(string $paypalPlanId): bool
{
$response = $this->paypal()->get("billing/plans/{$paypalPlanId}");
return $response->successful();
}
protected function create(Product $product, Price $price): bool
{
$response = $this->paypal()->post('billing/plans', [
'name' => $product->name,
'product_id' => config('services.paypal.product_id'),
'status' => 'ACTIVE',
'payment_preferences' => [
'auto_bill_outstanding' => true,
'payment_failure_threshold' => 2,
],
'billing_cycles' => [
[
'frequency' => [
'interval_unit' => Str::upper($price->interval),
'interval_count' => $price->interval_count,
],
'tenure_type' => 'REGULAR',
'sequence' => 1,
'total_cycles' => 0, // infinite
'pricing_scheme' => [
'fixed_price' => [
'value' => number_format(
$price->amount,
2,
'.',
'',
),
'currency_code' => Str::upper($price->currency),
],
],
],
],
]);
if (!$response->successful()) {
throw new GatewayException('Could not create plan on PayPal');
}
$price->fill(['paypal_id' => $response['id']])->save();
return true;
}
public function delete(Product $product): bool
{
$statuses = $product->prices->map(function (Price $price) {
$response = $this->paypal()->post(
"billing/plans/{$price->paypal_id}/deactivate",
);
return $response->successful();
});
return $statuses->every(fn($status) => $status);
}
}

View File

@@ -0,0 +1,163 @@
<?php namespace Common\Billing\Gateways\Paypal;
use App\Models\User;
use Common\Billing\Invoices\CreateInvoice;
use Common\Billing\Invoices\Invoice;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Common\Billing\Subscription;
use Illuminate\Support\Carbon;
class PaypalSubscriptions
{
use InteractsWithPaypalRestApi;
public function isIncomplete(Subscription $subscription): bool
{
return $subscription->gateway_status === 'APPROVAL_PENDING' ||
$subscription->gateway_status === 'APPROVED';
}
public function isPastDue(Subscription $subscription): bool
{
// no way to check this via PayPal API
return false;
}
public function sync(
string $paypalSubscriptionId,
?int $userId = null,
): void {
$response = $this->paypal()->get(
"billing/subscriptions/$paypalSubscriptionId",
);
$price = Price::where('paypal_id', $response['plan_id'])->firstOrFail();
if ($userId != null) {
$user = User::where('id', $userId)->firstOrFail();
$user->update(['paypal_id' => $response['subscriber']['payer_id']]);
} else {
$user = User::where(
'paypal_id',
$response['subscriber']['payer_id'],
)->firstOrFail();
}
$subscription = $user->subscriptions()->firstOrNew([
'gateway_name' => 'paypal',
'gateway_id' => $response['id'],
]);
if (
in_array($response['status'], ['CANCELLED', 'EXPIRED', 'SUSPENDED'])
) {
$subscription->markAsCancelled();
}
$data = [
'price_id' => $price->id,
'product_id' => $price->product_id,
'gateway_name' => 'paypal',
'gateway_id' => $paypalSubscriptionId,
'gateway_status' => $response['status'],
'renews_at' =>
$response['status'] === 'ACTIVE' &&
isset($response['billing_info']['next_billing_time'])
? Carbon::parse(
$response['billing_info']['next_billing_time'],
)
: null,
];
if ($response['status'] === 'ACTIVE') {
$data['ends_at'] = null;
}
$subscription->fill($data)->save();
$this->createOrUpdateInvoice($subscription, $response->json());
}
public function createOrUpdateInvoice(
Subscription $subscription,
array $paypalSubscription,
): void {
// subscription is no longer active, no need to update invoice
if (!isset($paypalSubscription['billing_info']['next_billing_time'])) {
return;
}
$startTime = Carbon::parse($paypalSubscription['start_time']);
$renewsAt = Carbon::parse(
$paypalSubscription['billing_info']['next_billing_time'],
);
$isPaid = $paypalSubscription['status'] === 'ACTIVE';
$existing = Invoice::whereBetween('created_at', [
$startTime,
$renewsAt,
])->first();
if ($existing) {
// paid invoices should never be set to unpaid
if (!$existing->paid) {
$existing->update(['paid' => $isPaid]);
}
} else {
(new CreateInvoice())->execute([
'subscription_id' => $subscription->id,
'paid' => $isPaid,
]);
}
}
public function changePlan(
Subscription $subscription,
Product $newProduct,
Price $newPrice,
): bool {
$this->paypal()->post(
"billing/subscriptions/$subscription->gateway_id/revise",
[
'plan_id' => $newPrice->paypal_id,
],
);
$this->sync($subscription->gateway_id);
return true;
}
public function cancel(
Subscription $subscription,
$atPeriodEnd = true,
): bool {
if ($atPeriodEnd) {
$this->paypal()->post(
"billing/subscriptions/$subscription->gateway_id/suspend",
['reason' => 'User requested cancellation.'],
);
} else {
$this->paypal()->post(
"billing/subscriptions/$subscription->gateway_id/cancel",
['reason' => 'Subscription deleted locally.'],
);
}
$this->sync($subscription->gateway_id);
return true;
}
public function resume(Subscription $subscription, array $params): bool
{
$this->paypal()->post(
"billing/subscriptions/$subscription->gateway_id/activate",
['reason' => 'Subscription resumed by user.'],
);
$this->sync($subscription->gateway_id);
return true;
}
}

View File

@@ -0,0 +1,103 @@
<?php namespace Common\Billing\Gateways\Paypal;
use Common\Billing\GatewayException;
use Common\Billing\Notifications\PaymentFailed;
use Common\Billing\Subscription;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Arr;
use Symfony\Component\HttpFoundation\Response;
class PaypalWebhookController extends Controller
{
use InteractsWithPaypalRestApi;
public function __construct(
protected Subscription $subscription,
protected Paypal $paypal,
) {
}
public function handleWebhook(Request $request): Response
{
$payload = $request->all();
if (
config('common.site.verify_paypal_webhook') &&
!$this->webhookIsValid()
) {
return response('Webhook validation failed', 422);
}
return match ($payload['event_type']) {
'BILLING.SUBSCRIPTION.PAYMENT.FAILED'
=> $this->handleInvoicePaymentFailed($payload),
'BILLING.SUBSCRIPTION.ACTIVATED',
'BILLING.SUBSCRIPTION.CANCELLED',
'BILLING.SUBSCRIPTION.EXPIRED',
'BILLING.SUBSCRIPTION.SUSPENDED'
=> $this->handleSubscriptionStateChanged($payload),
'PAYMENT.SALE.COMPLETED' => $this->handleSaleCompleted($payload),
default => response('Webhook Handled', 200),
};
}
protected function handleInvoicePaymentFailed(array $payload): Response
{
$paypalSubscriptionId = Arr::get(
$payload,
'resource.billing_agreement_id',
);
$subscription = $this->subscription
->where('gateway_id', $paypalSubscriptionId)
->first();
$subscription?->user->notify(new PaymentFailed($subscription));
return response('Webhook handled', 200);
}
protected function handleSaleCompleted(array $payload): Response
{
$this->paypal->subscriptions->sync(
$payload['resource']['billing_agreement_id'],
);
return response('Webhook Handled', 200);
}
protected function handleSubscriptionStateChanged(array $payload): Response
{
$this->paypal->subscriptions->sync($payload['resource']['id']);
return response('Webhook Handled', 200);
}
protected function webhookIsValid(): bool
{
$payload = [
'auth_algo' => request()->header('PAYPAL-AUTH-ALGO'),
'cert_url' => request()->header('PAYPAL-CERT-URL'),
'transmission_id' => request()->header('PAYPAL-TRANSMISSION-ID'),
'transmission_sig' => request()->header('PAYPAL-TRANSMISSION-SIG'),
'transmission_time' => request()->header(
'PAYPAL-TRANSMISSION-TIME',
),
'webhook_id' => config('services.paypal.webhook_id'),
'webhook_event' => request()->all(),
];
$response = $this->paypal()->post(
'notifications/verify-webhook-signature',
$payload,
);
if (!$response->successful()) {
throw new GatewayException(
"Could not validate paypal webhook: {$response->body()}",
);
}
return $response['verification_status'] === 'SUCCESS';
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Common\Billing\Gateways\Stripe;
use Common\Billing\Models\Price;
use Money\Currencies\ISOCurrencies;
use Money\Currency;
use Money\Parser\IntlLocalizedDecimalParser;
use NumberFormatter;
trait FormatsMoney
{
protected function priceToCents(Price $price): string
{
$currencies = new ISOCurrencies();
$numberFormatter = new NumberFormatter('en', NumberFormatter::DECIMAL);
$moneyParser = new IntlLocalizedDecimalParser(
$numberFormatter,
$currencies,
);
$money = $moneyParser->parse(
$price->amount,
new Currency($price->currency),
);
return $money->getAmount();
}
}

View File

@@ -0,0 +1,130 @@
<?php namespace Common\Billing\Gateways\Stripe;
use App\Models\User;
use Common\Billing\Gateways\Contracts\CommonSubscriptionGatewayActions;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Common\Billing\Subscription;
use Common\Settings\Settings;
use Stripe\StripeClient;
class Stripe implements CommonSubscriptionGatewayActions
{
public StripePlans $plans;
public StripeSubscriptions $subscriptions;
public StripeClient $client;
public function __construct()
{
$this->client = new StripeClient([
'api_key' => config('services.stripe.secret'),
'stripe_version' => '2022-08-01',
]);
$this->plans = new StripePlans($this->client);
$this->subscriptions = new StripeSubscriptions($this->client);
}
public function isEnabled(): bool
{
return (bool) app(Settings::class)->get('billing.stripe.enable');
}
public function syncPlan(Product $product): bool
{
return $this->plans->sync($product);
}
public function getAllPlans(): array
{
return $this->plans->getAll();
}
public function changePlan(
Subscription $subscription,
Product $newProduct,
Price $newPrice,
): bool {
return $this->subscriptions->changePlan(
$subscription,
$newProduct,
$newPrice,
);
}
public function deletePlan(Product $product): bool
{
return $this->plans->delete($product);
}
public function isSubscriptionIncomplete(Subscription $subscription): bool
{
return $this->subscriptions->isIncomplete($subscription);
}
public function isSubscriptionPastDue(Subscription $subscription): bool
{
return $this->subscriptions->isPastDue($subscription);
}
public function cancelSubscription(
Subscription $subscription,
bool $atPeriodEnd = true,
): bool {
return $this->subscriptions->cancel($subscription, $atPeriodEnd);
}
public function resumeSubscription(
Subscription $subscription,
array $gatewayParams = [],
): bool {
return $this->subscriptions->resume($subscription, $gatewayParams);
}
public function createSetupIntent(User $user): string
{
$setupIntent = $this->client->setupIntents->create([
'customer' => $user->stripe_id,
]);
return $setupIntent->client_secret;
}
public function changeDefaultPaymentMethod(
User $user,
string $paymentMethodId,
): bool {
$updatedUser = $this->client->customers->update($user->stripe_id, [
'invoice_settings' => [
'default_payment_method' => $paymentMethodId,
],
]);
$isSuccess =
$updatedUser->invoice_settings['default_payment_method'] ==
$paymentMethodId;
if ($isSuccess) {
$paymentMethod = $this->client->paymentMethods->retrieve(
$paymentMethodId,
);
if ($paymentMethod->type === 'card') {
$this->storeCardDetailsLocally(
$user,
$paymentMethod->card->toArray(),
);
}
}
return $isSuccess;
}
public function storeCardDetailsLocally(User $user, array $card): void
{
$user->update([
'card_brand' => $card['brand'],
'card_last_four' => $card['last4'],
'card_expires' => "{$card['exp_month']}/{$card['exp_year']}",
]);
}
}

View File

@@ -0,0 +1,75 @@
<?php namespace Common\Billing\Gateways\Stripe;
use Auth;
use Common\Billing\Models\Product;
use Common\Billing\Subscription;
use Common\Core\BaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class StripeController extends BaseController
{
public function __construct(
protected Request $request,
protected Subscription $subscription,
protected Stripe $stripe,
) {
$this->middleware('auth');
}
public function createPartialSubscription(): Response|JsonResponse
{
$data = $this->validate($this->request, [
'product_id' => 'required|integer|exists:products,id',
'price_id' => 'integer|exists:prices,id',
'start_date' => 'string',
]);
$product = Product::findOrFail($data['product_id']);
$clientSecret = $this->stripe->subscriptions->createPartial(
$product,
Auth::user(),
$data['price_id'] ?? null,
);
return $this->success(['clientSecret' => $clientSecret]);
}
public function createSetupIntent(): Response|JsonResponse
{
$clientSecret = $this->stripe->createSetupIntent(Auth::user());
return $this->success(['clientSecret' => $clientSecret]);
}
public function changeDefaultPaymentMethod(): Response|JsonResponse
{
$data = $this->validate($this->request, [
'payment_method_id' => 'required|string',
]);
$this->stripe->changeDefaultPaymentMethod(
$this->request->user(),
$data['payment_method_id'],
);
return $this->success();
}
public function storeSubscriptionDetailsLocally(): Response|JsonResponse
{
$data = $this->validate($this->request, [
'payment_intent_id' => 'required|string',
]);
$paymentIntent = $this->stripe->client->paymentIntents->retrieve(
$data['payment_intent_id'],
);
$this->stripe->subscriptions->sync(
$paymentIntent->invoice->subscription,
);
return $this->success();
}
}

View File

@@ -0,0 +1,97 @@
<?php namespace Common\Billing\Gateways\Stripe;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Stripe\Exception\ApiErrorException;
use Stripe\Exception\InvalidRequestException;
use Stripe\Price as StripePrice;
use Stripe\StripeClient;
class StripePlans
{
use FormatsMoney;
public function __construct(protected StripeClient $client)
{
}
public function sync(Product $product): bool
{
$product->load('prices');
// create product on stripe, if it does not exist already
try {
$stripeProduct = $this->client->products->retrieve($product->uuid);
} catch (ApiErrorException $err) {
$stripeProduct = null;
}
if (!$stripeProduct) {
$this->client->products->create([
'id' => $product->uuid,
'name' => $product->name,
]);
}
// create any local product prices on stripe, that don't exist there already
$product->prices->each(function (Price $price) use ($product) {
if (
!$price->stripe_id ||
!$this->priceExistsOnStripe($price->stripe_id)
) {
$this->createPrice($product, $price);
}
});
return true;
}
public function createPrice(Product $product, Price $price): StripePrice
{
$stripePrice = $this->client->prices->create([
'product' => $product->uuid,
'unit_amount' => $this->priceToCents($price),
'currency' => $price->currency,
'recurring' => [
'interval' => $price->interval,
'interval_count' => $price->interval_count,
],
]);
$price->fill(['stripe_id' => $stripePrice->id])->save();
return $stripePrice;
}
public function delete(Product $product): bool
{
// stripe does not allow deleting product if it has prices attached,
// and prices can't be deleted via API, we archive the product instead
try {
$this->client->products->update($product->uuid, [
'active' => false,
]);
} catch (InvalidRequestException $e) {
// if this product is already deleted on stripe, ignore
if ($e->getStripeCode() !== 'resource_missing') {
throw $e;
}
}
return true;
}
public function getAll(): array
{
return $this->client->products->all()->toArray();
}
protected function priceExistsOnStripe(string $stripePriceId): bool
{
try {
$this->client->prices->retrieve($stripePriceId);
return true;
} catch (InvalidRequestException $e) {
return false;
}
}
}

View File

@@ -0,0 +1,287 @@
<?php namespace Common\Billing\Gateways\Stripe;
use App\Models\User;
use Carbon\Carbon;
use Common\Billing\Invoices\CreateInvoice;
use Common\Billing\Invoices\Invoice;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Common\Billing\Subscription;
use Stripe\Exception\InvalidRequestException;
use Stripe\Invoice as StripeInvoice;
use Stripe\StripeClient;
use Stripe\Subscription as StripeSubscription;
class StripeSubscriptions
{
public function __construct(public StripeClient $client)
{
}
public function isIncomplete(Subscription $subscription): bool
{
return $subscription->gateway_status ===
StripeSubscription::STATUS_INCOMPLETE ||
($subscription->gateway_status ===
StripeSubscription::STATUS_INCOMPLETE_EXPIRED &&
$subscription->gateway_status !==
StripeSubscription::STATUS_UNPAID);
}
public function isPastDue(Subscription $subscription): bool
{
return $subscription->gateway_status ===
StripeSubscription::STATUS_PAST_DUE;
}
public function sync(string $stripeSubscriptionId): void
{
$stripeSubscription = $this->client->subscriptions->retrieve(
$stripeSubscriptionId,
['expand' => ['latest_invoice']],
);
$price = Price::where(
'stripe_id',
$stripeSubscription->items->data[0]->price->id,
)->firstOrFail();
$user = User::where(
'stripe_id',
$stripeSubscription->customer,
)->firstOrFail();
$subscription = $user->subscriptions()->firstOrNew([
'gateway_name' => 'stripe',
'gateway_id' => $stripeSubscription->id,
]);
// Cancellation date...
if ($stripeSubscription->cancel_at_period_end) {
$subscription->ends_at = $subscription->onTrial()
? $subscription->trial_ends_at
: Carbon::createFromTimestamp(
$stripeSubscription->current_period_end,
);
} elseif (
$stripeSubscription->cancel_at ||
$stripeSubscription->canceled_at
) {
$subscription->ends_at = Carbon::createFromTimestamp(
$stripeSubscription->cancel_at ??
$stripeSubscription->canceled_at,
);
} else {
$subscription->ends_at = null;
}
$subscription
->fill([
'price_id' => $price->id,
'product_id' => $price->product_id,
'gateway_name' => 'stripe',
'gateway_id' => $stripeSubscription->id,
'gateway_status' => $stripeSubscription->status,
'renews_at' =>
$subscription->ends_at ||
$stripeSubscription->status ===
StripeSubscription::STATUS_INCOMPLETE
? null
: Carbon::createFromTimestamp(
$stripeSubscription->current_period_end,
),
])
->save();
if ($stripeSubscription->latest_invoice) {
$this->createOrUpdateInvoice(
$subscription,
$stripeSubscription->latest_invoice->id,
$stripeSubscription->latest_invoice->status ===
StripeInvoice::STATUS_PAID,
);
}
}
public function createPartial(
Product $product,
User $user,
?int $priceId = null,
): string {
$price = $priceId
? $product->prices()->findOrFail($priceId)
: $product->prices->firstOrFail();
$user = $this->syncStripeCustomer($user);
// find incomplete subscriptions for this customer and price
$stripeSubscription = $this->client->subscriptions
->all([
'customer' => $user->stripe_id,
'price' => $price->stripe_id,
'status' => 'incomplete',
'expand' => ['data.latest_invoice.payment_intent'],
])
->first();
// if matching subscription was not created yet, do it now
if (!$stripeSubscription) {
$stripeSubscription = $this->client->subscriptions->create([
'customer' => $user->stripe_id,
'items' => [
[
'price' => $price->stripe_id,
],
],
'payment_behavior' => 'default_incomplete',
'payment_settings' => [
'save_default_payment_method' => 'on_subscription',
],
'expand' => ['latest_invoice.payment_intent'],
]);
}
// return client secret, needed in frontend to complete subscription
return $stripeSubscription->latest_invoice->payment_intent
->client_secret;
}
public function cancel(
Subscription $subscription,
bool $atPeriodEnd = true,
): bool {
if (!$subscription->user->stripe_id) {
return true;
}
try {
$stripeSubscription = $this->client->subscriptions->retrieve(
$subscription->gateway_id,
);
} catch (InvalidRequestException $e) {
if ($e->getStripeCode() === 'resource_missing') {
return true;
}
throw $e;
}
// cancel subscription at current period end and don't delete
if ($atPeriodEnd) {
$updatedSubscription = $this->client->subscriptions->update(
$stripeSubscription->id,
[
'cancel_at_period_end' => true,
],
);
$subscription
->fill([
'gateway_status' => $updatedSubscription->status,
])
->save();
return $updatedSubscription->cancel_at_period_end;
// cancel and delete subscription instantly
} else {
try {
$stripeSubscription = $this->client->subscriptions->cancel(
$stripeSubscription->id,
);
return $stripeSubscription->status === 'cancelled';
} catch (InvalidRequestException $e) {
return $e->getStripeCode() === 'resource_missing';
}
}
}
public function resume(Subscription $subscription, array $params): bool
{
$stripeSubscription = $this->client->subscriptions->retrieve(
$subscription->gateway_id,
);
$updatedSubscription = $this->client->subscriptions->update(
$stripeSubscription->id,
array_merge(
[
'cancel_at_period_end' => false,
],
$params,
),
);
$subscription
->fill([
'gateway_status' => $updatedSubscription->status,
])
->save();
return $updatedSubscription->status === 'active';
}
public function changePlan(
Subscription $subscription,
Product $newProduct,
Price $newPrice,
): bool {
$stripeSubscription = $this->client->subscriptions->retrieve(
$subscription->gateway_id,
);
$updatedSubscription = $this->client->subscriptions->update(
$stripeSubscription->id,
[
'proration_behavior' => 'always_invoice',
'items' => [
[
'id' => $stripeSubscription->items->data[0]->id,
'price' => $newPrice->stripe_id,
],
],
],
);
return $updatedSubscription->status === 'active';
}
public function createOrUpdateInvoice(
Subscription $subscription,
string $stripeInvoiceId,
bool $isPaid,
): void {
$existing = Invoice::where('uuid', $stripeInvoiceId)->first();
if ($existing) {
// paid invoices should never be set to unpaid
if (!$existing->paid) {
$existing->update(['paid' => $isPaid]);
}
} else {
(new CreateInvoice())->execute([
'subscription_id' => $subscription->id,
'uuid' => $stripeInvoiceId,
'paid' => $isPaid,
]);
}
}
protected function syncStripeCustomer(User $user): User
{
// make sure user with stored stripe ID actually exists on stripe
if ($user->stripe_id) {
try {
$this->client->customers->retrieve($user->stripe_id);
} catch (InvalidRequestException $e) {
$user->stripe_id = null;
}
}
// create customer object on stripe, if it does not exist already
if (!$user->stripe_id) {
$customer = $this->client->customers->create([
'email' => $user->email,
'metadata' => [
'userId' => $user->id,
],
]);
$user->fill(['stripe_id' => $customer->id])->save();
}
return $user;
}
}

View File

@@ -0,0 +1,153 @@
<?php namespace Common\Billing\Gateways\Stripe;
use App\Models\User;
use Common\Billing\Notifications\PaymentFailed;
use Common\Billing\Subscription;
use Exception;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Stripe\Invoice as StripeInvoice;
use Stripe\Subscription as StripeSubscription;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function __construct(
protected Stripe $stripe,
protected Subscription $subscription,
) {
}
public function handleWebhook(Request $request): Response|JsonResponse
{
$webhookSecret = config('services.stripe.webhook_secret');
if ($webhookSecret) {
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('stripe-signature'),
$webhookSecret,
)->toArray();
} catch (Exception $e) {
return response()->json(['message' => $e->getMessage()], 403);
}
} else {
$event = $request->all();
}
return match ($event['type']) {
'invoice.paid' => $this->handleInvoicePaid($event),
// sync user payment methods with local database
'customer.updated' => $this->handleCustomerUpdated($event),
// user subscription ended and can't be resumed
'customer.subscription.deleted' => $this->deleteSubscription(
$event,
),
// automatic subscription renewal failed on stripe
'invoice.payment_failed' => $this->handleInvoicePaymentFailed(
$event,
),
'customer.subscription.created',
'customer.subscription.updated'
=> $this->handleSubscriptionCreatedAndUpdated($event),
default => response('Webhook handled', 200),
};
}
protected function handleInvoicePaid(
array $payload,
): Response|Application|ResponseFactory {
$stripeInvoice = $payload['data']['object'];
$stripeSubscriptionId = $stripeInvoice['subscription'];
$subscription = Subscription::where(
'gateway_id',
$stripeSubscriptionId,
)->first();
if ($subscription) {
$this->stripe->subscriptions->createOrUpdateInvoice(
$subscription,
$stripeInvoice['id'],
true,
);
}
return response('Webhook Handled', 200);
}
protected function handleCustomerUpdated(
array $payload,
): Response|Application|ResponseFactory {
$stripeCustomer = $payload['data']['object'];
$user = User::where('stripe_id', $stripeCustomer['id'])->firstOrFail();
$stripePaymentMethods = $this->stripe->client->customers
->allPaymentMethods($stripeCustomer['id'], ['type' => 'card'])
->toArray()['data'];
if (!empty($stripePaymentMethods)) {
$card = $stripePaymentMethods[0]['card'];
$this->stripe->storeCardDetailsLocally($user, $card);
}
return response('Webhook Handled', 200);
}
protected function handleInvoicePaymentFailed(array $payload): Response
{
$stripeUserId = $payload['data']['object']['customer'];
$user = User::where('stripe_id', $stripeUserId)->first();
$reason = $payload['data']['object']['billing_reason'];
$shouldNotify =
$reason === StripeInvoice::BILLING_REASON_SUBSCRIPTION_CYCLE ||
$reason === StripeInvoice::BILLING_REASON_SUBSCRIPTION_THRESHOLD;
if ($user && $shouldNotify) {
$stripeSubscription = $user
->subscriptions()
->where('gateway_name', 'stripe')
->first();
if ($stripeSubscription) {
$user->notify(new PaymentFailed($stripeSubscription));
}
}
return response('Webhook handled', 200);
}
protected function handleSubscriptionCreatedAndUpdated(array $payload)
{
$stripeSubscriptions = $payload['data']['object'];
// initial payment failed and 24 hours passed, subscription can't be renewed anymore
if (
$stripeSubscriptions['status'] ===
StripeSubscription::STATUS_INCOMPLETE_EXPIRED
) {
$this->deleteSubscription($payload);
// sync subscription with latest data on stripe, regardless of event type
} else {
$this->stripe->subscriptions->sync($stripeSubscriptions['id']);
}
return response('Webhook Handled', 200);
}
protected function deleteSubscription(array $payload)
{
$subscription = Subscription::where(
'gateway_id',
$payload['data']['object']['id'],
)->first();
$subscription?->cancelAndDelete();
return response('Webhook handled', 200);
}
}

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