diff --git a/README.md b/README.md
index c24e250..62b2f03 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ Aura.PHP application
### Features
* ID & password login
+* Cloudflare turnstile check
* Flash message
### Architecture
@@ -49,3 +50,7 @@ sequenceDiagram
```bash
composer run-script cli get /hello
```
+
+## Cloudflare Turnstile
+
+* [Dummy sitekeys and secret keys](https://developers.cloudflare.com/turnstile/troubleshooting/testing/)
diff --git a/source/app/ddd/core/src/Application/Shared/DbConnectionInterface.php b/source/app/ddd/core/src/Application/Shared/DbConnectionInterface.php
new file mode 100644
index 0000000..3233d6f
--- /dev/null
+++ b/source/app/ddd/core/src/Application/Shared/DbConnectionInterface.php
@@ -0,0 +1,14 @@
+pdo->inTransaction()) {
+ return;
+ }
+
+ $this->pdo->beginTransaction();
+ }
+
+ public function commit(): void
+ {
+ if (! $this->pdo->inTransaction()) {
+ return;
+ }
+
+ $this->pdo->commit();
+ }
+
+ public function rollback(): void
+ {
+ if (! $this->pdo->inTransaction()) {
+ return;
+ }
+
+ $this->pdo->rollBack();
+ }
+}
diff --git a/source/app/env.dist.json b/source/app/env.dist.json
index c3629d7..5a36f0a 100644
--- a/source/app/env.dist.json
+++ b/source/app/env.dist.json
@@ -1,8 +1,8 @@
{
"$schema": "./env.schema.json",
"ADMIN_PREFIX": "upvQzoCTaaYrTDP7",
- "CLOUDFLARE_TURNSTILE_SECRET_KEY": "",
- "CLOUDFLARE_TURNSTILE_SITE_KEY": "",
+ "CLOUDFLARE_TURNSTILE_SECRET_KEY": "1x0000000000000000000000000000000AA",
+ "CLOUDFLARE_TURNSTILE_SITE_KEY": "1x00000000000000000000AA",
"DB_DSN": "mysql:host=aura-mysql:3306;dbname=aura_db",
"DB_PASS": "passw0rd",
"DB_USER": "aura",
diff --git a/source/app/phpcs.xml b/source/app/phpcs.xml
index fcee186..f359471 100644
--- a/source/app/phpcs.xml
+++ b/source/app/phpcs.xml
@@ -19,6 +19,7 @@
tests
*/tmp/*
*/Fake/*
+ ddd/core/src
diff --git a/source/app/phpstan.neon b/source/app/phpstan.neon
index 13ec8a3..52dd4c7 100644
--- a/source/app/phpstan.neon
+++ b/source/app/phpstan.neon
@@ -3,3 +3,4 @@ parameters:
paths:
- src
- tests
+ - ddd/core/src
diff --git a/source/app/psalm.xml b/source/app/psalm.xml
index 45a660d..d6fc530 100644
--- a/source/app/psalm.xml
+++ b/source/app/psalm.xml
@@ -13,5 +13,6 @@
+
diff --git a/source/app/src/Auth/AdminAuthenticationHandler.php b/source/app/src/Auth/AdminAuthenticationHandler.php
index a600a6b..7341c87 100644
--- a/source/app/src/Auth/AdminAuthenticationHandler.php
+++ b/source/app/src/Auth/AdminAuthenticationHandler.php
@@ -9,6 +9,7 @@
use Aura\Auth\Exception\PasswordMissing as AuraPasswordMissing;
use Aura\Auth\Exception\UsernameMissing as AuraUsernameMissing;
use Aura\Auth\Exception\UsernameNotFound as AuraUsernameNotFound;
+use Koriym\HttpConstants\Method;
use Laminas\Diactoros\Response\RedirectResponse;
use MyVendor\MyPackage\Router\RouterMatch;
use Psr\Http\Message\ResponseInterface;
@@ -95,7 +96,8 @@ private function isLogin(RouterMatch $routerMatch): bool
return is_array($auth) &&
isset($auth['login']) &&
is_bool($auth['login']) &&
- $auth['login'];
+ $auth['login'] &&
+ $routerMatch->method === Method::POST;
}
private function isLogout(RouterMatch $routerMatch): bool
@@ -109,6 +111,7 @@ private function isLogout(RouterMatch $routerMatch): bool
return is_array($auth) &&
isset($auth['logout']) &&
is_bool($auth['logout']) &&
- $auth['logout'];
+ $auth['logout'] &&
+ $routerMatch->method === Method::POST;
}
}
diff --git a/source/app/src/DiBinder.php b/source/app/src/DiBinder.php
index 54abf72..cafbf70 100644
--- a/source/app/src/DiBinder.php
+++ b/source/app/src/DiBinder.php
@@ -4,7 +4,9 @@
namespace MyVendor\MyPackage;
+use AppCore\Application\Shared\DbConnectionInterface;
use AppCore\Domain\Hasher\PasswordHasher;
+use AppCore\Infrastructure\Persistence\DbConnection;
use Aura\Accept\Accept;
use Aura\Accept\AcceptFactory;
use Aura\Di\Container;
@@ -16,8 +18,15 @@
use Aura\Router\RouterContainer;
use Aura\Session\Session;
use Aura\Session\SessionFactory;
+use Aura\Sql\ExtendedPdo;
+use Aura\Sql\ExtendedPdoInterface;
+use Aura\SqlQuery\Common\DeleteInterface;
+use Aura\SqlQuery\Common\InsertInterface;
+use Aura\SqlQuery\Common\SelectInterface;
+use Aura\SqlQuery\Common\UpdateInterface;
+use Aura\SqlQuery\QueryFactory;
use Koriym\QueryLocator\QueryLocator;
-use Laminas\Diactoros\ServerRequestFactory;
+use Koriym\QueryLocator\QueryLocatorInterface;
use MyVendor\MyPackage\Auth\AdminAuthenticationHandler;
use MyVendor\MyPackage\Auth\AdminAuthenticator;
use MyVendor\MyPackage\Auth\AdminAuthenticatorInterface;
@@ -34,6 +43,7 @@
use MyVendor\MyPackage\Responder\WebResponder;
use MyVendor\MyPackage\Router\CliRouter;
use MyVendor\MyPackage\Router\RouterInterface;
+use MyVendor\MyPackage\Router\ServerRequestFactory;
use MyVendor\MyPackage\Router\WebRouter;
use MyVendor\MyPackage\TemplateEngine\QiqCustomHelper;
use MyVendor\MyPackage\TemplateEngine\QiqRenderer;
@@ -57,15 +67,15 @@ public function __invoke(string $appDir, string $tmpDir): Container
$di = $builder->newInstance(true); // NOTE: "$di->types['xxx']" を使うために有効化
$di->values['timestamp'] = time();
- $di->values['baseUrl'] = getenv('BASE_URL');
+ $di->values['siteUrl'] = getenv('SITE_URL');
$di->values['pdoDsn'] = getenv('DB_DSN');
$di->values['pdoUsername'] = getenv('DB_USER');
$di->values['pdoPassword'] = getenv('DB_PASS');
$this->appMeta($di, $appDir, $tmpDir);
$this->authentication($di);
+ $this->db($di, $appDir);
$this->form($di);
- $this->queryLocator($di, $appDir);
$this->renderer($di, $appDir);
$this->request($di);
$this->requestDispatcher($di);
@@ -101,6 +111,29 @@ private function authentication(Container $di): void
$di->types[AdminAuthenticatorInterface::class] = $di->lazyGet(AdminAuthenticator::class);
}
+ private function db(Container $di, string $appDir): void
+ {
+ $di->params[ExtendedPdo::class]['dsn'] = $di->lazyValue('pdoDsn');
+ $di->params[ExtendedPdo::class]['username'] = $di->lazyValue('pdoUsername');
+ $di->params[ExtendedPdo::class]['password'] = $di->lazyValue('pdoPassword');
+ $di->set(ExtendedPdoInterface::class, $di->lazyNew(ExtendedPdo::class));
+ $di->types[ExtendedPdoInterface::class] = $di->lazyGet(ExtendedPdoInterface::class);
+
+ $di->types[SelectInterface::class] = $di->lazy(static fn () => (new QueryFactory('mysql'))->newSelect());
+ $di->types[InsertInterface::class] = $di->lazy(static fn () => (new QueryFactory('mysql'))->newInsert());
+ $di->types[UpdateInterface::class] = $di->lazy(static fn () => (new QueryFactory('mysql'))->newUpdate());
+ $di->types[DeleteInterface::class] = $di->lazy(static fn () => (new QueryFactory('mysql'))->newDelete());
+
+ $di->types[DbConnectionInterface::class] = $di->lazyNew(DbConnection::class);
+
+ $di->values['sqlDir'] = $appDir . '/var/sql';
+ $di->params[QueryLocator::class]['sqlDir'] = $di->lazyValue('sqlDir');
+
+ $di->set(QueryLocatorInterface::class, $di->lazyNew(QueryLocator::class));
+
+ $di->types[QueryLocatorInterface::class] = $di->lazyGet(QueryLocatorInterface::class);
+ }
+
private function form(Container $di): void
{
$di->params[ExtendedForm::class]['builder'] = $di->lazyNew(Builder::class);
@@ -114,13 +147,6 @@ private function form(Container $di): void
$di->types[LoginForm::class] = $di->lazyNew(LoginForm::class);
}
- private function queryLocator(Container $di, string $appDir): void
- {
- $di->values['sqlDir'] = $appDir . '/var/sql';
-
- $di->params[QueryLocator::class]['sqlDir'] = $di->lazyValue('sqlDir');
- }
-
private function renderer(Container $di, string $appDir): void
{
$di->params[QiqRenderer::class]['template'] = $di->lazy(static function () use ($appDir, $di) {
@@ -137,7 +163,7 @@ private function renderer(Container $di, string $appDir): void
);
});
$di->params[QiqRenderer::class]['data'] = $di->lazyArray([
- 'baseUrl' => $di->lazyValue('baseUrl'),
+ 'siteUrl' => $di->lazyValue('siteUrl'),
'timestamp' => $di->lazyValue('timestamp'),
]);
$di->set(QiqRenderer::class, $di->lazyNew(QiqRenderer::class));
diff --git a/source/app/src/Handler/Download.php b/source/app/src/Handler/Download.php
new file mode 100644
index 0000000..e9a85f5
--- /dev/null
+++ b/source/app/src/Handler/Download.php
@@ -0,0 +1,36 @@
+headers = [
+ 'Content-Type' => 'application/octet-stream',
+ 'Content-Disposition' => 'attachment; filename=dummy.csv',
+ 'Content-Transfer-Encoding' => 'binary',
+ ];
+ $this->stream = $stream;
+
+ fputcsv($stream, [
+ 'ID',
+ 'NAME',
+ ]);
+
+ return $this;
+ }
+}
diff --git a/source/app/src/RequestDispatcher.php b/source/app/src/RequestDispatcher.php
index 82d72b6..3981f57 100644
--- a/source/app/src/RequestDispatcher.php
+++ b/source/app/src/RequestDispatcher.php
@@ -9,10 +9,12 @@
use Aura\Di\Container;
use Koriym\HttpConstants\MediaType;
use Koriym\HttpConstants\Method;
+use Koriym\HttpConstants\ResponseHeader;
use Koriym\HttpConstants\StatusCode;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Response\RedirectResponse;
use Laminas\Diactoros\Response\TextResponse;
+use Laminas\Diactoros\Stream;
use MyVendor\MyPackage\Auth\AdminAuthenticationHandler;
use MyVendor\MyPackage\Auth\AdminAuthenticationRequestHandlerInterface;
use MyVendor\MyPackage\Auth\AuthenticationException;
@@ -28,6 +30,7 @@
use MyVendor\MyPackage\Router\RouteHandlerMethodNotAllowedException;
use MyVendor\MyPackage\Router\RouteHandlerNotFoundException;
use MyVendor\MyPackage\Router\RouterInterface;
+use MyVendor\MyPackage\Router\RouterMatch;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
@@ -35,7 +38,10 @@
use function assert;
use function call_user_func_array;
use function class_exists;
+use function is_array;
+use function is_bool;
use function is_callable;
+use function is_resource;
use function is_string;
use function method_exists;
use function sprintf;
@@ -61,7 +67,7 @@ public function __invoke(): ResponseInterface|null
{
$routerMatch = $this->router->match($this->serverRequest);
$route = $routerMatch->route;
- if ($route === false) {
+ if (is_bool($route)) {
return new TextResponse(
'Route not found :(',
StatusCode::NOT_FOUND,
@@ -136,7 +142,16 @@ public function __invoke(): ResponseInterface|null
}
// NOTE: Request handling
- $action = sprintf('on%s', ucfirst(strtolower($routerMatch->method)));
+ $action = sprintf('on%s', ucfirst(strtolower($this->getMethod($routerMatch))));
+ $parsedBody = $serverRequest->getParsedBody();
+ if (
+ is_array($parsedBody) &&
+ isset($parsedBody['_method']) &&
+ $serverRequest->getMethod() === Method::POST
+ ) {
+ $action = sprintf('on%s', ucfirst(strtolower($parsedBody['_method'])));
+ }
+
if (! method_exists($object, $action)) {
throw new RouteHandlerMethodNotAllowedException('Method not allowed.');
}
@@ -145,16 +160,16 @@ public function __invoke(): ResponseInterface|null
// NOTE: RequestHandler で ServerRequest や Route の取得をしたい場合は "Typehinted constructor" を使う
$callable = [$object, $action];
if (is_callable($callable)) {
- call_user_func_array($callable, $route->attributes);
+ $object = call_user_func_array($callable, $route->attributes);
}
if (! $object instanceof RequestHandler) {
throw new InvalidResponseException('Invalid response type.');
}
- if (isset($object->headers['location'])) {
+ if (isset($object->headers[ResponseHeader::LOCATION])) {
return new RedirectResponse(
- $object->headers['location'],
+ $object->headers[ResponseHeader::LOCATION],
$object->code,
$object->headers,
);
@@ -178,6 +193,16 @@ private function getResponse(RequestHandler $object): ResponseInterface
assert($renderer instanceof RendererInterface);
$response = new Response();
+
+ if (is_resource($object->stream)) {
+ $response = $response->withBody(new Stream($object->stream));
+ foreach ($object->headers as $name => $value) {
+ $response = $response->withHeader($name, $value);
+ }
+
+ return $response->withStatus($object->code);
+ }
+
$response->getBody()->write($renderer->render($object));
foreach ($object->headers as $name => $value) {
$response = $response->withHeader($name, $value);
@@ -186,6 +211,20 @@ private function getResponse(RequestHandler $object): ResponseInterface
return $response->withStatus($object->code);
}
+ private function getMethod(RouterMatch $routerMatch): string
+ {
+ if ($this->serverRequest->getMethod() !== Method::POST) {
+ return $routerMatch->method;
+ }
+
+ $body = $this->serverRequest->getParsedBody();
+ if (! is_array($body) || ! isset($body['_method'])) {
+ return $routerMatch->method;
+ }
+
+ return $body['_method'];
+ }
+
private function getRenderer(): RendererInterface
{
$media = $this->accept->negotiateMedia([
diff --git a/source/app/src/RequestHandler.php b/source/app/src/RequestHandler.php
index 48533ee..341a258 100644
--- a/source/app/src/RequestHandler.php
+++ b/source/app/src/RequestHandler.php
@@ -18,6 +18,9 @@ class RequestHandler implements Stringable
/** @var array|null */
public array|null $body = null;
+
+ /** @var resource|null */
+ public $stream;
public RendererInterface|null $renderer = null;
public string|null $string = null;
diff --git a/source/app/src/Responder/WebResponder.php b/source/app/src/Responder/WebResponder.php
index 9a9646a..e7b7904 100644
--- a/source/app/src/Responder/WebResponder.php
+++ b/source/app/src/Responder/WebResponder.php
@@ -10,8 +10,6 @@
use function http_response_code;
use function sprintf;
-use const PHP_EOL;
-
final class WebResponder implements ResponderInterface
{
public function handle(ResponseInterface $response): void
@@ -24,6 +22,6 @@ public function handle(ResponseInterface $response): void
}
}
- echo $response->getBody() . PHP_EOL;
+ echo $response->getBody();
}
}
diff --git a/source/app/src/Router/ServerRequestFactory.php b/source/app/src/Router/ServerRequestFactory.php
new file mode 100644
index 0000000..4aec3bb
--- /dev/null
+++ b/source/app/src/Router/ServerRequestFactory.php
@@ -0,0 +1,73 @@
+getHeader('content-type'),
+ true,
+ );
+ if ($isFormUrlEncoded) {
+ return self::parseFormUrlEncoded($serverRequest);
+ }
+
+ $isJson = in_array(
+ 'application/json',
+ $serverRequest->getHeader('content-type'),
+ true,
+ );
+ if (! $isJson) {
+ return $serverRequest;
+ }
+
+ return self::parseJson($serverRequest);
+ }
+
+ private static function parseFormUrlEncoded(ServerRequestInterface $serverRequest): ServerRequestInterface
+ {
+ parse_str((string) $serverRequest->getBody(), $parsedBody);
+
+ return $serverRequest->withParsedBody($parsedBody);
+ }
+
+ private static function parseJson(ServerRequestInterface $serverRequest): ServerRequestInterface
+ {
+ $parsedBody = json_decode(
+ (string) $serverRequest->getBody(),
+ true,
+ 512,
+ JSON_THROW_ON_ERROR,
+ );
+
+ $error = json_last_error();
+ if ($error !== JSON_ERROR_NONE) {
+ throw new InvalidRequestException(json_last_error_msg());
+ }
+
+ assert(is_array($parsedBody));
+
+ return $serverRequest->withParsedBody($parsedBody);
+ }
+}
diff --git a/source/app/src/Router/WebRouter.php b/source/app/src/Router/WebRouter.php
index ca56b94..85bc719 100644
--- a/source/app/src/Router/WebRouter.php
+++ b/source/app/src/Router/WebRouter.php
@@ -8,17 +8,6 @@
use Aura\Router\RouterContainer;
use Psr\Http\Message\ServerRequestInterface;
-use function assert;
-use function in_array;
-use function is_array;
-use function json_decode;
-use function json_last_error;
-use function json_last_error_msg;
-use function parse_str;
-
-use const JSON_ERROR_NONE;
-use const JSON_THROW_ON_ERROR;
-
final class WebRouter implements RouterInterface
{
public function __construct(
@@ -30,66 +19,12 @@ public function match(ServerRequestInterface $serverRequest): RouterMatch
{
$matcher = $this->routerContainer->getMatcher();
- $isFormUrlEncoded = in_array(
- 'application/x-www-form-urlencoded',
- $serverRequest->getHeader('content-type'),
- true,
- );
- if ($isFormUrlEncoded) {
- return new RouterMatch(
- $serverRequest->getMethod(),
- $serverRequest->getUri()->getPath(),
- $matcher->match($serverRequest),
- $this->parseFormUrlEncoded($serverRequest),
- );
- }
-
- $isJson = in_array(
- 'application/json',
- $serverRequest->getHeader('content-type'),
- true,
- );
- if (! $isJson) {
- return new RouterMatch(
- $serverRequest->getMethod(),
- $serverRequest->getUri()->getPath(),
- $matcher->match($serverRequest),
- $serverRequest,
- );
- }
-
return new RouterMatch(
$serverRequest->getMethod(),
$serverRequest->getUri()->getPath(),
$matcher->match($serverRequest),
- $this->parseJson($serverRequest),
- );
- }
-
- private function parseFormUrlEncoded(ServerRequestInterface $serverRequest): ServerRequestInterface
- {
- parse_str((string) $serverRequest->getBody(), $parsedBody);
-
- return $serverRequest->withParsedBody($parsedBody);
- }
-
- private function parseJson(ServerRequestInterface $serverRequest): ServerRequestInterface
- {
- $parsedBody = json_decode(
- (string) $serverRequest->getBody(),
- true,
- 512,
- JSON_THROW_ON_ERROR,
+ $serverRequest,
);
-
- $error = json_last_error();
- if ($error !== JSON_ERROR_NONE) {
- throw new InvalidRequestException(json_last_error_msg());
- }
-
- assert(is_array($parsedBody));
-
- return $serverRequest->withParsedBody($parsedBody);
}
/** @param array $data */
diff --git a/source/app/var/conf/aura.route.web.php b/source/app/var/conf/aura.route.web.php
index 0474775..77b6f70 100644
--- a/source/app/var/conf/aura.route.web.php
+++ b/source/app/var/conf/aura.route.web.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use Aura\Router\Map;
+use Koriym\HttpConstants\Method;
use MyVendor\MyPackage\Handler;
use MyVendor\MyPackage\Handler\Admin as AdminHandler;
@@ -17,6 +18,8 @@
$map->get('hello', '/hello', Handler\Hello::class)
->extras(['a' => 'b']);
+
+ $map->get('download', '/download', Handler\Download::class);
});
$map->attach('/admin', '/' . $adminPrefix, function (Map $map) {
@@ -24,10 +27,8 @@
$map->auth($auth);
$map->get('/login', '/login', AdminHandler\Login::class)
- ->auth([]);
-
- $map->post('/_login', '/login', AdminHandler\Login::class)
- ->auth(['login' => true]);
+ ->auth(['login' => true])
+ ->allows([Method::POST]);
$map->post('/logout', '/logout', AdminHandler\Logout::class)
->auth(array_merge($auth, ['logout' => true]));