baseline
This commit is contained in:
96
lib/Http/AccessControl.php
Normal file
96
lib/Http/AccessControl.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
|
||||
/**
|
||||
* Handles public path detection and authentication guards.
|
||||
*/
|
||||
class AccessControl
|
||||
{
|
||||
/** Paths that are always public (no auth required) */
|
||||
private const ALWAYS_PUBLIC_PATHS = [
|
||||
'login',
|
||||
'register',
|
||||
'verify-email',
|
||||
];
|
||||
|
||||
/** Prefixes that are always public */
|
||||
private const ALWAYS_PUBLIC_PREFIXES = [
|
||||
'password/',
|
||||
'branding/',
|
||||
'flash/',
|
||||
];
|
||||
|
||||
private array $configuredPublicPaths;
|
||||
|
||||
public function __construct(?array $publicPaths = null)
|
||||
{
|
||||
$this->configuredPublicPaths = $publicPaths ?? (defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is publicly accessible (no authentication required).
|
||||
*/
|
||||
public function isPublicPath(string $path): bool
|
||||
{
|
||||
$normalizedPath = $this->normalizePath($path);
|
||||
|
||||
// Root path check
|
||||
if ($normalizedPath === '/') {
|
||||
return in_array('/', $this->configuredPublicPaths, true);
|
||||
}
|
||||
|
||||
// Check configured public paths
|
||||
if (in_array($normalizedPath, $this->configuredPublicPaths, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check page/ prefix with slug lookup
|
||||
if (str_starts_with($normalizedPath, 'page/')) {
|
||||
$pageSlug = substr($normalizedPath, strlen('page/'));
|
||||
if (in_array($pageSlug, $this->configuredPublicPaths, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check always-public paths
|
||||
if (in_array($normalizedPath, self::ALWAYS_PUBLIC_PATHS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check always-public prefixes
|
||||
foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) {
|
||||
if (str_starts_with($normalizedPath, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login if the path requires authentication and user is not logged in.
|
||||
*
|
||||
* @return bool True if access is allowed, false if redirected
|
||||
*/
|
||||
public function requireAuthOrRedirect(string $path, bool $isLoggedIn): bool
|
||||
{
|
||||
if ($this->isPublicPath($path) || $isLoggedIn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$locale = I18n::$locale ?? I18n::$defaultLocale;
|
||||
Router::redirect(Request::withLocale('login', $locale));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizePath(string $path): string
|
||||
{
|
||||
$normalized = trim($path, '/');
|
||||
return $normalized === '' ? '/' : $normalized;
|
||||
}
|
||||
}
|
||||
56
lib/Http/AssetDetector.php
Normal file
56
lib/Http/AssetDetector.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
/**
|
||||
* Detects static asset requests that should bypass locale redirects.
|
||||
*/
|
||||
class AssetDetector
|
||||
{
|
||||
/** File extensions that indicate static assets */
|
||||
private const ASSET_EXTENSIONS = [
|
||||
'css', 'js', 'mjs', 'map',
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp',
|
||||
'woff', 'woff2', 'ttf', 'eot',
|
||||
'webmanifest',
|
||||
];
|
||||
|
||||
/** Path prefixes that indicate static asset directories */
|
||||
private const ASSET_PREFIXES = [
|
||||
'vendor/',
|
||||
'css/',
|
||||
'js/',
|
||||
'favicon/',
|
||||
'brand/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the given path is a static asset request.
|
||||
*/
|
||||
public static function isAssetRequest(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (self::hasAssetExtension($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check directory prefix
|
||||
foreach (self::ASSET_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function hasAssetExtension(string $path): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
return $extension !== '' && in_array($extension, self::ASSET_EXTENSIONS, true);
|
||||
}
|
||||
}
|
||||
142
lib/Http/LocaleResolver.php
Normal file
142
lib/Http/LocaleResolver.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
|
||||
/**
|
||||
* Handles locale detection and URL manipulation for multi-language support.
|
||||
*/
|
||||
class LocaleResolver
|
||||
{
|
||||
private array $availableLocales;
|
||||
private string $defaultLocale;
|
||||
private string $basePath;
|
||||
|
||||
public function __construct(?array $availableLocales = null, ?string $defaultLocale = null)
|
||||
{
|
||||
$this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale;
|
||||
$this->availableLocales = $availableLocales ?? (defined('APP_LOCALES') ? APP_LOCALES : [$this->defaultLocale]);
|
||||
$this->basePath = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a request URI and extract locale information.
|
||||
*
|
||||
* @return array{
|
||||
* locale: string,
|
||||
* pathWithoutLocale: string,
|
||||
* query: string,
|
||||
* hadLocaleInUrl: bool,
|
||||
* hadInvalidLocale: bool
|
||||
* }
|
||||
*/
|
||||
public function parseUri(string $uri): array
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
$query = parse_url($uri, PHP_URL_QUERY) ?: '';
|
||||
|
||||
$relativePath = $this->stripBasePath($path);
|
||||
$segments = $relativePath === '' ? [] : explode('/', $relativePath);
|
||||
|
||||
$localeCandidate = $segments[0] ?? '';
|
||||
$hadLocaleInUrl = false;
|
||||
$hadInvalidLocale = false;
|
||||
$locale = '';
|
||||
|
||||
if ($localeCandidate !== '' && in_array($localeCandidate, $this->availableLocales, true)) {
|
||||
$locale = array_shift($segments);
|
||||
$hadLocaleInUrl = true;
|
||||
} elseif ($localeCandidate !== '' && $this->looksLikeLocale($localeCandidate)) {
|
||||
array_shift($segments);
|
||||
$hadInvalidLocale = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'locale' => $locale,
|
||||
'pathWithoutLocale' => implode('/', $segments),
|
||||
'query' => $query,
|
||||
'hadLocaleInUrl' => $hadLocaleInUrl,
|
||||
'hadInvalidLocale' => $hadInvalidLocale,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective locale from multiple sources (priority order):
|
||||
* 1. URL segment (explicit)
|
||||
* 2. User session preference
|
||||
* 3. Cookie preference
|
||||
* 4. Default locale
|
||||
*/
|
||||
public function resolveLocale(string $urlLocale, ?string $userLocale = null, ?string $cookieLocale = null): string
|
||||
{
|
||||
if ($urlLocale !== '' && $this->isValidLocale($urlLocale)) {
|
||||
return $urlLocale;
|
||||
}
|
||||
|
||||
if ($userLocale !== null && $userLocale !== '' && $this->isValidLocale($userLocale)) {
|
||||
return $userLocale;
|
||||
}
|
||||
|
||||
if ($cookieLocale !== null && $cookieLocale !== '' && $this->isValidLocale($cookieLocale)) {
|
||||
return $cookieLocale;
|
||||
}
|
||||
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new REQUEST_URI without the locale segment.
|
||||
*/
|
||||
public function buildUriWithoutLocale(string $pathWithoutLocale, string $query): string
|
||||
{
|
||||
$newPath = $this->basePath !== '' ? '/' . $this->basePath : '';
|
||||
|
||||
if ($pathWithoutLocale !== '') {
|
||||
$newPath .= '/' . $pathWithoutLocale;
|
||||
} elseif ($newPath === '') {
|
||||
$newPath = '/';
|
||||
}
|
||||
|
||||
return $newPath . ($query !== '' ? '?' . $query : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a locale string is valid (exists in available locales).
|
||||
*/
|
||||
public function isValidLocale(string $locale): bool
|
||||
{
|
||||
return in_array($locale, $this->availableLocales, true);
|
||||
}
|
||||
|
||||
public function getAvailableLocales(): array
|
||||
{
|
||||
return $this->availableLocales;
|
||||
}
|
||||
|
||||
public function getDefaultLocale(): string
|
||||
{
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
private function stripBasePath(string $path): string
|
||||
{
|
||||
$relativePath = ltrim($path, '/');
|
||||
|
||||
if ($this->basePath !== '' && strpos($relativePath, $this->basePath . '/') === 0) {
|
||||
return substr($relativePath, strlen($this->basePath) + 1);
|
||||
}
|
||||
|
||||
if ($this->basePath !== '' && $relativePath === $this->basePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
private function looksLikeLocale(string $candidate): bool
|
||||
{
|
||||
return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1;
|
||||
}
|
||||
}
|
||||
102
lib/Http/Request.php
Normal file
102
lib/Http/Request.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\I18n;
|
||||
|
||||
class Request
|
||||
{
|
||||
public static function path(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public static function safeReturnTarget(string $returnParam = ''): string
|
||||
{
|
||||
if ($returnParam !== '') {
|
||||
$parts = parse_url($returnParam);
|
||||
if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') {
|
||||
$path = $parts['path'] ?? '';
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
return $path . $query;
|
||||
}
|
||||
}
|
||||
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if ($referer !== '') {
|
||||
$parts = parse_url($referer);
|
||||
$host = $parts['host'] ?? '';
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host === '' || $host === $currentHost) {
|
||||
$path = $parts['path'] ?? '';
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
return $path . $query;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function pathWithQuery(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = self::path();
|
||||
$query = parse_url($uri, PHP_URL_QUERY);
|
||||
|
||||
return $path . ($query ? '?' . $query : '');
|
||||
}
|
||||
|
||||
public static function stripLocale(string $path, ?array $locales = null): string
|
||||
{
|
||||
$locales = $locales ?? (defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]);
|
||||
$path = ltrim($path, '/');
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = explode('/', $path);
|
||||
while ($parts && $parts[0] !== '' && in_array($parts[0], $locales, true)) {
|
||||
array_shift($parts);
|
||||
}
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
public static function withLocale(string $path = '', ?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$path = ltrim($path, '/');
|
||||
return ($locale !== '' ? $locale . '/' : '') . $path;
|
||||
}
|
||||
|
||||
public static function wantsJson(): bool
|
||||
{
|
||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||
return stripos($accept, 'application/json') !== false || $requestedWith !== '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user