164 lines
5.1 KiB
PHP
164 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Route;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* The public demo policy.
|
|
*
|
|
* The rules live in config/demo.php rather than in guards scattered through
|
|
* the controllers, so the same code base can be deployed as a demo or as a
|
|
* production install without a diff. App\Http\Middleware\DemoMode enforces
|
|
* them; nothing else in the application needs to know demo mode exists.
|
|
*/
|
|
class Demo
|
|
{
|
|
public static function enabled(): bool
|
|
{
|
|
return (bool) config('demo.enabled');
|
|
}
|
|
|
|
/**
|
|
* One of the shared accounts the demo hands out.
|
|
*
|
|
* The single place that decides what "the demo account" means. Never
|
|
* compare a username to 'admin' at a call site.
|
|
*/
|
|
public static function isProtectedUser(?User $user): bool
|
|
{
|
|
if (! self::enabled() || ! $user) {
|
|
return false;
|
|
}
|
|
|
|
// Blanks are dropped from both sides, so a stray empty entry in the
|
|
// config does not end up protecting every user without a username.
|
|
$protected = self::withoutBlanks(array_map('strtolower', (array) config('demo.protected_users', [])));
|
|
|
|
$identifiers = self::withoutBlanks([
|
|
Str::lower((string) $user->username),
|
|
Str::lower((string) $user->email),
|
|
]);
|
|
|
|
return array_intersect($identifiers, $protected) !== [];
|
|
}
|
|
|
|
/**
|
|
* Why this request is refused, or null when it may proceed.
|
|
*
|
|
* The order matters. The handful of state changing GET routes are matched
|
|
* before the safe verbs let everything else through, and the safe verbs
|
|
* are matched before the write rules so that the settings pages stay
|
|
* readable and a protected account can still be viewed.
|
|
*/
|
|
public static function blocks(Request $request): ?string
|
|
{
|
|
if (! self::enabled()) {
|
|
return null;
|
|
}
|
|
|
|
$route = $request->route();
|
|
|
|
if (self::matches($request, config('demo.allowed', []))) {
|
|
return null;
|
|
}
|
|
|
|
if (self::matches($request, config('demo.unsafe_reads', []))) {
|
|
return self::message();
|
|
}
|
|
|
|
if (in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true)) {
|
|
return null;
|
|
}
|
|
|
|
if (self::matches($request, config('demo.blocked', []))) {
|
|
return self::message();
|
|
}
|
|
|
|
if (self::isProtectedUser(self::boundUser($route))) {
|
|
return self::message(__('The demo account cannot be modified.'));
|
|
}
|
|
|
|
if (self::matches($request, config('demo.self_routes', [])) && self::isProtectedUser($request->user())) {
|
|
return self::message(__('The demo account cannot be modified.'));
|
|
}
|
|
|
|
if (config('demo.protect_default_roles', true) && self::touchesSeededAuthorization($route)) {
|
|
return self::message(__('Default roles and permissions cannot be modified in the demo.'));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The configured override, the reason for this particular rule, or the
|
|
* generic refusal.
|
|
*/
|
|
public static function message(?string $default = null): string
|
|
{
|
|
return config('demo.message')
|
|
?: ($default ?: __('This action is disabled in the demo.'));
|
|
}
|
|
|
|
/**
|
|
* The route bound {user}, when there is one.
|
|
*
|
|
* Impersonation binds a raw {id} rather than a model, which is why that
|
|
* route sits on the blocklist instead of relying on this.
|
|
*/
|
|
private static function boundUser(?Route $route): ?User
|
|
{
|
|
$bound = $route?->parameter('user');
|
|
|
|
return $bound instanceof User ? $bound : null;
|
|
}
|
|
|
|
/**
|
|
* A seeded role or permission, identified by the removable column that
|
|
* RolesSeeder and PermissionsSeeder already set to false. Deleting one is
|
|
* refused by the remove requests; this covers renaming it.
|
|
*/
|
|
private static function touchesSeededAuthorization(?Route $route): bool
|
|
{
|
|
foreach (['role', 'permission'] as $parameter) {
|
|
$bound = $route?->parameter($parameter);
|
|
|
|
if ($bound && isset($bound->removable) && ! $bound->removable) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Match a rule against the route name and against the request path.
|
|
*
|
|
* Names read better and survive a URI change, but most of routes/api.php
|
|
* is unnamed, so a name only matcher would leave the API half guarded.
|
|
* Names and paths cannot collide: one is dot separated, the other slash
|
|
* separated.
|
|
*
|
|
* @param array<int, string> $patterns
|
|
*/
|
|
private static function matches(Request $request, array $patterns): bool
|
|
{
|
|
$name = $request->route()?->getName();
|
|
|
|
return ($name !== null && Str::is($patterns, $name))
|
|
|| Str::is($patterns, $request->path());
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $values
|
|
* @return array<int, string>
|
|
*/
|
|
private static function withoutBlanks(array $values): array
|
|
{
|
|
return array_values(array_filter($values, fn (string $value) => $value !== ''));
|
|
}
|
|
}
|