This commit is contained in:
2026-02-04 23:31:53 +01:00
commit cd59ccd99b
2401 changed files with 56808 additions and 0 deletions

View 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;
}
}

View 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
View 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
View 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 !== '';
}
}