feat: add prefixed route #39
@@ -7,6 +7,7 @@ use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Injection\Container;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Routing\Attributes\AnonymousPrefixRoute;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -19,6 +20,8 @@ class Router
|
||||
private Container $container;
|
||||
/** @var array<string,array<string,Route>> */
|
||||
private array $routes = []; // [method][path] => Route
|
||||
/** @var array<string,array<string,Route>> prefix routes [method][prefix] => Route */
|
||||
private array $prefixRoutes = [];
|
||||
private bool $initialized = false;
|
||||
private string $cacheFile;
|
||||
|
||||
@@ -40,8 +43,9 @@ class Router
|
||||
// load cached routes in production
|
||||
if ($this->environment === 'prod' && file_exists($this->cacheFile)) {
|
||||
$data = include $this->cacheFile;
|
||||
if (is_array($data)) {
|
||||
$this->routes = $data;
|
||||
if (is_array($data) && isset($data['routes'])) {
|
||||
$this->routes = $data['routes'];
|
||||
$this->prefixRoutes = $data['prefix'] ?? [];
|
||||
$this->initialized = true;
|
||||
return;
|
||||
}
|
||||
@@ -52,7 +56,8 @@ class Router
|
||||
// write cache
|
||||
$dir = dirname($this->cacheFile);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
file_put_contents($this->cacheFile, '<?php return ' . var_export($this->routes, true) . ';');
|
||||
$cache = ['routes' => $this->routes, 'prefix' => $this->prefixRoutes];
|
||||
file_put_contents($this->cacheFile, '<?php return ' . var_export($cache, true) . ';');
|
||||
}
|
||||
|
||||
|
||||
@@ -95,13 +100,17 @@ class Router
|
||||
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
|
||||
$attributes = array_merge(
|
||||
$reflectionMethod->getAttributes(AnonymousRoute::class),
|
||||
$reflectionMethod->getAttributes(AuthenticatedRoute::class)
|
||||
$reflectionMethod->getAttributes(AuthenticatedRoute::class),
|
||||
$reflectionMethod->getAttributes(AnonymousPrefixRoute::class),
|
||||
);
|
||||
foreach ($attributes as $attribute) {
|
||||
$route = $attribute->newInstance();
|
||||
$httpPath = $routePrefix . $route->path;
|
||||
$isPrefix = $route instanceof AnonymousPrefixRoute;
|
||||
$httpPath = ($isPrefix && $route->absolute)
|
||||
? $route->path
|
||||
: $routePrefix . $route->path;
|
||||
foreach ($route->methods as $httpMethod) {
|
||||
$this->routes[$httpMethod][$httpPath] = new Route(
|
||||
$routeObject = new Route(
|
||||
method: $httpMethod,
|
||||
path: $httpPath,
|
||||
name: $route->name,
|
||||
@@ -111,6 +120,11 @@ class Router
|
||||
classMethodParameters: $reflectionMethod->getParameters(),
|
||||
permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [],
|
||||
);
|
||||
if ($isPrefix) {
|
||||
$this->prefixRoutes[$httpMethod][$httpPath] = $routeObject;
|
||||
} else {
|
||||
$this->routes[$httpMethod][$httpPath] = $routeObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,7 +137,7 @@ class Router
|
||||
/**
|
||||
* Match a Request to a Route, or return null if no match.
|
||||
* Supports exact matches and simple {param} patterns.
|
||||
* Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns
|
||||
* Prioritizes: 1) exact matches, 2) specific patterns, 3) prefix routes, 4) catch-all patterns
|
||||
*/
|
||||
public function match(Request $request): ?Route
|
||||
{
|
||||
@@ -161,6 +175,20 @@ class Router
|
||||
return $routeObj->withParams($params);
|
||||
}
|
||||
}
|
||||
// Try prefix routes before catch-all — longest matching prefix wins
|
||||
$bestPrefix = null;
|
||||
$bestRoute = null;
|
||||
foreach ($this->prefixRoutes[$method] ?? [] as $prefix => $routeObj) {
|
||||
if (str_starts_with($path, $prefix) || str_starts_with($path . '/', $prefix)) {
|
||||
if ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix)) {
|
||||
$bestPrefix = $prefix;
|
||||
$bestRoute = $routeObj;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($bestRoute !== null) {
|
||||
return $bestRoute;
|
||||
}
|
||||
// Try catch-all pattern last
|
||||
if ($catchAllPattern !== null) {
|
||||
[$routePath, $routeObj] = $catchAllPattern;
|
||||
|
||||
@@ -142,4 +142,19 @@ class SecurityService
|
||||
{
|
||||
return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using a plain-text identity + password credential.
|
||||
*
|
||||
* Used by the DAV auth backend which receives credentials from the HTTP
|
||||
* Basic challenge after they have been extracted from the Authorization header.
|
||||
*
|
||||
* @param string $identity Username / e-mail
|
||||
* @param string $password Plain-text password
|
||||
* @return User|null Authenticated user, or null on failure
|
||||
*/
|
||||
public function authenticatePassword(string $identity, string $password): ?User
|
||||
{
|
||||
return $this->authenticateBasic($identity, $password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,17 @@ class SessionIdentity
|
||||
|
||||
public function mailAddress(): ?string
|
||||
{
|
||||
return $this->identityData?->getEmail();
|
||||
return $this->identityData?->getIdentity();
|
||||
}
|
||||
|
||||
public function nameFirst(): ?string
|
||||
{
|
||||
return $this->identityData?->getFirstName();
|
||||
return null;
|
||||
}
|
||||
|
||||
public function nameLast(): ?string
|
||||
{
|
||||
return $this->identityData?->getLastName();
|
||||
return null;
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
|
||||
26
shared/lib/Routing/Attributes/AnonymousPrefixRoute.php
Normal file
26
shared/lib/Routing/Attributes/AnonymousPrefixRoute.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace KTXF\Routing\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* Marks a controller method as a prefix-matched anonymous route.
|
||||
*
|
||||
* Unlike AnonymousRoute (exact match), this attribute matches any request
|
||||
* whose path *starts with* the given $path prefix, making it suitable for
|
||||
* mounting sub-frameworks (e.g. sabre/dav) under a well-known URL tree.
|
||||
*
|
||||
* When $absolute is true the module prefix (/m/{handle}) is NOT prepended,
|
||||
* so the route is registered exactly at $path from the root.
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
|
||||
class AnonymousPrefixRoute
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $path,
|
||||
public readonly string $name,
|
||||
public readonly array $methods = ['GET'],
|
||||
public readonly bool $absolute = false,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user