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

185
common/Core/AppUrl.php Executable file
View File

@@ -0,0 +1,185 @@
<?php
namespace Common\Core;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
class AppUrl
{
/**
* If host in .env file and current request did not match, but
* we were able to find a matching custom domain in database.
*/
public ?object $matchedCustomDomain = null;
/**
* Url "app.url" config item was changed to dynamically.
*/
public ?string $newAppUrl = null;
/**
* Whether hosts from APP_URL in .env file and current request match.
* This will strip "www" and schemes from both and only compare hosts.
*/
public bool $envAndCurrentHostsAreEqual;
public string $htmlBaseUri;
public string $originalAppUrl;
public function init(): static
{
$this->originalAppUrl = config('app.url');
if (
config('common.site.dynamic_app_url') ||
!config('common.site.installed')
) {
$this->maybeDynamicallyUpdate();
} else {
$this->envAndCurrentHostsAreEqual = true;
}
$this->registerHtmlBaseUri();
return $this;
}
private function maybeDynamicallyUpdate(): void
{
$request = app('request');
$requestHost = $request->getHost();
$envParts = parse_url(config('app.url'));
$requestScheme = in_array($request->header('x-forwarded-proto'), [
'https',
'on',
'ssl',
'1',
])
? 'https'
: $request->getScheme();
$schemeIsDifferent = $requestScheme !== Arr::get($envParts, 'scheme');
$this->envAndCurrentHostsAreEqual =
$this->getHostFrom($requestHost) ===
$this->getHostFrom(Arr::get($envParts, 'host'));
$hostsWithWwwAreEqual = $requestHost === Arr::get($envParts, 'host');
$customDomainsEnabled = config('common.site.enable_custom_domains');
$endsWithSlash = Str::endsWith(Arr::get($envParts, 'path'), '/');
// update app.url if not installed yet, or if only scheme, slash or www is different
if (
($this->envAndCurrentHostsAreEqual ||
!config('common.site.installed')) &&
($schemeIsDifferent || $endsWithSlash || !$hostsWithWwwAreEqual)
) {
if (!config('common.site.installed')) {
$this->handleInstallationAppUrl();
return;
}
$this->newAppUrl =
$request->getSchemeAndHttpHost() .
rtrim(Arr::get($envParts, 'path'), '/');
config(['app.url' => $this->newAppUrl]);
// update social auth urls as well
foreach (config('services') as $serviceName => $serviceConfig) {
if (isset($serviceConfig['redirect'])) {
config(
"services.$serviceName.redirect",
"$this->newAppUrl/secure/auth/social/$serviceName/callback",
);
}
}
} elseif (!$this->envAndCurrentHostsAreEqual && $customDomainsEnabled) {
$this->matchedCustomDomain = DB::table('custom_domains')
->where('host', $requestHost)
->orWhere('host', $request->getSchemeAndHttpHost())
// if there are multiple domains with same host, get the one that has resource attached to it first
->orderBy('resource_id', 'desc')
->first();
if ($this->matchedCustomDomain) {
$this->newAppUrl = $request->getSchemeAndHttpHost();
config(['app.url' => $this->newAppUrl]);
}
}
}
protected function handleInstallationAppUrl(): void
{
// create new request so main laravel request is not instantiated yet,
// and "normalizeRequestUri" on CommonProvider works properly
$request = SymfonyRequest::createFromGlobals();
$pathParts = [
...explode('/', $request->getBaseUrl()),
...explode('/', $request->getPathInfo()),
];
$pathParts = array_values(
array_filter($pathParts, fn($part) => $part !== ''),
);
// get path parts up to "install" segment (if it exists), in case site is not installed at root domain
$domainParts = [];
foreach ($pathParts as $key => $part) {
if ($part !== 'install') {
$domainParts[] = $part;
} else {
break;
}
}
$this->newAppUrl = request()->getSchemeAndHttpHost();
if (!empty($pathParts)) {
$this->newAppUrl .= '/' . implode('/', $domainParts);
}
config(['app.url' => $this->newAppUrl]);
}
protected function registerHtmlBaseUri(): void
{
$htmlBaseUri = '/';
//get uri for html "base" tag
if (substr_count(config('app.url'), '/') > 2) {
$htmlBaseUri = parse_url(config('app.url'))['path'] . '/';
}
$this->htmlBaseUri = $htmlBaseUri;
}
public function getRequestHost(): string
{
return $this->getHostFrom(app('request')->getHost());
}
public function requestHostMatches(
$hostOrUrl,
$subdomainMatch = false,
): bool {
$hostOrUrl = $this->getHostFrom($hostOrUrl);
$requestHost = $this->getRequestHost();
return $hostOrUrl === $requestHost ||
($subdomainMatch && Str::endsWith($requestHost, $hostOrUrl));
}
/*
* Extract host from full or partial url.
* This will remove scheme, port, "www", path and query params.
*/
public function getHostFrom($hostOrUrl)
{
// if there's no scheme, add // so it's parsed properly
if (!preg_match('/^([a-z][a-z0-9\-\.\+]*:)|(\/)/', $hostOrUrl)) {
$hostOrUrl = '//' . $hostOrUrl;
}
$parts = parse_url($hostOrUrl);
return preg_replace('/^www\./i', '', $parts['host']);
}
}

144
common/Core/BaseController.php Executable file
View File

@@ -0,0 +1,144 @@
<?php namespace Common\Core;
use App\Models\User;
use Common\Core\Prerender\HandlesSeo;
use Common\Core\Rendering\DetectsCrawlers;
use Common\Core\Rendering\RendersClientSideApp;
use Illuminate\Auth\Access\Response as AuthResponse;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
class BaseController extends Controller
{
use AuthorizesRequests,
DispatchesJobs,
ValidatesRequests,
HandlesSeo,
RendersClientSideApp,
DetectsCrawlers;
// todo: refactor bedrive and belink policies to use basePolicy permission check and remove guest fetching here
/**
* Authorize a given action for the current user
* or guest if user is not logged in.
*/
public function authorize(
string $ability,
mixed $arguments = [],
): AuthResponse {
if (Auth::check()) {
[$ability, $arguments] = $this->parseAbilityAndArguments(
$ability,
$arguments,
);
return app(Gate::class)->authorize($ability, $arguments);
} else {
$guest = new User();
// make sure ID is not NULL to avoid false positives in authorization
$guest->forceFill(['id' => -1]);
$guest->setRelation('roles', collect([app('guestRole')]));
return $this->authorizeForUser($guest, $ability, $arguments);
}
}
public function renderClientOrApi(array $options)
{
$ssrEnabled =
config('common.site.ssr_enabled') && !Arr::get($options, 'noSSR');
$data = Arr::get($options, 'data', []);
$pageName = Arr::get($options, 'pageName');
$isCrawler = $this->isCrawler();
if ($pageName) {
$customPath = storage_path(
"app/editable-views/seo-tags/$pageName.blade.php",
);
$seoTagsView = file_exists($customPath)
? "editable-views::seo-tags.$pageName"
: "seo.$pageName.seo-tags";
}
// if it's an API request, simply return data as JSON
if (isApiRequest()) {
// only include SEO tags for internal API requests
if (requestIsFromFrontend() && isset($seoTagsView)) {
$data['seo'] = view($seoTagsView, $options['data'])->render();
}
return response()->json($data);
}
// if it's a web request and SSR is disabled, prerender a simple blade page for crawlers
if (
!Arr::get($options, 'noPrerender') &&
!$ssrEnabled &&
$isCrawler &&
$pageName &&
file_exists(
resource_path("views/seo/$pageName/prerender.blade.php"),
)
) {
return view("seo.$pageName.prerender", $data)->with([
'htmlBaseUri' => app(AppUrl::class)->htmlBaseUri,
'seoTagsView' => $seoTagsView ?? null,
]);
}
// finally render the full react app with optional SSR
return $this->renderClientSideApp([
'pageName' => $pageName,
'pageData' => $data,
'seoTagsView' => $seoTagsView ?? null,
'noSSR' => !$ssrEnabled,
]);
}
public function success(
array|Collection $data = [],
int $status = 200,
array $options = [],
) {
$data = $data ?: [];
if (!Arr::get($data, 'status')) {
$data['status'] = 'success';
}
// only generate seo tags if request is coming from frontend and not from API
if (
(requestIsFromFrontend() || defined('SHOULD_PRERENDER')) &&
($response = $this->handleSeo($data, $options))
) {
return $response;
}
foreach ($data as $key => $value) {
if ($value instanceof Arrayable) {
$data[$key] = $value->toArray();
}
}
return response()->json($data, $status);
}
/**
* Return error response with specified messages.
*/
public function error(
?string $message = '',
array $errors = [],
int $status = 422,
$data = [],
) {
$data = array_merge($data, [
'message' => $message,
'errors' => $errors ?: [],
]);
return response()->json($data, $status);
}
}

11
common/Core/BaseFormRequest.php Executable file
View File

@@ -0,0 +1,11 @@
<?php namespace Common\Core;
use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
}

114
common/Core/BaseModel.php Executable file
View File

@@ -0,0 +1,114 @@
<?php
namespace Common\Core;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
abstract class BaseModel extends Model
{
const MODEL_TYPE = '';
abstract public static function filterableFields(): array;
abstract public function toNormalizedArray(): array;
abstract public function toSearchableArray(): array;
abstract public static function getModelTypeAttribute(): string;
public function getMorphClass()
{
return static::MODEL_TYPE;
}
public function scopeMysqlSearch(Builder $builder, string $query): Builder
{
$searchableFields = [];
$searchableRelations = [];
foreach ((new static())->toSearchableArray() as $field => $value) {
if (!in_array($field, static::filterableFields())) {
if (
method_exists(static::class, $field) &&
!$this->$field() instanceof Attribute
) {
$searchableRelations[] = $field;
} else {
$searchableFields[] = $field;
}
}
}
$builder->matches($searchableFields, $query);
foreach ($searchableRelations as $relation) {
$builder->orWhereHas(
$relation,
fn(Builder $q) => $q->mysqlSearch($query),
);
}
return $builder;
}
public function scopeMatches(
Builder $builder,
array $columns,
string $value,
): Builder {
$mode = config('common.site.scout_mysql_mode');
$columns = array_map(fn($col) => $this->qualifyColumn($col), $columns);
if ($mode === 'fulltext' && strlen($value) >= 3) {
if (is_null($builder->getQuery()->columns)) {
$builder->select($this->qualifyColumn('*'));
}
$colString = implode(',', $columns);
$builder->selectRaw(
"MATCH($colString) AGAINST(? IN NATURAL LANGUAGE MODE) AS relevance",
[$value],
);
$builder->whereRaw("MATCH($colString) AGAINST(?)", [$value]);
} else {
$builder->where(function (Builder $nestedBuilder) use (
$columns,
$mode,
$value,
) {
foreach ($columns as $column) {
$nestedBuilder->orWhere(
$column,
'like',
$mode === 'basic' ? "$value%" : "%$value%",
);
}
});
}
return $builder;
}
public function getSearchableValues(): array
{
$searchableValues = [];
foreach ($this->toSearchableArray() as $key => $value) {
if (!in_array($key, self::filterableFields())) {
$searchableValues[] = $value;
}
}
return $searchableValues;
}
public static function getSearchableKeys($skipRelations = false): array
{
$searchableKeys = [];
foreach ((new static())->toSearchableArray() as $key => $value) {
if (
!in_array($key, static::filterableFields()) &&
(!$skipRelations || !method_exists(static::class, $key))
) {
$searchableKeys[] = $key;
}
}
return $searchableKeys;
}
}

26
common/Core/BaseTrustHosts.php Executable file
View File

@@ -0,0 +1,26 @@
<?php
namespace Common\Core;
use Common\Domains\CustomDomainController;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class BaseTrustHosts extends Middleware
{
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
protected function shouldSpecifyTrustedHosts(): bool
{
// allow custom domain validation
if (request()->path() === CustomDomainController::VALIDATE_CUSTOM_DOMAIN_PATH) {
return false;
} else {
return parent::shouldSpecifyTrustedHosts();
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Common\Core;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Http\Request;
class BaseVerifyCsrfToken extends VerifyCsrfToken
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
//
];
/**
* Determine if the request has a URI/Domain that should pass through CSRF verification.
*
* @param Request $request
* @return bool
*/
protected function inExceptArray($request)
{
return true;
if (config('common.site.demo') || config('common.site.disable_csrf')) {
return true;
}
return parent::inExceptArray($request);
}
protected function addCookieToResponse($request, $response)
{
// don't add cookie if session is set to null
// (belink needs to disable laravel headers for 301 redirect)
if (config('session.driver') === null) {
return $response;
}
return parent::addCookieToResponse($request, $response);
}
}

View File

@@ -0,0 +1,213 @@
<?php namespace Common\Core\Bootstrap;
use App\Models\User;
use Common\Admin\Appearance\Themes\CssTheme;
use Common\Auth\Jobs\LogActiveSessionJob;
use Common\Auth\Roles\Role;
use Common\Billing\Gateways\Stripe\FormatsMoney;
use Common\Core\AppUrl;
use Common\Localizations\LocalizationsRepository;
use Common\Settings\Settings;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Jenssegers\Agent\Agent;
use Laravel\Sanctum\PersonalAccessToken;
class BaseBootstrapData implements BootstrapData
{
use FormatsMoney;
protected array $data = [];
public function __construct(
protected Settings $settings,
protected Request $request,
protected Role $role,
protected LocalizationsRepository $localizationsRepository,
) {
}
public function getEncoded(): string
{
if ($this->data['user']) {
$this->data['user'] = $this->data['user']->toArray();
}
return json_encode($this->data);
}
public function set(string $key, mixed $value): static
{
Arr::set($this->data, $key, $value);
return $this;
}
public function get($key = null)
{
return $key ? Arr::get($this->data, $key) : $this->data;
}
public function getSelectedTheme($key = null)
{
$themeId = $this->get('themes.selectedThemeId');
$theme = Arr::first(
$this->data['themes']['all'],
fn($theme) => $theme['id'] === (int) $themeId,
);
if (!$theme) {
$theme = $this->data['themes']['all'][0];
}
$value = $key ? Arr::get($theme, $key) : $theme;
return $key === 'name' ? strtolower($value) : $value;
}
public function init(): self
{
$this->data['settings'] = settings()->getUnflattened();
$this->data['csrf_token'] = csrf_token();
$this->data['is_mobile_device'] = app(Agent::class)->isMobile();
$this->data['settings']['base_url'] = config('app.url');
$this->data['settings']['asset_url'] = config('app.asset_url');
$this->data['settings']['html_base_uri'] = app(
AppUrl::class,
)->htmlBaseUri;
$this->data['settings']['version'] = config('common.site.version');
$this->data['sentry_release'] = config('sentry.release');
$this->data['default_meta_tags'] = $this->getDefaultMetaTags();
$this->data['user'] = $this->getCurrentUser();
$this->data['guest_role'] = app('guestRole')?->load('permissions');
$this->data['i18n'] =
$this->localizationsRepository->getByNameOrCode(
app()->getLocale(),
settings('i18n.enable', true),
) ?:
null;
$this->data['themes'] = $this->getThemes();
$this->data['language'] = $this->data['i18n']
? $this->data['i18n']['language']
: 'en';
if (
config('common.site.notifications_integrated') &&
$this->data['user']
) {
$this->data['user']->loadCount('unreadNotifications');
}
$alreadyAccepted =
!settings('cookie_notice.enable') ||
(bool) Arr::get($_COOKIE, 'cookie_notice', false);
$this->data['show_cookie_notice'] =
!$alreadyAccepted && $this->isCookieLawCountry();
$this->logActiveSession();
return $this;
}
public function getThemes(): array
{
$themes = CssTheme::where('default_dark', true)
->orWhere('default_light', true)
->get();
if ($themes->isEmpty()) {
$themes = CssTheme::limit(2)->get();
}
$selectedTheme = null;
// first, get theme from cookie or url param, if theme change by user is enabled
if (settings('themes.user_change')) {
if ($themeFromUrl = $this->request->get('beThemeId')) {
$selectedTheme = $themes->find($themeFromUrl);
} else {
$selectedTheme = $themes->find(
Arr::get($_COOKIE, 'be-active-theme'),
);
}
}
// if no theme was selected, get default theme specified by admin
if (!$selectedTheme && ($defaultId = settings('themes.default_id'))) {
$selectedTheme = $themes->find($defaultId);
}
// finally, fallback to default light theme
if (!$selectedTheme) {
$selectedTheme = $themes->where('default_light', true)->first();
}
return [
'all' => $themes,
'selectedThemeId' => $selectedTheme?->id,
];
}
/**
* Load current user and his roles.
*/
public function getCurrentUser(): ?User
{
$user = $this->request->user();
if ($user) {
// load user subscriptions, if billing is enabled
if (
settings('billing.enable') &&
!$user->relationLoaded('subscriptions')
) {
$user->load('subscriptions.price');
}
// load user roles, if not already loaded
if (!$user->relationLoaded('roles')) {
$user->load('roles');
}
if (!$user->relationLoaded('permissions')) {
$user->loadPermissions();
}
}
return $user;
}
protected function getDefaultMetaTags(): string
{
$pageName = 'landing-page';
$customPath = storage_path(
"app/editable-views/seo-tags/$pageName.blade.php",
);
if (file_exists($customPath)) {
return view("editable-views::seo-tags.$pageName")->render();
} else {
return view("seo.$pageName.seo-tags")->render();
}
}
protected function isCookieLawCountry(): bool
{
$isoCode = geoip(getIp())['iso_code'];
// prettier-ignore
return in_array($isoCode, ['AT', 'BE', 'BG', 'BR', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'GB', 'HR', 'HU', 'IE', 'IT','LT', 'LU', 'LV', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
]);
}
protected function logActiveSession(): void
{
if ($this->data['user']) {
$token = $this->data['user']->currentAccessToken();
LogActiveSessionJob::dispatch([
'user_id' => $this->data['user']->id,
'ip_address' => getIp(),
'user_agent' => $this->request->userAgent(),
'session_id' => session()->getId(),
'token' =>
$token instanceof PersonalAccessToken
? $token->token
: null,
]);
}
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Common\Core\Bootstrap;
interface BootstrapData
{
public function getEncoded(): string;
public function init(): self;
public function getThemes(): array;
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Common\Core\Bootstrap;
use App\Models\User;
use Common\Localizations\Localization;
use Illuminate\Support\Str;
use Spatie\Color\Hex;
use Spatie\Color\Rgb;
class MobileBootstrapData extends BaseBootstrapData
{
public function init(): self
{
$cssThemes = $this->getThemes()['all'];
$themes = [
'light' => $cssThemes
->where('default_light', true)
->first()
->toArray(),
'dark' => $cssThemes
->where('default_dark', true)
->first()
->toArray(),
];
$themes['light']['values'] = $this->transformValuesForFlutter(
$themes['light']['values'],
);
$themes['dark']['values'] = $this->transformValuesForFlutter(
$themes['dark']['values'],
);
$this->data = [
'themes' => $themes,
'user' => $this->getCurrentUser(),
'menus' => $this->getMobileMenus(),
'settings' => [
'social.google.enable' => (bool) settings(
'social.google.enable',
),
'require_email_confirmation' => (bool) settings(
'require_email_confirmation',
),
'registration.disable' => (bool) settings(
'registration.disable',
),
],
'locales' => Localization::get(),
];
if (settings('i18n.enable')) {
$langCode = request('activeLocale') ?: app()->getLocale();
foreach ($this->data['locales'] as $locale) {
if ($locale->language === $langCode) {
$locale->loadLines();
break;
}
}
}
$this->logActiveSession();
return $this;
}
public function refreshToken(string $deviceName): self
{
$user = $this->data['user'];
if ($user) {
$user['access_token'] = $user->refreshApiToken($deviceName);
$this->loadFcmToken($user);
}
return $this;
}
public function getCurrentUser(): ?User
{
if ($user = $this->request->user()) {
return $this->loadFcmToken($user);
}
return null;
}
private function getMobileMenus(): array
{
return array_values(
array_filter(
settings('menus'),
fn($menu) => collect($menu['positions'])->some(
fn($position) => Str::startsWith($position, 'mobile-app'),
),
),
);
}
private function transformValuesForFlutter(array $colors): array
{
if (!class_exists(Hex::class)) {
return $colors;
}
$radiusValues = [
'--be-button-radius',
'--be-input-radius',
'--be-panel-radius',
];
$valuesToSkip = ['--be-navbar-color'];
return collect($colors)
->map(function ($value, $name) use ($valuesToSkip, $radiusValues) {
if (in_array($name, $radiusValues)) {
if (str_ends_with($value, 'rem')) {
return (float) str_replace('rem', '', $value) * 16;
} else {
return (float) str_replace('px', '', $value);
}
} elseif (in_array($name, $valuesToSkip)) {
return $value;
} elseif (str_ends_with($value, '%')) {
return (int) str_replace('%', '', $value);
} else {
$value = str_replace(' ', ',', $value);
$rgb = Rgb::fromString("rgb($value)");
return [$rgb->red(), $rgb->green(), $rgb->blue(), 1.0];
}
})
->toArray();
}
private function loadFcmToken(User $user): User
{
if (method_exists($user, 'loadFcmToken')) {
$user->loadFcmToken();
}
return $user;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Common\Core\Commands;
use File;
use Illuminate\Console\Command;
use Str;
class GenerateChecksums extends Command
{
/**
* @var string
*/
protected $signature = 'checksums:generate';
public function handle(): int
{
$rootPath = base_path();
$allFiles = File::allFiles($rootPath);
$bar = $this->output->createProgressBar(count($allFiles));
$bar->start();
$checksums = [];
foreach ($allFiles as $file) {
if (Str::startsWith($file->getFilename(), '.')) {
continue;
}
$relativePath = str_replace($rootPath, '', $file->getPathname());
$checksums[$relativePath] = md5_file($file);
$bar->advance();
}
file_put_contents("$rootPath/checksums.json", json_encode($checksums));
$bar->finish();
return 0;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Common\Core\Commands;
use Common\Admin\Sitemap\BaseSitemapGenerator;
use Illuminate\Console\Command;
class GenerateSitemap extends Command
{
protected $signature = 'sitemap:generate';
public function handle()
{
$sitemap = class_exists('App\Services\SitemapGenerator')
? app('App\Services\SitemapGenerator')
: app(BaseSitemapGenerator::class);
$sitemap->generate();
$this->info('Sitemap generated successfully');
}
}

View File

@@ -0,0 +1,37 @@
<?php namespace Common\Core\Commands;
use File;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Seeder;
use Str;
class SeedCommand extends Command
{
protected $signature = 'common:seed';
protected $description = 'Execute all common package seeders.';
public function handle()
{
$paths = collect(File::files(__DIR__ . '/../../Database/Seeds'));
$paths->filter(function($path) {
return Str::endsWith($path, '.php');
})->each(function($path) {
Model::unguarded(function () use ($path) {
$namespace = 'Common\Database\Seeds\\'.basename($path, '.php');
$this->getSeeder($namespace)->__invoke();
});
});
$this->info('Seeded database successfully.');
}
protected function getSeeder(string $namespace): Seeder
{
$class = $this->laravel->make($namespace);
return $class->setContainer($this->laravel)->setCommand($this);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Common\Core\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class UpdateSimplePaginateTables extends Command
{
protected $signature = 'pagination:optimize';
protected $description = 'Optimize pagination for large tables.';
public function handle(): int
{
$max = 150000;
$tables = [];
collect(DB::select('SHOW TABLES'))
->map(function ($val) {
foreach ($val as $key => $tbl) {
return $tbl;
}
})
->each(function ($table) use ($max, &$tables) {
if (DB::table($table)->count() > $max) {
$tables[] = $table;
}
});
settings()->save([
'simple_pagination_tables' => implode(',', $tables),
]);
$this->info(
sprintf(
'Tables with more than %d rows: %s',
$max,
implode(', ', $tables),
),
);
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Common\Core\Contracts;
interface AppUrlGenerator
{
//
}

View File

@@ -0,0 +1,25 @@
<?php namespace Common\Core\Controllers;
use Common\Core\BaseController;
use Common\Core\Bootstrap\BootstrapData;
use Common\Core\Bootstrap\MobileBootstrapData;
use Illuminate\Http\JsonResponse;
class BootstrapController extends BaseController
{
/**
* Get data needed to bootstrap the application.
*
* @param BootstrapData $bootstrapData
* @return JsonResponse
*/
public function getBootstrapData(BootstrapData $bootstrapData)
{
return response()->json(['data' => $bootstrapData->init()->getEncoded()]);
}
public function getMobileBootstrapData(MobileBootstrapData $bootstrapData)
{
return response()->json($bootstrapData->init()->get());
}
}

View File

@@ -0,0 +1,57 @@
<?php namespace Common\Core\Controllers;
use Common\Core\AppUrl;
use Common\Core\BaseController;
use Common\Core\Bootstrap\BootstrapData;
use Common\Settings\Settings;
class HomeController extends BaseController
{
public function __construct(
protected BootstrapData $bootstrapData,
protected Settings $settings,
) {
}
public function show()
{
// only get meta tags if we're actually
// rendering homepage and not a fallback route
$data = [];
if (
request()->path() === '/' &&
($response = $this->handleSeo($data))
) {
return $response;
}
$this->bootstrapData->init();
$view = view('app')
->with('bootstrapData', $this->bootstrapData)
->with('htmlBaseUri', app(AppUrl::class)->htmlBaseUri)
->with('settings', $this->settings)
->with(
'customHtmlPath',
public_path('storage/custom-code/custom-html.html'),
)
->with(
'customCssPath',
public_path('storage/custom-code/custom-styles.css'),
);
if (isset($data['seo'])) {
$view->with('meta', $data['seo']);
}
return response($view);
}
/**
* Render basic client side page with optional SSR when page has no data or seo tags.
* (contact page, login, register, etc.)
*/
public function render() {
return $this->renderClientOrApi([]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Common\Core\Exceptions;
class AccessResponseWithAction extends AccessResponseWithPermission
{
public array|null $action;
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Common\Core\Exceptions;
use Illuminate\Auth\Access\Response as LaravelAccessResponse;
class AccessResponseWithPermission extends LaravelAccessResponse
{
public function __construct(
protected $allowed,
public string|null $permission,
protected $message = '',
protected $code = null,
) {
parent::__construct($allowed, $message, $code);
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace Common\Core\Exceptions;
use Common\Billing\Models\Product;
use ErrorException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Exceptions\Handler;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
use Spatie\Ignition\Ignition;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
use function Sentry\configureScope;
class BaseExceptionHandler extends Handler
{
public function render($request, Throwable $e)
{
$isAuthException =
$e instanceof AuthorizationException ||
($e instanceof HttpException && $e->getStatusCode() === 403);
if (
$isAuthException &&
(requestIsFromFrontend() &&
!$request->expectsJson() &&
!Auth::check())
) {
return redirect('/login');
}
if (
$e instanceof AuthorizationException &&
$e->response() instanceof AccessResponseWithPermission &&
$e->response()->permission &&
Auth::check() &&
settings('billing.enable')
) {
$permissionExistsInSubscriptionPlan = Product::with(['permissions'])
->get()
->some(function ($product) use ($e) {
// check if there's a plan that has this permission and if user is not already on this plan
return $product->permissions->contains(
'name',
$e->response()->permission,
) &&
Auth::user()->subscriptions->first()?->product_id !==
$product->id;
});
if ($permissionExistsInSubscriptionPlan) {
return Auth::user()->subscribed()
? redirect('/billing')
: redirect('/pricing');
}
}
return parent::render($request, $e);
}
public function register()
{
if (config('app.env') !== 'production') {
return;
}
$this->renderable(function (ErrorException $e) {
if (
Str::contains($e->getMessage(), [
'failed to open stream: Permission denied',
'mkdir(): Permission denied',
])
) {
return $this->filePermissionResponse($e);
}
});
configureScope(function (Scope $scope): void {
$scope->setContext('app_name', ['value' => config('app.name')]);
});
$this->reportable(function (Throwable $e) {
Integration::captureUnhandledException($e);
});
}
protected function convertExceptionToArray(Throwable $e): array
{
$previous = $e->getPrevious();
$isValidationException =
$e instanceof HttpException && $e->getStatusCode() === 422;
$isExceptionWithAction =
$previous &&
method_exists($previous, 'response') &&
$previous->response() &&
property_exists($previous->response(), 'action');
if (
config('app.debug') &&
!config('common.site.demo') &&
!$isValidationException
) {
$array = $this->ignitionReportFromThrowable($e);
} else {
$array = parent::convertExceptionToArray($e);
}
if ($isExceptionWithAction) {
$array['action'] = $e->getPrevious()->response()->action;
}
if ($array['message'] === 'Server Error') {
$array['message'] = __(
'There was an issue. Please try again later.',
);
}
if ($array['message'] === 'This action is unauthorized.') {
$array['message'] = __(
"You don't have required permissions for this action.",
);
}
return $array;
}
protected function filePermissionResponse(ErrorException $e)
{
if (request()->expectsJson()) {
return response()->json(['message' => 'test']);
} else {
preg_match('/\((.+?)\):/', $e->getMessage(), $matches);
$path = $matches[1] ?? null;
// should not return a view here, in case laravel views folder is not readable as well
return response(
"<div style='text-align:center'><h1>Could not access a file or folder</h1> <br> Location: <b>$path</b><br>" .
'<p>See the article here for possible solutions: <a target="_blank" href="https://support.vebto.com/hc/articles/21/25/207/changing-file-permissions">https://support.vebto.com/hc/articles/207/changing-file-permissions</a></p></div>',
);
}
}
protected function ignitionReportFromThrowable(Throwable $e): array
{
$report = app(Ignition::class)
->shouldDisplayException(false)
->handleException($e)
->toArray();
$trace = array_map(function ($item) {
$path = Str::of($item['class'] ?? $item['file'])
->replace([base_path(), 'vendor/laravel/framework/src/'], '')
->replace('\\', '/')
->trim('/')
->explode('/');
return [
'applicationFrame' => $item['application_frame'],
'codeSnippet' => $item['code_snippet'],
'path' => $path,
'lineNumber' => $item['line_number'],
'method' => $item['method'],
];
}, $report['stacktrace']);
$flatIndex = 0;
$totalVendorGroups = 0;
$groupedTrace = array_reduce(
$trace,
function ($carry, $item) use (&$flatIndex, &$totalVendorGroups) {
$item['flatIndex'] = $flatIndex;
if ($item['applicationFrame']) {
$carry[] = $item;
} else {
if (Arr::get(Arr::last($carry), 'vendorGroup')) {
$carry[count($carry) - 1]['items'][] = $item;
} else {
$totalVendorGroups++;
$carry[] = [
'vendorGroup' => true,
'items' => [$item],
];
}
}
$flatIndex++;
return $carry;
},
[],
);
return [
'ignitionTrace' => true,
'message' => $report['message'],
'exception' => $report['exception_class'],
'file' => $report['stacktrace'][0]['file'],
'line' => $report['stacktrace'][0]['line_number'],
'trace' => $groupedTrace,
'totalVendorGroups' => $totalVendorGroups,
'phpVersion' => $report['language_version'],
'appVersion' => config('common.site.version'),
];
}
}

75
common/Core/HttpClient.php Executable file
View File

@@ -0,0 +1,75 @@
<?php
namespace Common\Core;
use Common\Settings\Settings;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
class HttpClient
{
/**
* @var Client
*/
private $client;
/**
* @param array $params
*/
public function __construct($params = [])
{
if ( ! isset($params['exceptions'])) $params['exceptions'] = false;
if ( ! isset($params['timeout'])) $params['timeout'] = 2;
$defaultVerify = (bool) app(Settings::class)->get('https.enable_cert_verification', true);
if ( ! isset($params['verify'])) $params['verify'] = $defaultVerify;
$this->client = new Client($params);
}
/**
* @return array|string
*/
public function get(string $url, array $params = [])
{
try {
$r = $this->client->get($url, $params);
} catch (ClientException $e) {
$r = $e->getResponse();
if ($r->getStatusCode() === 429 && $r->hasHeader('Retry-After')) {
$seconds = $r->getHeader('Retry-After') ?: 5;
sleep((int) $seconds);
$r = $this->get($url);
}
}
if ($r->getStatusCode() === 429 && $r->hasHeader('Retry-After')) {
$seconds = $r->getHeader('Retry-After') ? $r->getHeader('Retry-After') : 5;
sleep((int) $seconds);
$r = $this->get($url);
}
$contents = is_string($r) ? $r : $r->getBody()->getContents();
$json = json_decode($contents, true);
return $json ? $json : $contents;
}
/**
* @param string $url
* @param array $params
* @return array
*/
public function post($url, $params = [])
{
$r = $this->client->post($url, $params);
if ($r->getStatusCode() === 429 && $r->hasHeader('Retry-After')) {
$seconds = $r->getHeader('Retry-After') ? $r->getHeader('Retry-After') : 5;
sleep($seconds);
$r = $this->get($url);
}
$contents = $r->getBody()->getContents();
$json = json_decode($contents, true);
return $json ? $json : $contents;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace Common\Core\Install;
class CheckSiteHealth
{
public function execute(): array
{
return $this->performChecks();
}
protected function performChecks(): array
{
$minPhpVersion = $this->getMinimumPhpversion();
$results = collect([
'server' => [
'items' => [
'PHP Version' => [
'passes' => version_compare(
PHP_VERSION,
$minPhpVersion,
'>',
),
'errorMessage' => "You need at least $minPhpVersion PHP version.",
],
],
],
'extensions' => [
'items' => [
'PDO' => [
'passes' => defined('PDO::ATTR_DRIVER_NAME'),
'errorMessage' =>
'PHP PDO extension needs to be enabled.',
],
'XML' => [
'passes' => extension_loaded('xml'),
'errorMessage' =>
'PHP XML extension needs to be enabled.',
],
'Mbstring' => [
'passes' => extension_loaded('mbstring'),
'errorMessage' =>
'PHP mbstring extension needs to be enabled.',
],
'Fileinfo' => [
'passes' => extension_loaded('fileinfo'),
'errorMessage' =>
'PHP fileinfo extension needs to be enabled.',
],
'OpenSSL' => [
'passes' => extension_loaded('openssl'),
'errorMessage' =>
'PHP openssl extension needs to be enabled.',
],
'GD' => [
'passes' => extension_loaded('gd'),
'errorMessage' =>
'PHP GD extension needs to be enabled.',
],
'Curl' => [
'passes' => extension_loaded('curl'),
'errorMessage' =>
'PHP curl extension needs to be enabled.',
],
'Zip' => [
'passes' => class_exists('ZipArchive'),
'errorMessage' =>
'PHP ZipArchive extension needs to be installed.',
],
'fpassthru' => [
'passes' => function_exists('fpassthru'),
'errorMessage' =>
'"fpassthru" PHP function needs to be enabled.',
],
],
],
'filesystem' => $this->checkFilesystemPermissions(),
])->toArray();
$someFailed = false;
foreach ($results as $groupName => $group) {
$results[$groupName]['allPassed'] = collect($group['items'])->every(
'passes',
);
if (!$results[$groupName]['allPassed']) {
$someFailed = true;
}
}
return [
'results' => $results,
'allPassed' => !$someFailed,
];
}
protected function checkFilesystemPermissions(): array
{
$basePath = base_path();
return [
'items' => collect([
'.htaccess',
'public/.htaccess',
config('common.site.installed') ? '.env' : 'env.example',
'storage',
'storage/app',
'storage/logs',
'storage/framework',
'public/storage',
])
->map(function ($directory) use ($basePath) {
$path = rtrim("$basePath/$directory", '/');
if (is_file($path)) {
if (!is_writable($path)) {
@chmod($path, 0664);
}
return [
'path' => $path,
'passes' =>
is_writable($path) && filesize($path) > 0,
'errorMessage' => "Make sure <strong>$path</strong> has been uploaded properly and is writable (0664 permission).",
];
} else {
if (!is_writable($path)) {
@chmod($path, 0775);
}
return [
'path' => $path,
'passes' => is_writable($path),
'errorMessage' => "Make <strong>$path</strong> writable by giving it 0775 or 0777 permissions via file manager.",
];
}
})
->toArray(),
];
}
protected function getMinimumPhpversion(): string
{
$composer = json_decode(
file_get_contents(base_path('composer.json')),
true,
);
preg_match('/(\d+\.\d+\.\d+)/', $composer['require']['php'], $matches);
return $matches[1] ?? '8.1';
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace Common\Core\Install;
use App\Models\User;
use Common\Admin\Appearance\GenerateFavicon;
use Common\Auth\Permissions\Permission;
use Common\Database\MigrateAndSeed;
use Common\Settings\DotEnvEditor;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
class InstallController
{
public function __construct()
{
if (config('common.site.installed')) {
abort(404);
}
}
public function introductionStep()
{
$this->createHtaccessFiles();
return view('common::install/introduction');
}
public function requirementsStep()
{
$data = (new CheckSiteHealth())->execute();
return view('common::install/requirements')->with($data);
}
public function databaseStep()
{
$credentials = config('database.connections.mysql');
return view('common::install/database', [
'host' => $credentials['host'],
'database' => $credentials['database'],
'username' => $credentials['username'],
'password' => $credentials['password'],
'port' => $credentials['port'],
'prefix' => $credentials['prefix'],
]);
}
public function insertAndValidateDatabaseCredentials()
{
$data = request()->validate([
'host' => 'required|string',
'database' => 'required|string',
'username' => 'required|string',
'password' => 'nullable|string',
'port' => 'nullable|int',
'prefix' => 'nullable|string|max:10',
]);
$this->getEnvWriter()->write(
collect($data)->mapWithKeys(
fn($value, $key) => ['DB_' . strtoupper($key) => $value],
),
);
config()->set(
'database.connections.mysql',
array_merge(config('database.connections.mysql'), $data),
);
try {
$tables = DB::select('SHOW TABLES');
if (count($tables) > 0) {
return back()->withErrors([
'database' =>
'Database is not empty. Please provide an empty database or delete all tables from the current one.',
]);
}
} catch (\Exception $e) {
return back()->withErrors([
'database' => $e->getMessage(),
]);
}
if (!file_exists(base_path('.env'))) {
rename(base_path('env.example'), base_path('.env'));
}
return redirect('install/admin');
}
public function adminStep()
{
return view('common::install/admin');
}
public function validateAdminCredentials()
{
$data = request()->validate([
'email' => 'required|email',
'password' => 'required|string|min:4|confirmed',
]);
return redirect('install/finalize')->with('adminCredentials', $data);
}
public function finalizeStep()
{
if (!session()->has('adminCredentials')) {
return redirect('install/admin');
}
// app key
$appKey = 'base64:' . base64_encode(random_bytes(32));
$this->getEnvWriter()->write(['APP_KEY' => $appKey]);
// clear cache
Cache::flush();
Schema::defaultStringLength(191);
// migrate/seed/admin account
(new MigrateAndSeed())->execute(function () {
$credentials = session('adminCredentials');
$user = app(User::class)->firstOrNew([
'email' => $credentials['email'],
]);
$user->password = $credentials['password'];
$user->email_verified_at = now();
$user->save();
$adminPermission = app(Permission::class)->firstOrCreate(
['name' => 'admin'],
[
'name' => 'admin',
'group' => 'admin',
'display_name' => 'Super Admin',
'description' => 'Give all permissions to user.',
],
);
$user->permissions()->syncWithoutDetaching($adminPermission->id);
Auth::login($user);
});
$appUrl = $this->getFinalSiteUrl();
// finalize
$this->getEnvWriter()->write([
'app_url' => $appUrl,
'app_env' => 'production',
'app_debug' => false,
'cache_driver' => 'file',
'installed' => true,
]);
// move default favicons
File::copyDirectory(
base_path('assets/favicons'),
public_path(GenerateFavicon::FAVICON_DIR),
);
Cache::flush();
return view('common::install/finalize')->with([
'url' => $appUrl,
]);
}
protected function getFinalSiteUrl(): string
{
// config('app.url') will already be updated by "AppUrl" class at this point,
// we just need to trim "public" in case .htaccess file was not created for some reason
$url = config('app.url');
$url = rtrim($url, 'public');
return rtrim($url, '/');
}
protected function createHtaccessFiles(): void
{
$rootHtaccess = base_path('.htaccess');
$rootHtaccessStub = base_path('htaccess.example');
$publicHtaccess = public_path('.htaccess');
$publicHtaccessStub = base_path('public/htaccess.example');
if (!file_exists($rootHtaccess)) {
$contents = file_get_contents($rootHtaccessStub);
file_put_contents($rootHtaccess, $contents);
}
if (!file_exists($publicHtaccess)) {
$contents = file_get_contents($publicHtaccessStub);
file_put_contents($publicHtaccess, $contents);
}
}
protected function getEnvWriter(): DotEnvEditor
{
return new DotEnvEditor(
file_exists(base_path('.env')) ? '.env' : 'env.example',
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Common\Core\Install;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class RedirectIfNotInstalledMiddleware
{
public function handle(Request $request, Closure $next)
{
if (
!config('common.site.installed') &&
!Str::contains($request->path(), 'install')
) {
return redirect()->route('install');
}
return $next($request);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Common\Core\Install;
use Common\Database\MigrateAndSeed;
use Common\Settings\DotEnvEditor;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
class UpdateActions
{
public function execute(): void
{
@ini_set('memory_limit', '-1');
@set_time_limit(0);
//fix "index is too long" issue on MariaDB and older mysql versions
Schema::defaultStringLength(191);
app(MigrateAndSeed::class)->execute();
if (
file_exists(base_path('env.example')) &&
file_exists(base_path('.env'))
) {
$envExampleValues = (new DotEnvEditor('env.example'))->load(
'env.example',
);
$currentEnvValues = (new DotEnvEditor())->load();
$envValuesToWrite = array_diff_key(
$envExampleValues,
$currentEnvValues,
);
$envValuesToWrite['app_version'] = $envExampleValues['app_version'];
$envValuesToWrite['installed'] = true;
// mark mail as setup if app was installed before this setting was added.
if (!isset($currentEnvValues['mail_setup'])) {
$envValuesToWrite['mail_setup'] = true;
}
if (
isset($currentEnvValues['scout_driver']) &&
$currentEnvValues['scout_driver'] === 'mysql-like'
) {
$envValuesToWrite['scout_driver'] = 'mysql';
}
(new DotEnvEditor())->write($envValuesToWrite);
}
Cache::flush();
// clear cached views
$path = config('view.compiled');
foreach (File::glob("{$path}/*") as $view) {
File::delete($view);
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Common\Core\Install;
use Illuminate\Console\Command;
class UpdateActionsCommand extends Command
{
protected $signature = 'update:run';
public function handle(): int
{
(new UpdateActions())->execute();
$this->info('Update complete');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,43 @@
<?php namespace Common\Core\Install;
use Common\Core\BaseController;
use Common\Settings\DotEnvEditor;
use Exception;
class UpdateController extends BaseController
{
public function __construct()
{
if (
!config('common.site.disable_update_auth') &&
version_compare(
config('common.site.version'),
$this->getAppVersion(),
) === 0
) {
$this->middleware('isAdmin');
}
}
public function show()
{
$data = (new CheckSiteHealth())->execute();
return view('common::install/update')->with($data);
}
public function performUpdate()
{
(new UpdateActions())->execute();
return view('common::install/update-complete');
}
private function getAppVersion(): string
{
try {
return (new DotEnvEditor('env.example'))->load()['app_version'];
} catch (Exception $e) {
return config('common.site.version');
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Common\Core\Manifest;
use Common\Core\AppUrl;
use Common\Settings\Settings;
class BuildManifestFile
{
public function __construct(protected Settings $settings)
{
}
public function execute(): void
{
$primaryColor = config('common.themes.light.--be-primary');
$bgColor = config('common.themes.light.--be-background');
$replacements = [
'DUMMY_NAME' => config('app.name'),
'DUMMY_SHORT_NAME' => config('app.name'),
'DUMMY_THEME_COLOR' => "rgb($primaryColor)",
'DUMMY_BACKGROUND_COLOR' => "rgb($bgColor)",
'DUMMY_START_URL' => app(AppUrl::class)->htmlBaseUri,
];
@file_put_contents(
public_path('manifest.json'),
str_replace(
array_keys($replacements),
$replacements,
file_get_contents(__DIR__ . '/manifest-example.json'),
),
);
}
}

View File

@@ -0,0 +1,51 @@
{
"name": "DUMMY_NAME",
"short_name": "DUMMY_SHORT_NAME",
"theme_color": "DUMMY_THEME_COLOR",
"background_color": "DUMMY_BACKGROUND_COLOR",
"display": "standalone",
"scope": "DUMMY_START_URL",
"start_url": "DUMMY_START_URL",
"icons": [
{
"src": "favicon/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "favicon/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "favicon/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "favicon/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "favicon/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "favicon/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "favicon/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "favicon/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Common\Core\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class EnableDebugIfLoggedInAsAdmin
{
public function handle(Request $request, Closure $next)
{
if ($this->loggedInAsAdmin()) {
config(['app.debug' => true]);
}
return $next($request);
}
protected function loggedInAsAdmin(): bool
{
return Auth::user() && Auth::user()->hasPermission('admin');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Common\Core\Middleware;
use Closure;
use Common\Settings\Settings;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified as LaravelMiddleware;
class EnsureEmailIsVerified extends LaravelMiddleware
{
public function handle($request, Closure $next, $redirectToRoute = null)
{
// bail if user is not logged in, it will be handled by policies
// also bail if email verification is disabled from settings page
if (
!$request->user() ||
!app(Settings::class)->get('require_email_confirmation')
) {
return $next($request);
}
return parent::handle($request, $next, $redirectToRoute);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Common\Core\Middleware;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful as LaravelMiddleware;
class EnsureFrontendRequestsAreStateful extends LaravelMiddleware
{
public static function fromFrontend($request): bool
{
$domain =
$request->headers->get('referer') ?:
$request->headers->get('origin');
if (is_null($domain)) {
return false;
}
// make sure api calls from api docs page are not considered stateful to avoid 419 errors on POST requests
if (Str::contains($domain, '/api-docs')) {
return false;
}
$domain = parse_url($domain, PHP_URL_HOST);
$domain = Str::replaceFirst('www.', '', $domain);
$domain = Str::endsWith($domain, '/') ? $domain : "{$domain}/";
$stateful = [
...array_filter(config('sanctum.stateful', [])),
parse_url(config('app.url'), PHP_URL_HOST),
];
return Str::is(
Collection::make($stateful)
->map(
fn($uri) => Str::replaceFirst('www.', '', trim($uri)) .
'/*',
)
->all(),
$domain,
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Common\Core\Middleware;
use Auth;
use Closure;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
class IsAdmin
{
public function handle(Request $request, Closure $next)
{
if ( ! Auth::check()) {
throw new AuthenticationException();
}
if ( ! Auth::user()->hasPermission('admin')) {
throw new AuthorizationException();
}
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Common\Core\Middleware;
use Closure;
use Illuminate\Http\Request;
class JsonMiddleware
{
public function handle(Request $request, Closure $next)
{
$request->headers->set('Accept', 'application/json');
//$request->headers->set('Content-Type', 'application/json');
return $next($request);
}
}

View File

@@ -0,0 +1,92 @@
<?php namespace Common\Core\Middleware;
use Closure;
use Common\Core\Controllers\HomeController;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PrerenderIfCrawler
{
protected array $crawlerUserAgents = [
'Yahoo! Slurp',
'bingbot',
'yandex',
'baiduspider',
'facebookexternalhit',
'twitterbot',
'rogerbot',
'linkedinbot',
'embedly',
'quora link preview',
'showyoubot',
'outbrain',
'pinterest/0.',
'slackbot',
'vkShare',
'W3C_Validator',
'redditbot',
'Applebot',
'WhatsApp',
'flipboard',
'tumblr',
'bitlybot',
'SkypeUriPreview',
'nuzzel',
'Discordbot',
'Qwantify',
'pinterestbot',
'Bitrix link preview',
'XING-contenttabreceiver',
'developers.google.com/+/web/snippet',
];
public function handle(
Request $request,
Closure $next,
string $routeName = null
) {
if ($this->shouldPrerender($request)) {
define('SHOULD_PRERENDER', true);
// Always fallback to client routes if not prerendering
// otherwise prerender routes will override client side routing
} elseif ($routeName !== 'homepage') {
return app(HomeController::class)->show();
}
return $next($request);
}
protected function shouldPrerender(Request $request): bool
{
$userAgent = strtolower($request->server->get('HTTP_USER_AGENT'));
$bufferAgent = $request->server->get('X-BUFFERBOT');
$shouldPrerender = false;
if (!$userAgent) {
return false;
}
if (!$request->isMethod('GET')) {
return false;
}
// prerender if _escaped_fragment_ is in the query string
if ($request->query->has('_escaped_fragment_')) {
$shouldPrerender = true;
}
// prerender if a crawler is detected
foreach ($this->crawlerUserAgents as $crawlerUserAgent) {
if (Str::contains($userAgent, strtolower($crawlerUserAgent))) {
$shouldPrerender = true;
}
}
if ($bufferAgent) {
$shouldPrerender = true;
}
return $shouldPrerender;
}
}

View File

@@ -0,0 +1,177 @@
<?php namespace Common\Core\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class RestrictDemoSiteFunctionality
{
public function handle(Request $request, Closure $next)
{
if (
Auth::user() &&
Auth::user()->email === 'Ic0OdCIodqz8q1r@demo.com'
) {
return $next($request);
}
$uri = str_replace(
['secure/', 'api/v1/'],
'',
$request->route()->uri(),
);
if ($this->shouldForbidRequest($request, $uri)) {
abort(403, "You can't do that on demo site.");
}
if ($uri === 'settings') {
return $this->manglePrivateSettings($next($request));
}
if ($uri === 'users' || $uri === 'billing/subscriptions') {
return $this->mangleUserEmails($next($request));
}
if (
($uri === 'billing/stripe/cards/add' ||
$uri === 'billing/subscriptions/paypal/agreement/create') &&
(Auth::user() && Auth::user()->email === 'admin@admin.com')
) {
abort(403, "Demo admin account can't subscribe to plans.");
}
return $next($request);
}
/**
* Check if specified request should be forbidden on demo site.
*/
private function shouldForbidRequest(Request $request, string $uri): bool
{
$method = $request->method();
foreach (config('common.demo-blocked-routes') as $route) {
if (
$method === $route['method'] &&
trim($uri) === trim($route['name'])
) {
$originMatches = true;
$paramsMatch = true;
//block this request only if it originated from specified origin, for example: admin area
if (isset($route['origin'])) {
$originMatches = Str::contains(
$request->server('HTTP_REFERER'),
$route['origin'],
);
}
if (isset($route['params'])) {
$paramsMatch =
collect($route['params'])->first(function (
$param,
$key
) use ($request) {
$routeParam = $request->route($key);
if (is_array($param)) {
return in_array($routeParam, $param);
} else {
return $routeParam == $param;
}
}) !== null;
}
return $originMatches && $paramsMatch;
}
}
return false;
}
/**
* Mangle settings values, so they are not visible on demo site.
*/
private function manglePrivateSettings(Response $response): Response
{
$serverKeys = [
'google_id',
'google_secret',
'twitter_id',
'twitter_secret',
'facebook_id',
'facebook_secret',
'spotify_id',
'spotify_secret',
'lastfm_api_key',
'soundcloud_api_key',
'sentry_dns',
'mailgun_secret',
'sentry_dsn',
'paypal_client_id',
'pusher_key',
'pusher_secret',
'paypal_secret',
'stripe_key',
'stripe_secret',
'mail_password',
'tmdb_api_key',
'storage_digitalocean_key',
'storage_digitalocean_secret',
'stripe_webhook_secret',
'openai_api_key',
];
$clientKeys = [
'youtube_api_key',
'logging.sentry_public',
'analytics.google_id',
'builder.google_fonts_api_key',
'recaptcha.site_key',
'recaptcha.secret_key',
];
$settings = json_decode($response->getContent(), true);
foreach ($serverKeys as $key) {
if (isset($settings['server'][$key])) {
$settings['server'][$key] = Str::random(30);
}
}
foreach ($clientKeys as $key) {
if (isset($settings['client'][$key])) {
$settings['client'][$key] = Str::random(30);
}
}
$response->setContent(json_encode($settings));
return $response;
}
/**
* Mangle settings values, so they are not visible on demo site.
*/
private function mangleUserEmails(Response $response): Response
{
$pagination = json_decode($response->getContent(), true);
$pagination['data'] = array_map(function ($item) {
if (isset($item['email'])) {
$item['email'] = 'hidden@demo.com';
} elseif (isset($item['user']['email'])) {
$item['user']['email'] = 'hidden@demo.com';
}
return $item;
}, Arr::get($pagination, 'data', []));
$response->setContent(json_encode($pagination));
return $response;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Common\Core\Middleware;
use Closure;
use Common\Localizations\Localization;
use Common\Localizations\UserLocaleController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Negotiation\LanguageNegotiator;
class SetAppLocale
{
public function handle(Request $request, Closure $next)
{
if (settings('i18n.enable')) {
// 1. Check if current user has manually selected a specific language
$langCode =
$request->get('lang') ??
($request->user()->language ??
Cookie::get(UserLocaleController::COOKIE_NAME));
$defaultLocale = settings('locale.default', 'auto');
// 2. if admin manually selected a specific default locale, use that
if (!$langCode && $defaultLocale && $defaultLocale !== 'auto') {
$langCode = $defaultLocale;
}
// 3. Try to use language based on browser settings
if (!$langCode && ($header = $request->header('Accept-Language'))) {
$languages = Localization::pluck('language');
if ($languages->isNotEmpty()) {
$bestLanguage = (new LanguageNegotiator())->getBest(
$header,
$languages->toArray(),
);
$langCode = $bestLanguage?->getBasePart();
}
}
if ($langCode) {
app()->setLocale($langCode);
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Common\Core\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Sentry\State\Scope;
use function Sentry\configureScope;
class SetSentryUserMiddleware
{
public function handle(Request $request, Closure $next)
{
if ($user = Auth::user()) {
configureScope(function (Scope $scope) use ($user) {
$scope->setUser(['email' => $user->email, 'id' => $user->id]);
});
}
return $next($request);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Common\Core\Middleware;
use Closure;
use Illuminate\Http\Request;
class SimulateSlowConnectionMiddleware
{
public function handle(Request $request, Closure $next)
{
if ($speed = config('common.site.simulated_connection')) {
if ($speed === 'medium') {
// 200ms
usleep(200000);
} elseif ($speed === 'slow') {
// 1s
sleep(1);
}
}
return $next($request);
}
}

View File

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

View File

@@ -0,0 +1,19 @@
<?php namespace Common\Core\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class AppearancePolicy
{
use HandlesAuthorization;
public function index(User $user)
{
return $user->hasPermission('appearance.index');
}
public function update(User $user)
{
return $user->hasPermission('appearance.update');
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Common\Core\Policies;
use App\Models\User;
use Common\Core\Exceptions\AccessResponseWithAction;
use Common\Core\Exceptions\AccessResponseWithPermission;
use Common\Settings\Settings;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
use Illuminate\Http\Request;
use Str;
abstract class BasePolicy
{
use HandlesAuthorization;
public function __construct(
protected Request $request,
protected Settings $settings,
) {
}
protected function denyWithAction(
$message,
array|null $action = null,
): AccessResponseWithAction {
/** @var AccessResponseWithAction $response */
$response = AccessResponseWithAction::deny($message);
$response->action = $action;
return $response;
}
protected function storeWithCountRestriction(
User $user,
string $namespace,
): Response {
[
$relationName,
$permission,
$singularName,
$pluralName,
] = $this->parseNamespace($namespace);
// user can't create resource at all
if (!$this->hasPermission($user, $permission)) {
return Response::deny();
}
// user is admin, can ignore count restriction
if ($user->hasPermission('admin')) {
return Response::allow();
}
// user does not have any restriction on maximum resource count
$maxCount = $user->getRestrictionValue($permission, 'count');
if (!$maxCount) {
return Response::allow();
}
// check if user did not go over their max quota
if ($user->$relationName->count() >= $maxCount) {
$message = __('policies.quota_exceeded', [
'resources' => $pluralName,
'resource' => $singularName,
]);
return $this->denyWithAction($message, $this->upgradeAction());
}
return Response::allow();
}
protected function hasPermission(?User $user, string $permission): bool
{
$model = $user ?: app('guestRole');
return $model?->hasPermission($permission) ?? false;
}
protected function authorizePermission(
?User $user,
string $permission,
): AccessResponseWithPermission {
return new AccessResponseWithPermission(
$this->hasPermission($user, $permission),
$permission,
'',
403,
);
}
protected function parseNamespace(
string $namespace,
string $ability = 'create',
): array {
// 'App\SomeModel' => 'Some_Model'
$resourceName = Str::snake(class_basename($namespace));
// 'Some_Model' => 'someModels'
$relationName = Str::camel(Str::plural($resourceName));
// 'Some_Model' => 'Some Model'
$singularName = str_replace('_', ' ', $resourceName);
// 'Some Model' => 'Some Models'
$pluralName = Str::plural($singularName);
// parent might need to override permission name. custom_domains instead of links_domains for example.
$permissionName = $this->permissionName ?? Str::snake($relationName);
return [
$relationName,
"$permissionName.$ability",
$singularName,
$pluralName,
];
}
protected function upgradeAction(): ?array
{
if ($this->settings->get('billing.enable')) {
return ['label' => __('Upgrade'), 'action' => '/pricing'];
} else {
return null;
}
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace Common\Core\Policies;
use App\Models\User;
use Arr;
use Common\Files\FileEntry;
use Common\Files\FileEntryUser;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Collection;
use Laravel\Sanctum\PersonalAccessToken;
class FileEntryPolicy extends BasePolicy
{
public function index(
?User $user,
array $entryIds = null,
int $userId = null,
): bool {
if ($entryIds) {
return $this->userCan($user, 'files.view', $entryIds);
} else {
return $user->hasPermission('files.view') || $userId === $user->id;
}
}
public function show(?User $user, FileEntry $entry): bool
{
$token = $this->getAccessTokenFromRequest();
if ($token) {
if ($entry->preview_token === $token) {
return true;
} elseif (
$accessToken = app(PersonalAccessToken::class)->findToken(
$token,
)
) {
$user = $accessToken->tokenable;
}
}
return $user && $this->userCan($user, 'files.view', $entry);
}
public function download(User $user, $entries): bool
{
$token = $this->getAccessTokenFromRequest();
if ($token) {
$previewTokenMatches = collect($entries)->every(function (
$entry,
) use ($token) {
return $entry['preview_token'] === $token;
});
if ($previewTokenMatches) {
return true;
} elseif (
$accessToken = app(PersonalAccessToken::class)->findToken(
$token,
)
) {
$user = $accessToken->tokenable;
}
}
return $this->userCan($user, 'files.download', $entries);
}
public function store(User $user, int $parentId = null): bool
{
//check if user can modify parent entry (if specified)
if ($parentId) {
return $this->userCan($user, 'files.update', [$parentId]);
}
return $user->hasPermission('files.create');
}
public function update(User $user, Collection|array|FileEntry $entries)
{
return $this->userCan($user, 'files.update', $entries);
}
/**
* @param User $user
* @param Collection|array|FileEntry $entries
* @return bool
*/
public function destroy(User $user, $entries)
{
return $this->userCan($user, 'files.delete', $entries);
}
/**
* @param User $currentUser
* @param string $permission
* @param FileEntry|array|Collection $entries
* @return bool
*/
protected function userCan(User $currentUser, string $permission, $entries)
{
if ($currentUser->hasPermission($permission)) {
return true;
}
$entries = $this->findEntries($entries);
// extending class might use "findEntries" method so we load users here
if (!$entries->every->relationLoaded('users')) {
$entries->load([
'users' => function (MorphToMany $builder) use ($currentUser) {
$builder->where('users.id', $currentUser->id);
},
]);
}
return $entries->every(function (FileEntry $entry) use (
$permission,
$currentUser,
) {
$user = $entry->users->find($currentUser->id);
return $this->userOwnsEntryOrWasGrantedPermission(
$user,
$permission,
);
});
}
/**
* @param null|array|FileEntryUser $user
* @param string $permission
* @return bool
*/
public function userOwnsEntryOrWasGrantedPermission(
$user,
string $permission,
) {
return $user &&
($user['owns_entry'] ||
Arr::get(
$user['entry_permissions'],
$this->sharedFilePermission($permission),
));
}
protected function findEntries(
FileEntry|array|Collection $entries,
): Collection {
if ($entries instanceof FileEntry) {
return $entries->newCollection([$entries]);
} elseif (isset($entries[0]) && is_numeric($entries[0])) {
return app(FileEntry::class)
->whereIn('id', $entries)
->get();
} else {
return $entries;
}
}
protected function sharedFilePermission($fullPermission): string
{
switch ($fullPermission) {
case 'files.view':
return 'view';
case 'files.create':
case 'files.update':
return 'edit';
case 'files.delete':
return 'delete';
case 'files.download':
return 'download';
}
}
protected function getAccessTokenFromRequest(): ?string
{
if ($token = request()->bearerToken()) {
return $token;
} elseif ($token = request()->get('preview_token')) {
return $token;
} elseif ($token = request()->get('accessToken')) {
return $token;
} else {
return null;
}
}
}

View File

@@ -0,0 +1,34 @@
<?php namespace Common\Core\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class LocalizationPolicy
{
use HandlesAuthorization;
public function index(?User $user)
{
return $user->hasPermission('localizations.view');
}
public function show(?User $user)
{
return $user->hasPermission('localizations.view');
}
public function store(User $user)
{
return $user->hasPermission('localizations.create');
}
public function update(User $user)
{
return $user->hasPermission('localizations.update');
}
public function destroy(User $user)
{
return $user->hasPermission('localizations.delete');
}
}

View File

@@ -0,0 +1,24 @@
<?php namespace Common\Core\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class MailTemplatePolicy
{
use HandlesAuthorization;
public function index(User $user)
{
return $user->hasPermission('mail_templates.view');
}
public function show(User $user)
{
return $user->hasPermission('mail_templates.view');
}
public function update(User $user)
{
return $user->hasPermission('mail_templates.update');
}
}

View File

@@ -0,0 +1,40 @@
<?php namespace Common\Core\Policies;
use App\Models\User;
use Common\Pages\CustomPage;
class PagePolicy extends BasePolicy
{
public function index(?User $user, int $userId = null)
{
return $user->hasPermission('custom_pages.view') || $user->id === $userId;
}
public function show(?User $user, CustomPage $customPage)
{
return $user->hasPermission('custom_pages.view') || $customPage->user_id === $user->id;
}
public function store(User $user)
{
return $user->hasPermission('custom_pages.create');
}
public function update(User $user)
{
return $user->hasPermission('custom_pages.update');
}
public function destroy(User $user, $pageIds)
{
if ($user->hasPermission('custom_pages.delete')) {
return true;
} else {
$dbCount = app(CustomPage::class)
->whereIn('id', $pageIds)
->where('user_id', $user->id)
->count();
return $dbCount === count($pageIds);
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Common\Core\Policies;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class ProductPolicy extends BasePolicy
{
public function index(?User $user): bool|Response
{
return settings('billing.enable') || $user->hasPermission('plans.view');
}
public function show(?User $user): bool|Response
{
return settings('billing.enable') || $user->hasPermission('plans.view');
}
public function store(User $user): bool|Response
{
return $user->hasPermission('plans.create');
}
public function update(User $user): bool|Response
{
return $user->hasPermission('plans.update');
}
public function destroy(User $user): bool|Response
{
return $user->hasPermission('plans.delete');
}
}

View File

@@ -0,0 +1,14 @@
<?php namespace Common\Core\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class ReportPolicy
{
use HandlesAuthorization;
public function index(User $user)
{
return $user->hasPermission('admin.access');
}
}

View File

@@ -0,0 +1,35 @@
<?php namespace Common\Core\Policies;
use App\Models\User;
use Common\Auth\Roles\Role;
use Illuminate\Auth\Access\HandlesAuthorization;
class RolePolicy
{
use HandlesAuthorization;
public function index(User $user): bool
{
return $user->hasPermission('roles.view');
}
public function show(User $user): bool
{
return $user->hasPermission('roles.show');
}
public function store(User $user): bool
{
return $user->hasPermission('roles.create');
}
public function update(User $user): bool
{
return $user->hasPermission('roles.update');
}
public function destroy(User $user, Role $role): bool
{
return !$role->internal && $user->hasPermission('roles.delete');
}
}

View File

@@ -0,0 +1,19 @@
<?php namespace Common\Core\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class SettingPolicy
{
use HandlesAuthorization;
public function index(User $user)
{
return $user->hasPermission('settings.view');
}
public function update(User $user)
{
return $user->hasPermission('settings.update');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Common\Core\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class SubscriptionPolicy
{
use HandlesAuthorization;
public function index(User $user)
{
return $user->hasPermission('subscription.view');
}
public function show(User $user)
{
return $user->hasPermission('subscription.view');
}
public function store(User $user)
{
return $user->hasPermission('subscription.create');
}
public function update(User $user)
{
return $user->hasPermission('subscription.update');
}
public function destroy(User $user)
{
return $user->hasPermission('subscription.delete');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Common\Core\Policies;
use App\Models\User;
class TagPolicy extends BasePolicy
{
public function index(?User $user)
{
return $this->hasPermission($user, 'tags.view');
}
public function show(?User $user)
{
return $this->hasPermission($user, 'tags.view');
}
public function store(User $user)
{
return $this->hasPermission($user, 'tags.create');
}
public function update(User $user)
{
return $this->hasPermission($user, 'tags.update');
}
public function destroy(User $user)
{
return $this->hasPermission($user, 'tags.delete');
}
}

View File

@@ -0,0 +1,56 @@
<?php namespace Common\Core\Policies;
use App\Models\User;
class UserPolicy extends BasePolicy
{
public function index(?User $user)
{
return $this->hasPermission($user, 'users.view');
}
public function show(?User $current, User $requested)
{
return $this->hasPermission($current, 'users.view') ||
$current->id === $requested->id;
}
public function store(User $user)
{
return $this->hasPermission($user, 'users.create');
}
public function update(User $current, User $toUpdate = null)
{
// user has proper permissions
if ($this->hasPermission($current, 'users.update')) {
return true;
}
// no permissions and not trying to update his own model
if (!$toUpdate || $current->id !== $toUpdate->id) {
return false;
}
// user should not be able to change his own permissions or roles
if (
$this->request->get('permissions') ||
$this->request->get('roles')
) {
return false;
}
return true;
}
public function destroy(User $user, array $userIds)
{
$deletingOwnAccount = collect($userIds)->every(function (
int $userId
) use ($user) {
return $userId === $user->id;
});
return $deletingOwnAccount || $this->hasPermission($user, 'users.delete');
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Common\Core\Prerender\Actions;
use Common\Core\Contracts\AppUrlGenerator;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class ReplacePlaceholders
{
private array $allData;
public function __construct(protected AppUrlGenerator $urls)
{
}
public function execute(array|string $config, array $data): array|string
{
$this->allData = $data;
return $this->replace($config);
}
private function replace(array|string $config): array|string
{
if (is_array($config)) {
if (array_key_exists('_ifNotNull', $config)) {
if (is_null(Arr::get($this->allData, $config['_ifNotNull']))) {
return [];
}
unset($config['_ifExists']);
}
if (Arr::get($config, '_type') === 'loop') {
return $this->replaceLoop($config);
} else {
return array_map(function ($item) {
return $this->replace($item);
}, $config);
}
} else {
return $this->replaceString($config, $this->allData);
}
}
private function replaceLoop(array $config): array
{
$dataSelector = strtolower($config['dataSelector']);
$loopData = Arr::get($this->allData, $dataSelector);
// won't be able to access paginator data via dot notation
// selector (items.data), need to extract it manually
if ($loopData instanceof AbstractPaginator) {
$loopData = $loopData->items();
}
$loopData = collect($loopData);
// apply filter (if provided), filter will specify which array
// prop of loop item should match what value. For example:
// ['key' => 'pivot.department', 'value' => 'cast' will get
// only cast from movie credits array instead of full credits
if ($filter = Arr::get($config, 'filter')) {
$loopData = $loopData->filter(function ($loopItem) use ($filter) {
return Arr::get($loopItem, $filter['key']) === $filter['value'];
});
}
if ($limit = Arr::get($config, 'limit')) {
$loopData = $loopData->slice(0, $limit);
}
// if _type is "nested" we only need to return the first item so instead
// of nested [['name' => 'foo'], ['name' => 'bar']] only ['name' => 'foo']
if ($returnFirstOnly = Arr::get($config, 'returnFirstOnly')) {
$loopData->slice(0, 1);
}
$generated = collect($loopData)->map(function ($loopItem) use (
$config,
) {
// make sure template can access data via dot notation (TAG.NAME)
// so instead of passing just tag, pass ['tag' => $tag]
$model = is_array($loopItem)
? modelTypeToNamespace($loopItem['model_type'])
: $loopItem;
$name = strtolower(class_basename($model));
return $this->replaceString($config['template'], [
$name => $loopItem,
]);
});
return $returnFirstOnly
? $generated->first()
: $generated->values()->toArray();
}
private function replaceString(mixed $template, array $originalData): mixed
{
$data = [];
foreach ($originalData as $key => $value) {
$data[Str::lower($key)] = $value;
}
return preg_replace_callback(
'/{{([\w\.\-\?\:]+?)}}/',
function ($matches) use ($data) {
if (!isset($matches[1])) {
return $matches[0];
}
$placeholder = $matches[1];
// replace site name
if ($placeholder === 'site_name') {
return config('app.name');
}
// replace base url
if ($placeholder === 'url.base') {
return url('');
}
// replace by url generator url
if (Str::startsWith($placeholder, 'url.')) {
// "url.movie" => "movie"
$resource = str_replace('url.', '', $placeholder);
// "new_releases" => "newReleases"
$method = Str::camel($resource);
return $this->urls->$method(
Arr::get($data, $resource) ?: $data, $data,
);
}
// replace placeholder with actual value.
// supports dot notation: 'artist.bio.text' as well as ?:
$replacement = $this->findUsingDotNotation($data, $placeholder);
// prefix relative image urls with base site url
if ($replacement && Str::startsWith($replacement, 'storage/')) {
$replacement = config('app.url') . "/$replacement";
url();
}
// return null if we could not replace placeholder
if (!$replacement) {
return null;
}
return Str::limit(
strip_tags(
$this->replaceString($replacement, $data),
'<br>',
),
400,
);
},
$template,
);
}
private function findUsingDotNotation(array $data, string $item)
{
foreach (explode('?:', $item) as $itemVariant) {
if ($value = Arr::get($data, $itemVariant)) {
return $value;
}
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Common\Core\Prerender;
use Common\Core\Contracts\AppUrlGenerator;
use Common\Pages\CustomPage;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class BaseUrlGenerator implements AppUrlGenerator
{
const SEPARATOR = '-';
public function customPage(array|CustomPage $page): string
{
if (isset($page['page'])) {
$originalSlug = $page['page']['slug'];
} else {
$originalSlug = $page['slug'];
}
$slug = slugify($originalSlug);
return url("pages/$slug");
}
public function home(): string
{
return url('');
}
/**
* @param Model|array $model
*/
public function generate($model): string
{
$method =
$model instanceof Model ? $model::MODEL_TYPE : $model['modelType'];
return $this->$method($model);
}
public function __call(string $name, array $arguments): string
{
return url(Str::kebab($name));
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Common\Core\Prerender;
use Arr;
use Common\Core\AppUrl;
use Illuminate\Support\Collection;
use Request;
use Str;
trait HandlesSeo
{
protected function handleSeo(
array|Collection &$data = [],
array $options = []
) {
if (Request::method() === 'GET') {
$data['seo'] = $this->getMetaTags($data, $options) ?: null;
}
if (defined('SHOULD_PRERENDER')) {
$viewName =
Arr::get($options, 'prerender.view') ?:
$this->namespaceFromRouteAction();
$viewPath = "prerender.$viewName";
$view = null;
// load view from app views folder or fall back to common views otherwise
if (view()->exists($viewPath)) {
$view = view($viewPath);
} else {
$view = view("common::$viewPath");
}
return response(
$view->with([
'meta' => $data['seo'],
'htmlBaseUri' => app(AppUrl::class)->htmlBaseUri,
]),
);
}
}
protected function getMetaTags($data = [], $options = []): ?MetaTags
{
$namespace = Arr::get(
$options,
'prerender.config',
$this->namespaceFromRouteAction(),
);
if ($seoConfig = config("seo.$namespace")) {
$dataForSeo = Arr::get($options, 'prerender.dataForSeo') ?: $data;
return new MetaTags($seoConfig, $dataForSeo, $namespace);
}
return null;
}
protected function namespaceFromRouteAction(): string
{
// 'App/Http/Controllers/ArtistController@show'
$uses = request()->route()->action['uses'];
// get resource name and verb from route action
preg_match('/\\\(\w+?)Controller@(\w+)$/', $uses, $matches);
$resource = Str::kebab($matches[1]);
$verb = Str::kebab($matches[2]);
return "$resource.$verb";
}
}

View File

@@ -0,0 +1,274 @@
<?php
namespace Common\Core\Prerender;
use Arr;
use Common\Core\Contracts\AppUrlGenerator;
use Common\Core\Prerender\Actions\ReplacePlaceholders;
use Common\Settings\Settings;
use Illuminate\Contracts\Support\Arrayable;
use Str;
class MetaTags implements Arrayable
{
/**
* Tag types that can be edited by the user.
*/
const EDITABLE_TAGS = ['og:title', 'og:description', 'keywords'];
/**
* Data for replacing meta tag config placeholders.
*/
protected array $data = [];
/**
* Meta tag config before generation.
*/
protected array $tags = [];
/**
* Final tags for appending to site head.
*/
protected array $generatedTags = [];
/**
* Namespace for current tag config. "artist.show".
*
* @var string
*/
protected string $namespace;
public AppUrlGenerator $urls;
private Settings $settings;
public function __construct($tags, $data, $namespace)
{
$this->namespace = $namespace;
$this->data = $data;
$tags = $this->overrideTagsWithUserValues($tags);
$this->tags = array_merge($tags, config('seo.common'));
$this->urls = app(AppUrlGenerator::class);
$this->settings = app(Settings::class);
$this->generatedTags = $this->generateTags();
}
public function toArray()
{
// remove all tags to which placeholders could not be replaced with actual content
$tags = collect($this->getAll())->map(function ($tag) {
// ld+json tags will contain child arrays
$strings = array_filter($tag, function ($value) {
return !is_array($value);
});
$content = implode($strings);
$shouldRemove =
Str::contains($content, '{{') && Str::contains($content, '}}');
// if could not replace title placeholder, return app name as title instead
if ($shouldRemove && $tag['nodeName'] === 'title') {
$tag['_text'] = config('app.name');
$shouldRemove = false;
}
if ($shouldRemove && Arr::get($tag, 'property') === 'og:title') {
$tag['content'] = config('app.name');
$shouldRemove = false;
}
return $shouldRemove ? null : $tag;
});
return $tags
->filter()
->values()
->toArray();
}
public function getTitle()
{
return $this->get('og:title');
}
public function getDescription()
{
return $this->get('og:description');
}
public function getImage()
{
return url($this->get('og:image'));
}
public function getMenu(string $position): array
{
$default = ['items' => []];
return Arr::first(
settings('menus'),
fn($menu) => in_array($position, $menu['positions']),
$default,
);
}
public function get($value, $prop = 'property')
{
$tag = Arr::first(
$this->generatedTags,
function ($tag) use ($prop, $value) {
return Arr::get($tag, $prop) === $value;
},
[],
);
return Arr::get($tag, 'content');
}
public function getData($selector = null, $default = null)
{
return Arr::get($this->data, $selector) ?? $default;
}
public function getAll(): array
{
return $this->generatedTags;
}
/**
* Convert specified tag config into a string.
*
* @param array $tag
* @return string
*/
public function tagToString($tag)
{
$string = '';
foreach (Arr::except($tag, 'nodeName') as $key => $value) {
$value = is_array($value) ? implode(',', $value) : $value;
$string .= "$key=\"$value\" ";
}
return trim($string);
}
private function generateTags(): array
{
$tags = $this->tags;
$tags = array_map(function ($tag) {
return $this->replacePlaceholders($tag);
}, $tags);
$tags = $this->removeEmptyValues($tags);
$tags = $this->duplicateTags($tags);
$tags = $this->escapeValues($tags);
$tags = array_map(function ($tag) {
// set nodeName to <meta> tag, if not already specified
if (!array_key_exists('nodeName', $tag)) {
$tag['nodeName'] = 'meta';
}
return $tag;
}, $tags);
return $tags;
}
function removeEmptyValues(array &$array): array
{
foreach ($array as $key => &$value) {
if (is_array($value)) {
$value = $this->removeEmptyValues($value);
}
if (empty($value)) {
unset($array[$key]);
}
}
return $array;
}
private function replacePlaceholders(array $tag): array
{
$replacer = app(ReplacePlaceholders::class);
// if tag does not have "content" or "_text" prop, we can continue
if (array_key_exists('content', $tag)) {
$tag['content'] = $replacer->execute($tag['content'], $this->data);
} elseif (array_key_exists('_text', $tag)) {
$tag['_text'] = $replacer->execute($tag['_text'], $this->data);
}
return $tag;
}
private function escapeValues(array $tags): array
{
// only escape meta tags that have "content" property
foreach ($tags as $key => $tag) {
if (array_key_exists('content', $tag)) {
$tags[$key]['content'] = str_replace(
'"',
'&quot;',
$tag['content'],
);
}
}
return $tags;
}
/**
* Create duplicate tags from generated tags.
* (for example: canonical link from og:url)
*
* @param array $tags
* @return array
*/
private function duplicateTags($tags)
{
foreach ($tags as $tag) {
if (!isset($tag['content'])) {
continue;
}
if (Arr::get($tag, 'property') === 'og:url') {
$tags[] = [
'nodeName' => 'link',
'rel' => 'canonical',
'href' => $tag['content'],
];
}
if (Arr::get($tag, 'property') === 'og:title') {
$tags[] = [
'nodeName' => 'title',
'_text' => ucfirst($tag['content']),
];
}
if (Arr::get($tag, 'property') === 'og:description') {
$tags[] = [
'name' => 'description',
'content' => $tag['content'],
];
}
}
return $tags;
}
private function overrideTagsWithUserValues(array $metaTags): array
{
$overrides = app(Settings::class)->all();
foreach ($metaTags as $key => $tagConfig) {
$property = Arr::get($tagConfig, 'property');
$settingKey = "seo.{$this->namespace}.{$property}";
if (
in_array($property, self::EDITABLE_TAGS) &&
array_key_exists($settingKey, $overrides)
) {
$metaTags[$key]['content'] = $overrides[$settingKey];
}
}
return $metaTags;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Common\Core\Rendering;
use Illuminate\Support\Str;
trait DetectsCrawlers
{
protected array $crawlerUserAgents = [
'Yahoo! Slurp',
'bingbot',
'yandex',
'baiduspider',
'facebookexternalhit',
'twitterbot',
'rogerbot',
'linkedinbot',
'embedly',
'quora link preview',
'showyoubot',
'outbrain',
'pinterest/0.',
'slackbot',
'vkShare',
'W3C_Validator',
'redditbot',
'Applebot',
'WhatsApp',
'flipboard',
'tumblr',
'bitlybot',
'SkypeUriPreview',
'nuzzel',
'Discordbot',
'Qwantify',
'pinterestbot',
'Bitrix link preview',
'XING-contenttabreceiver',
'developers.google.com/+/web/snippet',
];
protected function isCrawler(): bool
{
$userAgent = strtolower(request()->server->get('HTTP_USER_AGENT'));
$bufferAgent = request()->server->get('X-BUFFERBOT');
$shouldPrerender = false;
if (!$userAgent) {
return false;
}
if (!request()->isMethod('GET')) {
return false;
}
// prerender if _escaped_fragment_ is in the query string
if (request()->query->has('_escaped_fragment_')) {
$shouldPrerender = true;
}
// prerender if a crawler is detected
foreach ($this->crawlerUserAgents as $crawlerUserAgent) {
if (Str::contains($userAgent, strtolower($crawlerUserAgent))) {
$shouldPrerender = true;
}
}
if ($bufferAgent) {
$shouldPrerender = true;
}
return $shouldPrerender;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Common\Core\Rendering;
use Common\Core\AppUrl;
use Common\Core\Bootstrap\BootstrapData;
use Common\SSR\RenderPageWithNode;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Vite;
trait RendersClientSideApp
{
protected function renderClientSideApp(array $options = [])
{
$bootstrapData = app(BootstrapData::class)->init();
$pageData = Arr::get($options, 'pageData', []);
// so "PageMetaTags" can know whether default meta tags should be rendered
$pageData['set_seo'] = isset($options['seoTagsView']);
if (isset($pageData['loader'])) {
$loader = $pageData['loader'];
$bootstrapData->set("loaders.$loader", $pageData);
}
$ssrContent =
isset($options['pageName']) && !Arr::get($options, 'noSSR')
? app(RenderPageWithNode::class)->execute($bootstrapData->get())
: null;
$bootstrapData->set('rendered_ssr', !is_null($ssrContent));
$view = view('app')
->with('pageData', $pageData)
->with('devCssPath', $this->getDevCssPath())
->with('seoTagsView', $options['seoTagsView'] ?? null)
->with('ssrContent', $ssrContent)
->with('bootstrapData', $bootstrapData)
->with('htmlBaseUri', app(AppUrl::class)->htmlBaseUri)
->with(
'customHtmlPath',
public_path('storage/custom-code/custom-html.html'),
)
->with(
'customCssPath',
public_path('storage/custom-code/custom-styles.css'),
);
if (isset($data['seo'])) {
$view->with('meta', $data['seo']);
}
return response($view);
}
protected function getDevCssPath(): string|null
{
if (
config('app.env') !== 'local' ||
!config('common.site.ssr_enabled') ||
!Vite::isRunningHot()
) {
return null;
}
$manifestPath = public_path('build/manifest.json');
if (!file_exists($manifestPath)) {
return null;
}
$manifest = json_decode(file_get_contents($manifestPath), true);
$cssPath = 'build/' . $manifest['resources/client/main.css']['file'];
if (file_exists(public_path($cssPath))) {
return $cssPath;
}
return null;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Common\Core\Resources;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
class BaseJsonResource extends JsonResource
{
/**
* @param mixed $resource
* @return AnonymousResourceCollection
*/
public static function paginated($resource)
{
return new PaginatedResourceCollection($resource, static::class);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Common\Core\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class PaginatedResourceCollection extends AnonymousResourceCollection
{
/**
* @var LengthAwarePaginator
*/
public $resource;
/**
* @param Request $request
* @return array
*/
public function toArray($request)
{
return [
'current_page' => $this->resource->currentPage(),
'data' => $this->collection,
'from' => $this->resource->firstItem(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'to' => $this->resource->lastItem(),
'total' => $this->total(),
];
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Common\Core\Values;
use Arr;
use Common\Settings\Settings;
use Illuminate\Filesystem\Filesystem;
use Str;
class GetStaticPermissions
{
/**
* @var Filesystem
*/
private $fs;
/**
* @param Filesystem $fs
*/
public function __construct(Filesystem $fs)
{
$this->fs = $fs;
}
public function execute()
{
$permissions = array_merge_recursive(
$this->fs->getRequire(
app('path.common') . '/resources/defaults/permissions.php',
),
$this->fs->getRequire(resource_path('defaults/permissions.php')),
)['all'];
$compiled = [];
foreach ($permissions as $key => $permissionGroup) {
// format permissions and add generic description, if needed
$compiled[$key] = collect($permissionGroup)->map(function ($item) {
if (!is_array($item)) {
$item = ['name' => $item];
}
if (!Arr::get($item, 'display_name')) {
$item['display_name'] = $this->getDisplayName(
$item['name'],
);
}
if (!Arr::get($item, 'description')) {
$item['description'] = $this->getGenericDescription(
$item['name'],
);
}
return $item;
});
}
// remove billing permissions, if billing functionality is disabled
if (
isset($compiled['billing_plans']) &&
!app(Settings::class)->get('billing.enable')
) {
unset($compiled['billing_plans']);
}
return $compiled;
}
private function getDisplayName($original)
{
// files.create => Create Files
if (!\Str::contains($original, '.')) {
return $original;
}
[$resource, $action] = explode('.', $original);
return ucfirst($action) .
' ' .
ucwords(str_replace('_', ' ', $resource));
}
/**
* @param string $permission
* @return string|null
*/
private function getGenericDescription($permission)
{
if (!Str::contains($permission, '.')) {
return null;
}
[$resource, $action] = explode('.', $permission);
$pluralAction = Str::plural(str_replace('_', ' ', $resource));
$verb = $this->getGenericVerb($action, $resource);
return "Allow $verb $pluralAction, regardless of who is the owner.";
}
/**
* @param string $action
* @param string $resource
* @return string|null
*/
private function getGenericVerb($action, $resource)
{
if ($resource === 'file' && $action === 'create') {
return 'uploading new';
}
switch ($action) {
case 'view':
return 'viewing ALL';
case 'create':
return 'creating new';
case 'update':
return 'updating ALL';
case 'delete':
return 'deleting ALL';
case 'download':
return 'downloading ALL';
default:
return null;
}
}
}

240
common/Core/Values/ValueLists.php Executable file
View File

@@ -0,0 +1,240 @@
<?php namespace Common\Core\Values;
use Common\Admin\Appearance\Themes\CssTheme;
use Common\Auth\Permissions\Permission;
use Common\Auth\Roles\Role;
use Common\Domains\CustomDomain;
use Common\Files\FileEntry;
use Common\Localizations\Localization;
use Common\Pages\CustomPage;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use const App\Providers\WORKSPACED_RESOURCES;
class ValueLists
{
public function __construct(
protected Filesystem $fs,
protected Localization $localization
) {
}
public function get(string $names, array $params = []): Collection|array
{
return collect(explode(',', $names))
->mapWithKeys(function ($name) use ($params) {
$methodName = Str::studly($name);
$value = method_exists($this, $methodName)
? $this->$methodName($params)
: $this->loadAppValueFile($name, $params);
return [$name => $value];
})
->filter();
}
public function permissions(): Collection
{
$query = app(Permission::class)->where('type', 'sitewide');
if (!config('common.site.enable_custom_domains')) {
$query->where('group', '!=', 'custom_domains');
}
if (!config('common.site.notifications_integrated')) {
$query->where('group', '!=', 'notifications');
}
// TODO: fetch and merge advanced and description from config files here
// instead of storing in db. Then can override workspace descriptions and
// advanced state easily here from separate config file
if (!config('common.site.workspaces_integrated')) {
$query
->where('group', '!=', 'workspaces')
->where('group', '!=', 'workspace_members');
}
if (!config('common.site.billing_integrated')) {
$query
->where('group', '!=', 'plans')
->where('group', '!=', 'invoices');
}
if (!Auth::user() || !Auth::user()->hasExactPermission('admin')) {
$query->where('name', '!=', 'admin');
}
return $query->get();
}
public function roles(): Collection
{
return app(Role::class)
->where('type', 'sitewide')
->get();
}
public function workspaceRoles(): Collection
{
return app(Role::class)
->where('type', 'workspace')
->get();
}
public function workspacePermissions($params = []): Collection
{
$filters = array_map(function ($resource) {
if (is_subclass_of($resource, FileEntry::class)) {
return 'files';
} elseif (is_subclass_of($resource, CustomDomain::class)) {
return 'custom_domains';
} else {
return Str::snake(Str::pluralStudly(class_basename($resource)));
}
}, WORKSPACED_RESOURCES);
return app(Permission::class)
->where('type', 'workspace')
->orWhere(function (Builder $builder) use ($filters) {
$builder->where('type', 'sitewide')->whereIn('group', $filters);
})
// don't return restrictions for workspace permissions so they
// are not shown when creating workspace role from admin area
->get([
'id',
'name',
'display_name',
'description',
'group',
'type',
])
->map(function (Permission $permission) {
$permission->description = str_replace(
'ALL',
'all workspace',
$permission->description,
);
$permission->description = str_replace(
'creating new',
'creating new workspace',
$permission->description,
);
return $permission;
});
}
public function currencies()
{
return json_decode(
$this->fs->get(__DIR__ . '/../../resources/lists/currencies.json'),
true,
);
}
public function timezones()
{
return json_decode(
$this->fs->get(__DIR__ . '/../../resources/lists/timezones.json'),
true,
);
}
public function countries()
{
return json_decode(
$this->fs->get(__DIR__ . '/../../resources/lists/countries.json'),
true,
);
}
public function languages()
{
return json_decode(
$this->fs->get(__DIR__ . '/../../resources/lists/languages.json'),
true,
);
}
public function localizations()
{
return $this->localization->get(['id', 'name', 'language']);
}
public function googleFonts(): array
{
$googleFonts = json_decode(
$this->fs->get(
__DIR__ . '/../../resources/lists/google-fonts.json',
),
true,
);
return array_map(function ($font) {
return [
'family' => $font['family'],
'category' => $font['category'],
'google' => true,
];
}, $googleFonts);
}
public function menuItemCategories(): array
{
return array_map(function ($category) {
$category['items'] = app($category['itemsLoader'])->execute();
unset($category['itemsLoader']);
return $category;
}, config('common.menus'));
}
public function pages($params = [])
{
if (!isset($params['userId'])) {
app(Gate::class)->authorize('index', CustomPage::class);
}
$query = app(CustomPage::class)
->select(['id', 'title'])
->where(
'type',
Arr::get($params, 'pageType') ?: CustomPage::PAGE_TYPE,
);
if ($userId = Arr::get($params, 'userId')) {
$query->where('user_id', $userId);
}
return $query->get();
}
public function domains(Collection|array $params): Collection
{
return app(CustomDomain::class)
->select(['host', 'id'])
->where('user_id', Arr::get($params, 'userId'))
->orWhere('global', true)
->get();
}
public function themes(Collection|array $params): Collection
{
app(Gate::class)->authorize('index', CssTheme::class);
return app(CssTheme::class)
->select(['name', 'id'])
->get();
}
private function loadAppValueFile(string $name, array $params): ?array
{
$fileName = Str::kebab($name);
$path = resource_path("lists/$fileName.json");
if (file_exists($path)) {
return json_decode(file_get_contents($path), true);
}
return null;
}
}

View File

@@ -0,0 +1,12 @@
<?php namespace Common\Core\Values;
use Common\Core\BaseController;
class ValueListsController extends BaseController
{
public function index(string $names)
{
$values = app(ValueLists::class)->get($names, request()->all());
return $this->success($values);
}
}