2
common/.gitattributes
vendored
Executable file
2
common/.gitattributes
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
32
common/Admin/AdminSetupAlertsController.php
Executable file
32
common/Admin/AdminSetupAlertsController.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
12
common/Admin/Analytics/Actions/BuildAnalyticsReport.php
Executable file
12
common/Admin/Analytics/Actions/BuildAnalyticsReport.php
Executable 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;
|
||||
}
|
||||
136
common/Admin/Analytics/Actions/BuildDemoAnalyticsReport.php
Executable file
136
common/Admin/Analytics/Actions/BuildDemoAnalyticsReport.php
Executable 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);
|
||||
}
|
||||
}
|
||||
331
common/Admin/Analytics/Actions/BuildGoogleAnalyticsReport.php
Executable file
331
common/Admin/Analytics/Actions/BuildGoogleAnalyticsReport.php
Executable 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());
|
||||
}
|
||||
}
|
||||
63
common/Admin/Analytics/Actions/BuildNullAnalyticsReport.php
Executable file
63
common/Admin/Analytics/Actions/BuildNullAnalyticsReport.php
Executable 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' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
18
common/Admin/Analytics/Actions/DemoTrend.php
Executable file
18
common/Admin/Analytics/Actions/DemoTrend.php
Executable 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);
|
||||
}
|
||||
}
|
||||
11
common/Admin/Analytics/Actions/GetAnalyticsHeaderDataAction.php
Executable file
11
common/Admin/Analytics/Actions/GetAnalyticsHeaderDataAction.php
Executable file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Admin\Analytics\Actions;
|
||||
|
||||
interface GetAnalyticsHeaderDataAction
|
||||
{
|
||||
/**
|
||||
* Get analytics header data.
|
||||
*/
|
||||
public function execute(array $params): array;
|
||||
}
|
||||
72
common/Admin/Analytics/AnalyticsController.php
Executable file
72
common/Admin/Analytics/AnalyticsController.php
Executable 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
common/Admin/Analytics/AnalyticsServiceProvider.php
Executable file
37
common/Admin/Analytics/AnalyticsServiceProvider.php
Executable 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
97
common/Admin/Appearance/AppearanceSaver.php
Executable file
97
common/Admin/Appearance/AppearanceSaver.php
Executable 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
common/Admin/Appearance/AppearanceValues.php
Executable file
127
common/Admin/Appearance/AppearanceValues.php
Executable 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
32
common/Admin/Appearance/Controllers/AppearanceController.php
Executable file
32
common/Admin/Appearance/Controllers/AppearanceController.php
Executable 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());
|
||||
}
|
||||
}
|
||||
55
common/Admin/Appearance/Controllers/SeoTagsController.php
Executable file
55
common/Admin/Appearance/Controllers/SeoTagsController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
39
common/Admin/Appearance/Events/AppearanceSettingSaved.php
Executable file
39
common/Admin/Appearance/Events/AppearanceSettingSaved.php
Executable 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;
|
||||
}
|
||||
}
|
||||
65
common/Admin/Appearance/GenerateFavicon.php
Executable file
65
common/Admin/Appearance/GenerateFavicon.php
Executable 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");
|
||||
}
|
||||
}
|
||||
33
common/Admin/Appearance/Themes/CrupdateCssTheme.php
Executable file
33
common/Admin/Appearance/Themes/CrupdateCssTheme.php
Executable 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;
|
||||
}
|
||||
}
|
||||
28
common/Admin/Appearance/Themes/CrupdateCssThemeRequest.php
Executable file
28
common/Admin/Appearance/Themes/CrupdateCssThemeRequest.php
Executable 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
75
common/Admin/Appearance/Themes/CssTheme.php
Executable file
75
common/Admin/Appearance/Themes/CssTheme.php
Executable 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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
76
common/Admin/Appearance/Themes/CssThemeController.php
Executable file
76
common/Admin/Appearance/Themes/CssThemeController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
46
common/Admin/Appearance/Themes/CssThemePolicy.php
Executable file
46
common/Admin/Appearance/Themes/CssThemePolicy.php
Executable 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;
|
||||
}
|
||||
}
|
||||
33
common/Admin/CacheController.php
Executable file
33
common/Admin/CacheController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
64
common/Admin/ImpersonateUserController.php
Executable file
64
common/Admin/ImpersonateUserController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
234
common/Admin/Sitemap/BaseSitemapGenerator.php
Executable file
234
common/Admin/Sitemap/BaseSitemapGenerator.php
Executable 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;
|
||||
}
|
||||
}
|
||||
23
common/Admin/Sitemap/SitemapController.php
Executable file
23
common/Admin/Sitemap/SitemapController.php
Executable 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([]);
|
||||
}
|
||||
}
|
||||
53
common/Auth/Actions/CreateUser.php
Executable file
53
common/Auth/Actions/CreateUser.php
Executable 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;
|
||||
}
|
||||
}
|
||||
68
common/Auth/Actions/DeleteUsers.php
Executable file
68
common/Auth/Actions/DeleteUsers.php
Executable 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();
|
||||
}
|
||||
}
|
||||
62
common/Auth/Actions/PaginateUsers.php
Executable file
62
common/Auth/Actions/PaginateUsers.php
Executable 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();
|
||||
}
|
||||
}
|
||||
28
common/Auth/Actions/UpdateUser.php
Executable file
28
common/Auth/Actions/UpdateUser.php
Executable 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
17
common/Auth/ActiveSession.php
Executable 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
32
common/Auth/Ban.php
Executable 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
485
common/Auth/BaseUser.php
Executable 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;
|
||||
}
|
||||
}
|
||||
29
common/Auth/Commands/DeleteExpiredBansCommand.php
Executable file
29
common/Auth/Commands/DeleteExpiredBansCommand.php
Executable 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;
|
||||
}
|
||||
}
|
||||
24
common/Auth/Commands/DeleteExpiredOtpCodesCommand.php
Executable file
24
common/Auth/Commands/DeleteExpiredOtpCodesCommand.php
Executable 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;
|
||||
}
|
||||
}
|
||||
39
common/Auth/Controllers/AccessTokenController.php
Executable file
39
common/Auth/Controllers/AccessTokenController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
48
common/Auth/Controllers/BanController.php
Executable file
48
common/Auth/Controllers/BanController.php
Executable 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]);
|
||||
}
|
||||
}
|
||||
45
common/Auth/Controllers/EmailVerificationController.php
Executable file
45
common/Auth/Controllers/EmailVerificationController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
84
common/Auth/Controllers/MobileAuthController.php
Executable file
84
common/Auth/Controllers/MobileAuthController.php
Executable 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);
|
||||
}
|
||||
}
|
||||
166
common/Auth/Controllers/SocialAuthController.php
Executable file
166
common/Auth/Controllers/SocialAuthController.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
17
common/Auth/Controllers/TwoFactorQrCodeController.php
Executable file
17
common/Auth/Controllers/TwoFactorQrCodeController.php
Executable 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
common/Auth/Controllers/UserAvatarController.php
Executable file
62
common/Auth/Controllers/UserAvatarController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
105
common/Auth/Controllers/UserController.php
Executable file
105
common/Auth/Controllers/UserController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
30
common/Auth/Controllers/UserFollowedUsersController.php
Executable file
30
common/Auth/Controllers/UserFollowedUsersController.php
Executable 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]);
|
||||
}
|
||||
}
|
||||
47
common/Auth/Controllers/UserFollowersController.php
Executable file
47
common/Auth/Controllers/UserFollowersController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
77
common/Auth/Controllers/UserSessionsController.php
Executable file
77
common/Auth/Controllers/UserSessionsController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
12
common/Auth/Events/SocialConnected.php
Executable file
12
common/Auth/Events/SocialConnected.php
Executable 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
12
common/Auth/Events/SocialLogin.php
Executable file
12
common/Auth/Events/SocialLogin.php
Executable 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
21
common/Auth/Events/UserAvatarChanged.php
Executable file
21
common/Auth/Events/UserAvatarChanged.php
Executable 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;
|
||||
}
|
||||
}
|
||||
12
common/Auth/Events/UserCreated.php
Executable file
12
common/Auth/Events/UserCreated.php
Executable 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
15
common/Auth/Events/UsersDeleted.php
Executable file
15
common/Auth/Events/UsersDeleted.php
Executable 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;
|
||||
}
|
||||
}
|
||||
70
common/Auth/Fortify/FortifyRegisterUser.php
Executable file
70
common/Auth/Fortify/FortifyRegisterUser.php
Executable 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;
|
||||
}
|
||||
}
|
||||
55
common/Auth/Fortify/FortifyServiceProvider.php
Executable file
55
common/Auth/Fortify/FortifyServiceProvider.php
Executable 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
27
common/Auth/Fortify/LoginResponse.php
Executable file
27
common/Auth/Fortify/LoginResponse.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
common/Auth/Fortify/LogoutResponse.php
Executable file
24
common/Auth/Fortify/LogoutResponse.php
Executable 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
common/Auth/Fortify/PasswordValidationRules.php
Executable file
18
common/Auth/Fortify/PasswordValidationRules.php
Executable 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'];
|
||||
}
|
||||
}
|
||||
34
common/Auth/Fortify/RegisterResponse.php
Executable file
34
common/Auth/Fortify/RegisterResponse.php
Executable 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);
|
||||
}
|
||||
}
|
||||
31
common/Auth/Fortify/ResetUserPassword.php
Executable file
31
common/Auth/Fortify/ResetUserPassword.php
Executable 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();
|
||||
}
|
||||
}
|
||||
10
common/Auth/Fortify/TwoFactorLoginResponse.php
Executable file
10
common/Auth/Fortify/TwoFactorLoginResponse.php
Executable file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Auth\Fortify;
|
||||
|
||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
|
||||
|
||||
class TwoFactorLoginResponse extends LoginResponse implements
|
||||
TwoFactorLoginResponseContract
|
||||
{
|
||||
}
|
||||
37
common/Auth/Fortify/UpdateUserPassword.php
Executable file
37
common/Auth/Fortify/UpdateUserPassword.php
Executable 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();
|
||||
}
|
||||
}
|
||||
52
common/Auth/Fortify/ValidateLoginCredentials.php
Executable file
52
common/Auth/Fortify/ValidateLoginCredentials.php
Executable 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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
common/Auth/Jobs/ExportRolesCsv.php
Executable file
51
common/Auth/Jobs/ExportRolesCsv.php
Executable 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
55
common/Auth/Jobs/ExportUsersCsv.php
Executable file
55
common/Auth/Jobs/ExportUsersCsv.php
Executable 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
51
common/Auth/Jobs/LogActiveSessionJob.php
Executable file
51
common/Auth/Jobs/LogActiveSessionJob.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
common/Auth/Middleware/ForbidBannedUser.php
Executable file
26
common/Auth/Middleware/ForbidBannedUser.php
Executable 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);
|
||||
}
|
||||
}
|
||||
14
common/Auth/Middleware/OptionalAuthenticate.php
Executable file
14
common/Auth/Middleware/OptionalAuthenticate.php
Executable 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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
20
common/Auth/Middleware/VerifyApiAccessMiddleware.php
Executable file
20
common/Auth/Middleware/VerifyApiAccessMiddleware.php
Executable 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);
|
||||
}
|
||||
}
|
||||
60
common/Auth/Notifications/VerifyEmailWithOtp.php
Executable file
60
common/Auth/Notifications/VerifyEmailWithOtp.php
Executable 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
344
common/Auth/Oauth.php
Executable 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
36
common/Auth/OtpCode.php
Executable 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
common/Auth/Permissions/Permission.php
Executable file
73
common/Auth/Permissions/Permission.php
Executable 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;
|
||||
}
|
||||
}
|
||||
48
common/Auth/Permissions/Policies/PermissionPolicy.php
Executable file
48
common/Auth/Permissions/Policies/PermissionPolicy.php
Executable 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');
|
||||
}
|
||||
}
|
||||
50
common/Auth/Permissions/Traits/HasPermissionsRelation.php
Executable file
50
common/Auth/Permissions/Traits/HasPermissionsRelation.php
Executable 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);
|
||||
}
|
||||
}
|
||||
36
common/Auth/Permissions/Traits/SyncsPermissions.php
Executable file
36
common/Auth/Permissions/Traits/SyncsPermissions.php
Executable 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);
|
||||
}
|
||||
}
|
||||
37
common/Auth/Requests/CrupdateUserRequest.php
Executable file
37
common/Auth/Requests/CrupdateUserRequest.php
Executable 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;
|
||||
}
|
||||
}
|
||||
51
common/Auth/Roles/CrupdateRole.php
Executable file
51
common/Auth/Roles/CrupdateRole.php
Executable 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
70
common/Auth/Roles/Role.php
Executable 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;
|
||||
}
|
||||
}
|
||||
146
common/Auth/Roles/RolesController.php
Executable file
146
common/Auth/Roles/RolesController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
36
common/Auth/Roles/UserRolesController.php
Executable file
36
common/Auth/Roles/UserRolesController.php
Executable 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
25
common/Auth/SocialProfile.php
Executable 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);
|
||||
}
|
||||
}
|
||||
50
common/Auth/Traits/HasAvatarAttribute.php
Executable file
50
common/Auth/Traits/HasAvatarAttribute.php
Executable 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;
|
||||
}
|
||||
}
|
||||
26
common/Auth/Traits/HasDisplayNameAttribute.php
Executable file
26
common/Auth/Traits/HasDisplayNameAttribute.php
Executable 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
30
common/Auth/Validators/EmailVerifiedValidator.php
Executable file
30
common/Auth/Validators/EmailVerifiedValidator.php
Executable 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();
|
||||
}
|
||||
}
|
||||
21
common/Auth/Validators/HashIsValid.php
Executable file
21
common/Auth/Validators/HashIsValid.php
Executable 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
22
common/Auth/Validators/PasswordIsValid.php
Executable file
22
common/Auth/Validators/PasswordIsValid.php
Executable 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
68
common/Billing/Billable.php
Executable 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
5
common/Billing/GatewayException.php
Executable file
5
common/Billing/GatewayException.php
Executable file
@@ -0,0 +1,5 @@
|
||||
<?php namespace Common\Billing;
|
||||
|
||||
class GatewayException extends \Exception {
|
||||
|
||||
}
|
||||
28
common/Billing/Gateways/Actions/SyncProductOnEnabledGateways.php
Executable file
28
common/Billing/Gateways/Actions/SyncProductOnEnabledGateways.php
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
common/Billing/Gateways/Contracts/CommonSubscriptionGatewayActions.php
Executable file
39
common/Billing/Gateways/Contracts/CommonSubscriptionGatewayActions.php
Executable 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;
|
||||
}
|
||||
43
common/Billing/Gateways/Paypal/InteractsWithPaypalRestApi.php
Executable file
43
common/Billing/Gateways/Paypal/InteractsWithPaypalRestApi.php
Executable 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);
|
||||
}
|
||||
}
|
||||
70
common/Billing/Gateways/Paypal/Paypal.php
Executable file
70
common/Billing/Gateways/Paypal/Paypal.php
Executable 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);
|
||||
}
|
||||
}
|
||||
33
common/Billing/Gateways/Paypal/PaypalController.php
Executable file
33
common/Billing/Gateways/Paypal/PaypalController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
99
common/Billing/Gateways/Paypal/PaypalPlans.php
Executable file
99
common/Billing/Gateways/Paypal/PaypalPlans.php
Executable 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);
|
||||
}
|
||||
}
|
||||
163
common/Billing/Gateways/Paypal/PaypalSubscriptions.php
Executable file
163
common/Billing/Gateways/Paypal/PaypalSubscriptions.php
Executable 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;
|
||||
}
|
||||
}
|
||||
103
common/Billing/Gateways/Paypal/PaypalWebhookController.php
Executable file
103
common/Billing/Gateways/Paypal/PaypalWebhookController.php
Executable 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';
|
||||
}
|
||||
}
|
||||
28
common/Billing/Gateways/Stripe/FormatsMoney.php
Executable file
28
common/Billing/Gateways/Stripe/FormatsMoney.php
Executable 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();
|
||||
}
|
||||
}
|
||||
130
common/Billing/Gateways/Stripe/Stripe.php
Executable file
130
common/Billing/Gateways/Stripe/Stripe.php
Executable 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']}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
common/Billing/Gateways/Stripe/StripeController.php
Executable file
75
common/Billing/Gateways/Stripe/StripeController.php
Executable 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();
|
||||
}
|
||||
}
|
||||
97
common/Billing/Gateways/Stripe/StripePlans.php
Executable file
97
common/Billing/Gateways/Stripe/StripePlans.php
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
287
common/Billing/Gateways/Stripe/StripeSubscriptions.php
Executable file
287
common/Billing/Gateways/Stripe/StripeSubscriptions.php
Executable 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;
|
||||
}
|
||||
}
|
||||
153
common/Billing/Gateways/Stripe/StripeWebhookController.php
Executable file
153
common/Billing/Gateways/Stripe/StripeWebhookController.php
Executable 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
Reference in New Issue
Block a user