Compare commits

...

7 Commits

Author SHA1 Message Date
2c18f74f97
add/modify template for audit logger
All checks were successful
soko-web/pipeline/pr-master This commit looks good
2023-04-18 23:15:06 +02:00
831433f9d8
implement audit logger 2023-04-18 23:15:06 +02:00
aa31c857c7
Merge pull request 'fix archive in pipeline' (#10) from bugfix/fix-archive-in-pipeline into master
All checks were successful
soko-web/pipeline/head This commit looks good
Reviewed-on: #10
2023-04-18 23:13:59 +02:00
73272fa6e5
fix archive in pipeline
All checks were successful
soko-web/pipeline/pr-master This commit looks good
2023-04-18 23:13:11 +02:00
0b047b0bcc
Merge pull request 'feature/database-transactions' (#9) from feature/database-transactions into master
All checks were successful
soko-web/pipeline/head This commit looks good
Reviewed-on: #9
2023-04-18 23:07:18 +02:00
ad7b8c3eda
handle errors and exceptions in controllers
All checks were successful
soko-web/pipeline/pr-master This commit looks good
2023-04-18 22:43:21 +02:00
51801d4228
execute every db command in a transaction in controllers 2023-04-18 22:22:02 +02:00
11 changed files with 227 additions and 17 deletions

6
Jenkinsfile vendored
View File

@ -32,7 +32,7 @@ pipeline {
sh 'vendor/bin/phpunit --log-junit unit_test_results.xml --testdox tests' sh 'vendor/bin/phpunit --log-junit unit_test_results.xml --testdox tests'
} }
post { post {
success { always {
archiveArtifacts 'unit_test_results.xml' archiveArtifacts 'unit_test_results.xml'
} }
} }
@ -47,10 +47,10 @@ pipeline {
} }
} }
steps { steps {
sh 'php vendor/bin/phpstan analyse -c phpstan.neon --error-format=prettyJson > static_code_analysis_results.json' sh 'php -d memory_limit=1G vendor/bin/phpstan analyse -c phpstan.neon --error-format=prettyJson > static_code_analysis_results.json'
} }
post { post {
success { always {
archiveArtifacts 'static_code_analysis_results.json' archiveArtifacts 'static_code_analysis_results.json'
} }
} }

View File

@ -7,6 +7,7 @@ const ROOT = __DIR__;
class Container class Container
{ {
static SokoWeb\Interfaces\Database\IConnection $dbConnection; static SokoWeb\Interfaces\Database\IConnection $dbConnection;
static SokoWeb\Interfaces\Database\IAuditLogger $auditLogger;
static SokoWeb\Routing\RouteCollection $routeCollection; static SokoWeb\Routing\RouteCollection $routeCollection;
static SokoWeb\Interfaces\Session\ISessionHandler $sessionHandler; static SokoWeb\Interfaces\Session\ISessionHandler $sessionHandler;
static SokoWeb\Interfaces\Request\IRequest $request; static SokoWeb\Interfaces\Request\IRequest $request;

View File

@ -0,0 +1,75 @@
<?php namespace SokoWeb\Database;
use SokoWeb\Interfaces\Database\IAuditLogger;
use SokoWeb\Interfaces\Database\IConnection;
abstract class AuditLoggerBase implements IAuditLogger
{
const LOG_TYPE_INSERT = 'insert';
const LOG_TYPE_UPDATE = 'update';
const LOG_TYPE_DELETE = 'delete';
private IConnection $connection;
private string $logTable;
public function __construct(IConnection $connection, string $logTable)
{
$this->connection = $connection;
$this->logTable = $logTable;
}
public function logInsert(string $localTable, $localId): void
{
$data = [
'local_table' => $localTable,
'local_id' => $localId,
'type' => static::LOG_TYPE_INSERT,
'modifier_id' => $this->getModifierId(),
];
$query = 'INSERT INTO ' . Utils::backtick($this->logTable) . ' SET ' . Utils::generateColumnsWithBinding(array_keys($data));
$stmt = $this->connection->prepare($query);
$stmt->execute($data);
}
public function logUpdate(string $localTable, $localId, array $diff): void
{
$data = [
'local_table' => $localTable,
'local_id' => $localId,
'type' => static::LOG_TYPE_UPDATE,
'modifier_id' => $this->getModifierId(),
'column' => null,
'old' => null,
'new' => null,
];
$query = 'INSERT INTO ' . Utils::backtick($this->logTable) . ' SET ' . Utils::generateColumnsWithBinding(array_keys($data));
$stmt = $this->connection->prepare($query);
foreach ($diff as $name => $values) {
$data['column'] = $name;
$data['old'] = $values['old'];
$data['new'] = $values['new'];
$stmt->execute($data);
}
}
public function logDelete(string $localTable, $localId, array $attributes): void
{
$data = [
'local_table' => $localTable,
'local_id' => $localId,
'type' => static::LOG_TYPE_DELETE,
'modifier_id' => $this->getModifierId(),
'old' => $attributes,
];
$query = 'INSERT INTO ' . Utils::backtick($this->logTable) . ' SET ' . Utils::generateColumnsWithBinding(array_keys($data));
$stmt = $this->connection->prepare($query);
$stmt->execute($data);
}
abstract protected function getModifierId();
}

View File

@ -2,6 +2,8 @@
use SokoWeb\Interfaces\Database\IConnection; use SokoWeb\Interfaces\Database\IConnection;
use SokoWeb\Database\Utils; use SokoWeb\Database\Utils;
use SokoWeb\Interfaces\Database\IAuditLogger;
use SokoWeb\Interfaces\Database\IResultSet;
class Modify class Modify
{ {
@ -9,6 +11,8 @@ class Modify
private string $table; private string $table;
private ?IAuditLogger $auditLogger;
private string $idName = 'id'; private string $idName = 'id';
private array $attributes = []; private array $attributes = [];
@ -17,10 +21,13 @@ class Modify
private bool $autoIncrement = true; private bool $autoIncrement = true;
public function __construct(IConnection $connection, string $table) private ?array $diff = null;
public function __construct(IConnection $connection, string $table, ?IAuditLogger $auditLogger = null)
{ {
$this->connection = $connection; $this->connection = $connection;
$this->table = $table; $this->table = $table;
$this->auditLogger = $auditLogger;
} }
public function setIdName(string $idName): Modify public function setIdName(string $idName): Modify
@ -65,6 +72,13 @@ class Modify
return $this; return $this;
} }
public function setDiff(array $diff): Modify
{
$this->diff = $diff;
return $this;
}
public function getId() public function getId()
{ {
return $this->attributes[$this->idName]; return $this->attributes[$this->idName];
@ -89,6 +103,10 @@ class Modify
$stmt = $this->connection->prepare($query); $stmt = $this->connection->prepare($query);
$stmt->execute([$this->idName => $this->attributes[$this->idName]]); $stmt->execute([$this->idName => $this->attributes[$this->idName]]);
if ($this->auditLogger !== null) {
$this->auditLogger->logDelete($this->table, $this->attributes[$this->idName], $this->attributes);
}
} }
private function insert(): void private function insert(): void
@ -99,7 +117,7 @@ class Modify
$this->attributes[$this->idName] = $this->generateKey(); $this->attributes[$this->idName] = $this->generateKey();
} }
$set = $this->generateColumnsWithBinding(array_keys($this->attributes)); $set = Utils::generateColumnsWithBinding(array_keys($this->attributes));
$query = 'INSERT INTO ' . Utils::backtick($this->table) . ' SET ' . $set; $query = 'INSERT INTO ' . Utils::backtick($this->table) . ' SET ' . $set;
@ -109,32 +127,62 @@ class Modify
if ($this->autoIncrement) { if ($this->autoIncrement) {
$this->attributes[$this->idName] = $this->connection->lastId(); $this->attributes[$this->idName] = $this->connection->lastId();
} }
if ($this->auditLogger !== null) {
$this->auditLogger->logInsert($this->table, $this->attributes[$this->idName]);
}
} }
private function update(): void private function update(): void
{ {
if ($this->auditLogger !== null) {
$this->generateDiff();
if (count($this->diff) === 0) {
return;
}
}
$attributes = $this->attributes; $attributes = $this->attributes;
unset($attributes[$this->idName]); unset($attributes[$this->idName]);
$set = $this->generateColumnsWithBinding(array_keys($attributes)); $set = Utils::generateColumnsWithBinding(array_keys($attributes));
$query = 'UPDATE ' . Utils::backtick($this->table) . ' SET ' . $set . ' WHERE ' . Utils::backtick($this->idName) . '=?'; $query = 'UPDATE ' . Utils::backtick($this->table) . ' SET ' . $set . ' WHERE ' . Utils::backtick($this->idName) . '=?';
$stmt = $this->connection->prepare($query); $stmt = $this->connection->prepare($query);
$stmt->execute(array_merge($attributes, [$this->idName => $this->attributes[$this->idName]])); $stmt->execute(array_merge($attributes, [$this->idName => $this->attributes[$this->idName]]));
}
public static function generateColumnsWithBinding(array $columns): string if ($this->auditLogger !== null) {
{ $this->auditLogger->logUpdate($this->table, $this->attributes[$this->idName], $this->diff);
array_walk($columns, function(&$value, $key) { }
$value = Utils::backtick($value) . '=?';
});
return implode(',', $columns);
} }
private function generateKey(): string private function generateKey(): string
{ {
return substr(hash('sha256', serialize($this->attributes) . random_bytes(5) . microtime()), 0, 7); return substr(hash('sha256', serialize($this->attributes) . random_bytes(5) . microtime()), 0, 7);
} }
private function generateDiff(): void
{
if (isset($this->diff)) {
return;
}
$this->diff = [];
$original = $this->readFromDb(array_keys($this->attributes));
foreach ($original as $key => $value) {
if ($value !== $this->attributes[$key]) {
$this->diff[$key] = ['old' => $value, 'new' => $this->attributes[$key]];
}
}
}
private function readFromDb($columns): array
{
return (new Select($this->connection, $this->table))
->columns($columns)
->where($this->idName, '=', $this->attributes[$this->idName])
->execute()
->fetch(IResultSet::FETCH_ASSOC);
}
} }

View File

@ -5,4 +5,13 @@ class Utils {
{ {
return '`' . $name . '`'; return '`' . $name . '`';
} }
public static function generateColumnsWithBinding(array $columns): string
{
array_walk($columns, function(&$value, $key) {
$value = static::backtick($value) . '=?';
});
return implode(',', $columns);
}
} }

View File

@ -0,0 +1,10 @@
<?php namespace SokoWeb\Interfaces\Database;
interface IAuditLogger
{
public function logInsert(string $localTable, $localId): void;
public function logUpdate(string $localTable, $localId, array $diff): void;
public function logDelete(string $localTable, $localId, array $attributes): void;
}

View File

@ -117,19 +117,23 @@ class PersistentDataManager
$modified = $model->toArray(); $modified = $model->toArray();
$id = $model->getId(); $id = $model->getId();
$modify = new Modify(\Container::$dbConnection, $model::getTable()); $modify = new Modify(\Container::$dbConnection, $model::getTable(), \Container::$auditLogger);
if ($id !== null) { if ($id !== null) {
$original = $model->getSnapshot(); $original = $model->getSnapshot();
$diff = [];
foreach ($original as $key => $value) { foreach ($original as $key => $value) {
if ($value === $modified[$key]) { if ($value === $modified[$key]) {
unset($modified[$key]); unset($modified[$key]);
} else {
$diff[$key] = ['old' => $value, 'new' => $modified[$key]];
} }
} }
if (count($modified) > 0) { if (count($modified) > 0) {
$modify->setId($id); $modify->setId($id);
$modify->setDiff($diff);
$modify->fill($modified); $modify->fill($modified);
$modify->save(); $modify->save();
} }
@ -145,8 +149,9 @@ class PersistentDataManager
public function deleteFromDb(Model $model): void public function deleteFromDb(Model $model): void
{ {
$modify = new Modify(\Container::$dbConnection, $model::getTable()); $modify = new Modify(\Container::$dbConnection, $model::getTable(), \Container::$auditLogger);
$modify->setId($model->getId()); $modify->setId($model->getId());
$modify->fill($model->toArray());
$modify->delete(); $modify->delete();
$model->setId(null); $model->setId(null);

View File

@ -1,9 +1,12 @@
<?php namespace SokoWeb\Response; <?php namespace SokoWeb\Response;
use ErrorException;
use Exception;
use SokoWeb\Interfaces\Response\IRedirect; use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired; use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Authorization\ISecured; use SokoWeb\Interfaces\Authorization\ISecured;
use SokoWeb\Interfaces\Database\IConnection;
use SokoWeb\Interfaces\Request\IRequest; use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Response\Redirect; use SokoWeb\Response\Redirect;
use SokoWeb\Response\HtmlContent; use SokoWeb\Response\HtmlContent;
@ -14,6 +17,8 @@ class HttpResponse
{ {
private IRequest $request; private IRequest $request;
private IConnection $dbConnection;
private RouteCollection $routeCollection; private RouteCollection $routeCollection;
private array $appConfig; private array $appConfig;
@ -26,12 +31,16 @@ class HttpResponse
public function __construct( public function __construct(
IRequest $request, IRequest $request,
IConnection $dbConnection,
RouteCollection $routeCollection, RouteCollection $routeCollection,
array $appConfig, array $appConfig,
string $requestMethod, string $requestMethod,
string $requestUrl string $requestUrl
) { ) {
set_error_handler([$this, 'exceptionsErrorHandler']);
$this->request = $request; $this->request = $request;
$this->dbConnection = $dbConnection;
$this->routeCollection = $routeCollection; $this->routeCollection = $routeCollection;
$this->appConfig = $appConfig; $this->appConfig = $appConfig;
$this->method = strtolower($requestMethod); $this->method = strtolower($requestMethod);
@ -39,6 +48,11 @@ class HttpResponse
$this->rawUrl = $requestUrl; $this->rawUrl = $requestUrl;
} }
public function exceptionsErrorHandler($severity, $message, $filename, $lineno)
{
throw new ErrorException($message, 0, $severity, $filename, $lineno);
}
public function render(): void public function render(): void
{ {
$match = $this->routeCollection->match($this->method, $this->parsedUrl['path']); $match = $this->routeCollection->match($this->method, $this->parsedUrl['path']);
@ -75,7 +89,16 @@ class HttpResponse
return; return;
} }
$response = call_user_func([$controller, $handler[1]]); $this->dbConnection->startTransaction();
try {
$response = call_user_func([$controller, $handler[1]]);
} catch (Exception $exception) {
$this->dbConnection->rollback();
$this->render500($exception);
return;
}
$this->dbConnection->commit();
if ($response instanceof IContent) { if ($response instanceof IContent) {
header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8'); header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8');
$response->render(); $response->render();
@ -106,4 +129,17 @@ class HttpResponse
header('Content-Type: text/html; charset=UTF-8', true, 404); header('Content-Type: text/html; charset=UTF-8', true, 404);
$content->render(); $content->render();
} }
private function render500(Exception $exception): void
{
if (empty($_ENV['DEV'])) {
$exceptionToPrint = null;
} else {
$exceptionToPrint = (string)$exception;
}
$content = new HtmlContent($this->appConfig['error500View'], ['exceptionToPrint' => $exceptionToPrint]);
header('Content-Type: text/html; charset=UTF-8', true, 500);
$content->render();
}
} }

View File

@ -15,9 +15,11 @@ $dotenv->load();
class Container class Container
{ {
static SokoWeb\Interfaces\Database\IConnection $dbConnection; static SokoWeb\Interfaces\Database\IConnection $dbConnection;
static SokoWeb\Interfaces\Database\IAuditLogger $auditLogger;
static SokoWeb\Routing\RouteCollection $routeCollection; static SokoWeb\Routing\RouteCollection $routeCollection;
static SokoWeb\Interfaces\Session\ISessionHandler $sessionHandler; static SokoWeb\Interfaces\Session\ISessionHandler $sessionHandler;
static SokoWeb\Interfaces\Request\IRequest $request; static SokoWeb\Interfaces\Request\IRequest $request;
} }
Container::$dbConnection = new SokoWeb\Database\Mysql\Connection($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], $_ENV['DB_NAME']); Container::$dbConnection = new SokoWeb\Database\Mysql\Connection($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], $_ENV['DB_NAME']);
Container::$auditLogger = new {app}\Database\AuditLogger(Container::$dbConnection, 'audit_log');

View File

@ -13,3 +13,16 @@ CREATE TABLE `users` (
UNIQUE KEY `email` (`email`), UNIQUE KEY `email` (`email`),
UNIQUE KEY `google_sub` (`google_sub`) UNIQUE KEY `google_sub` (`google_sub`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `audit_log` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`local_table` varchar(255) NOT NULL,
`local_id` int(10) unsigned NOT NULL,
`type` enum('insert','update','delete') NOT NULL,
`date` timestamp NOT NULL DEFAULT current_timestamp(),
`modifier_id` int(10) unsigned NULL,
`column` varchar(255) NULL,
`old` text NULL,
`new` text NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View File

@ -0,0 +1,11 @@
<?php namespace {app}\Database;
use SokoWeb\Database\AuditLoggerBase;
class AuditLogger extends AuditLoggerBase
{
protected function getModifierId()
{
\Container::$request->user()->getUniqueId();
}
}