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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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