feat: add prefixed route #39

Merged
Sebastian merged 1 commits from feat/dav-implementation into main 2026-03-07 03:56:00 +00:00
4 changed files with 79 additions and 10 deletions

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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

View 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,
) {}
}