From 3cc233de3ac63e56f5de05ab74f3b1f0f4979081 Mon Sep 17 00:00:00 2001 From: Martijn de Boer Date: Sat, 3 Feb 2024 14:08:03 +0100 Subject: [PATCH] Initial setup for framework --- DatabaseMigrations.php | 6 ++ LastLog.php | 19 ++++ Loader.php | 63 ++++++++++++ RunTask.php | 17 ++++ Settings.php | 24 +++++ lib/Cache.php | 120 ++++++++++++++++++++++ lib/Database.php | 95 +++++++++++++++++ lib/EmptyTemplate.php | 28 ++++++ lib/ITemplate.php | 8 ++ lib/KeyValue.php | 53 ++++++++++ lib/Logger.php | 40 ++++++++ lib/Task.php | 10 ++ lib/Template.php | 207 ++++++++++++++++++++++++++++++++++++++ schema/_schema.sql | 30 ++++++ schema/logs-000.sql | 0 schema/migrations-000.sql | 0 16 files changed, 720 insertions(+) create mode 100644 DatabaseMigrations.php create mode 100644 LastLog.php create mode 100644 Loader.php create mode 100644 RunTask.php create mode 100644 Settings.php create mode 100644 lib/Cache.php create mode 100644 lib/Database.php create mode 100644 lib/EmptyTemplate.php create mode 100644 lib/ITemplate.php create mode 100644 lib/KeyValue.php create mode 100644 lib/Logger.php create mode 100644 lib/Task.php create mode 100644 lib/Template.php create mode 100644 schema/_schema.sql create mode 100644 schema/logs-000.sql create mode 100644 schema/migrations-000.sql diff --git a/DatabaseMigrations.php b/DatabaseMigrations.php new file mode 100644 index 0000000..8f5deac --- /dev/null +++ b/DatabaseMigrations.php @@ -0,0 +1,6 @@ +runMigrations(); +$db->close(); \ No newline at end of file diff --git a/LastLog.php b/LastLog.php new file mode 100644 index 0000000..7cb196a --- /dev/null +++ b/LastLog.php @@ -0,0 +1,19 @@ +prepare("SELECT * FROM (SELECT * FROM `logs` ORDER BY datetime(`created`) DESC, `id` DESC LIMIT " . $limit . ") ORDER BY `created` ASC, `id` ASC"); +$result = $stmt->execute(); + +while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $levelColor = Logger::getColor($row["level"]); + + echo "\033[0;37m[" . $row["created"] . "] " . $levelColor . $row["level"] . "\033[0;37m: " . $row["message"] . "\033[0m" . PHP_EOL; +} + +$db->close(); \ No newline at end of file diff --git a/Loader.php b/Loader.php new file mode 100644 index 0000000..00d348b --- /dev/null +++ b/Loader.php @@ -0,0 +1,63 @@ +run(); \ No newline at end of file diff --git a/Settings.php b/Settings.php new file mode 100644 index 0000000..502e6b2 --- /dev/null +++ b/Settings.php @@ -0,0 +1,24 @@ +getMessage() . " on line " . $exception->getLine() . " in " . $exception->getFile() . PHP_EOL; + exit; +}); + +if (!defined("PROJECT_DIR") ) { + define("PROJECT_DIR", rtrim( dirname(__FILE__ . "/../.."), "/")); +} + +if (!defined("SQLITE_DB")) { + throw new Exception("SQLITE_DB not defined"); +} + +define("LIB_MIGRATIONS_DIR", dirname(__FILE__) . "/schema"); +define("LIB_TASKS_DIR", dirname(__FILE__) . "/tasks"); +define("LIB_TEMPLATE_DIR", dirname(__FILE__) . "/templates"); +define("LIB_LIB_DIR", dirname(__FILE__) . "/lib"); + +require_once(dirname(__FILE__) . "/Loader.php"); \ No newline at end of file diff --git a/lib/Cache.php b/lib/Cache.php new file mode 100644 index 0000000..119e2c5 --- /dev/null +++ b/lib/Cache.php @@ -0,0 +1,120 @@ + time() - $maxAge) { + Logger::log("Cache hit for " . $name, Logger::IOREAD, "Cache"); + return COMPRESS_LOCAL_CACHE ? gzuncompress(file_get_contents($cacheFile)) : file_get_contents($cacheFile); + } + + return null; + } + + public static function saveCacheFile(string $name, string $data) { + $cacheFile = CACHE_DIR . "/" . $name; + file_put_contents($cacheFile, COMPRESS_LOCAL_CACHE ? gzcompress($data,9) : $data ); + Logger::log("Saved cache file " . $name, Logger::IOWRITE, "Cache"); + } + + public static function publicCacheExists(string $name, bool $skipExtensionCheck = false) { + // For now cache is not reliable since we use a generated name. + return false; + /*if (!$skipExtensionCheck) { + $ext = substr($name, strrpos($name, ".") + 1); + if (strtolower($ext) == "jpg" || strtolower($ext) == "jpeg" || strtolower($ext) == "png") { + $name = str_replace("." . $ext, ".webp", strtolower($name)); + } + } + + $cacheFile = PUBLIC_CACHE_DIR . "/" . $name; + + try { + $fe = file_exists($cacheFile); + } catch (Exception $e) { + $fe = false; + Logger::log("Could not check if public cache file " . $name . " exists: " . $e->getMessage(), Logger::ERROR, "Cache"); + } + + return $fe;*/ + } + + public static function savePublicCacheFile(string $name, string $data, int $cacheLevel = 70, bool $skipExtensionCheck = false) { + $name = strtolower($name); + + $ext = substr($name, strrpos($name, ".") + 1); + if ($skipExtensionCheck || strtolower($ext) == "jpg" || strtolower($ext) == "jpeg" || strtolower($ext) == "png") { + $size = strlen($data); + $img = new Imagick(); + $img->readImageBlob($data); + $img->setImageFormat('webp'); + $img->setImageCompressionQuality($cacheLevel); + $img->stripImage(); + $data = $img->getImageBlob(); + $name = str_replace("." . $ext, ".webp", strtolower($name)); + + $cacheFile = PUBLIC_CACHE_DIR . "/" . $name; + $blurhash = self::generateBlurHash($cacheFile, $img, $skipExtensionCheck); + if (!empty($blurhash)) { + $name = "BH-" . bin2hex($blurhash) . "-W" . $img->getImageWidth() . "xH" . $img->getImageHeight() . ".webp"; + $cacheFile = PUBLIC_CACHE_DIR . "/" . $name; + } + + // For now don't transfer file if its generated name exists + if (file_exists($cacheFile)) { + Logger::log("Public cache file " . $name . " already exists", Logger::INFO, "Cache"); + return PUBLIC_CACHE_URL . "/" . $name; + } + + Logger::log("Compressed image from " . $size . " to " . strlen($data) . " bytes", Logger::METRICS, "Cache"); + } + + $context = stream_context_create([ + 's3' => [ + 'ACL' => 'public-read' + ] + ]); + + file_put_contents($cacheFile, $data, 0, $context); + + Logger::log("Saved public cache file " . $name, Logger::IOWRITE, "Cache"); + + return PUBLIC_CACHE_URL . "/" . $name; + } + + public static function generateBlurHash(string $cacheFile, ?IMagick $img = null, bool $skipExtensionCheck = false):?string { + $ext = substr($cacheFile, strrpos($cacheFile, ".") + 1); + if ($skipExtensionCheck || strtolower($ext) == "jpg" || strtolower($ext) == "jpeg" || strtolower($ext) == "png" || strtolower($ext) == "webp") { + + $hashFile = strtolower($cacheFile); + $hashFileName = substr($hashFile, strrpos($hashFile, "/") + 1); + if (file_exists($hashFile)) { + return null; + } + + $pixels = []; + + if ($img === null) { + $img = new Imagick(); + $img->readImage($cacheFile); + } + + $width = $img->getImageWidth(); + $height = $img->getImageHeight(); + + for ($y = 0; $y < $height; $y++) { + $row = []; + for ($x = 0; $x < $width; $x++) { + $pixel = $img->getImagePixelColor($x, $y); + $color = $pixel->getColor(); + $row[] = [$color["r"], $color["g"], $color["b"]]; + } + $pixels[] = $row; + } + + $hash = \kornrunner\Blurhash\Blurhash::encode($pixels, BLURHASH_X, BLURHASH_Y); + + return $hash; + } + } +} \ No newline at end of file diff --git a/lib/Database.php b/lib/Database.php new file mode 100644 index 0000000..a69f58f --- /dev/null +++ b/lib/Database.php @@ -0,0 +1,95 @@ +enableExceptions(true); + + + if ($createDatabase === true) { + $this->createDatabase(); + } + } + } + + public function createDatabase() + { + $schemaFiles = [ + LIB_MIGRATIONS_DIR . "/_schema.sql", + MIGRATIONS_DIR . "/_schema.sql" + ]; + + foreach ($schemaFiles as $schemaFile) { + if (file_exists($schemaFile)) { + $schema = file_get_contents($schemaFile); + self::$handle->exec($schema); + } + } + } + + public function runMigrations() + { + $currentTableVersions = []; + + $result = self::$handle->query("SELECT `table`,`version` FROM `migrations`"); + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $currentTableVersions[$row["table"]] = $row["version"]; + } + + $migrations = []; + $migrationPaths = [LIB_MIGRATIONS_DIR, MIGRATIONS_DIR]; + foreach ($migrationPaths as $migrationPath) { + if (is_dir($migrationPath)) { + $migrations = array_merge($migrations, scandir($migrationPath)); + } + } + + $migrationCount = 0; + + foreach ($migrations as $migration) { + if ($migration == "." || $migration == ".." || $migration == "_schema.sql") { + continue; + } + + $migrationParts = explode("-", str_replace(".sql", "", $migration)); + $migrationTable = $migrationParts[0]; + $migrationVersion = $migrationParts[1]; + + $run = false; + + if (!isset($currentTableVersions[$migrationTable])) { + $run = true; + } else { + if ($migrationVersion > $currentTableVersions[$migrationTable]) { + $run = true; + } + } + + if ($run === true) { + Logger::log("Found migration for table " . $migrationTable . " version " . $migrationVersion, LOGGER::INFO, "Database"); + $migrationContents = file_get_contents(MIGRATIONS_DIR . "/" . $migration); + if (@self::$handle->exec($migrationContents)) { + self::$handle->exec("INSERT INTO `migrations` (`table`,`version`, `schemafile`, `created`) VALUES ('" . $migrationTable . "'," . $migrationVersion . ",'" . $migration . "', strftime('%Y-%m-%d %H:%M:%S','now'))"); + $migrationCount++; + } else { + Logger::log("Failed to run migration for table " . $migrationTable . " version " . $migrationVersion . " with error: " . self::$handle->lastErrorMsg(), LOGGER::ERROR, "Database"); + } + } + } + + if ($migrationCount > 0) { + Logger::log("Ran " . $migrationCount . " migrations", LOGGER::DEBUG, "Database"); + } else { + Logger::log("No migrations to run", LOGGER::DEBUG, "Database"); + } + } + + public function close() + { + self::$handle->close(); + } +} diff --git a/lib/EmptyTemplate.php b/lib/EmptyTemplate.php new file mode 100644 index 0000000..760c8fb --- /dev/null +++ b/lib/EmptyTemplate.php @@ -0,0 +1,28 @@ +template = $template; + $this->options = $options; + } + + public function load() + { + } + + public function pretransform() + { + } + + public function transform() + { + } + + public function save() + { + } +} diff --git a/lib/ITemplate.php b/lib/ITemplate.php new file mode 100644 index 0000000..a78f831 --- /dev/null +++ b/lib/ITemplate.php @@ -0,0 +1,8 @@ +prepare("INSERT OR REPLACE INTO `keyvalue` (`key`, `value`, `channel`, `created`) VALUES (:key, :value, :channel, strftime('%Y-%m-%d %H:%M:%S','now'))"); + $stmt->bindValue(":key", $keyname, SQLITE3_TEXT); + $stmt->bindValue(":value", $value, SQLITE3_TEXT); + $stmt->bindValue(":channel", $channel, SQLITE3_TEXT); + $stmt->execute(); + + self::$kv[$keyname][$channel] = $value; + } + + protected static function load() { + $db = new Database(); + $stmt = $db::$handle->prepare("SELECT * FROM `keyvalue`"); + $result = $stmt->execute(); + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $channel = $row["channel"]; + if ($channel === null) { + $channel = "all"; + } + + self::$kv[$row["key"]][$channel] = $row["value"]; + } + } +} \ No newline at end of file diff --git a/lib/Logger.php b/lib/Logger.php new file mode 100644 index 0000000..ce5acc6 --- /dev/null +++ b/lib/Logger.php @@ -0,0 +1,40 @@ + $levelColor = "\033[0;32m", + self::WARNING => $levelColor = "\033[0;33m", + self::ERROR => $levelColor = "\033[0;31m", + self::DEBUG => $levelColor = "\033[0;34m", + self::IOREAD => $levelColor = "\033[0;35m", + self::IOWRITE => $levelColor = "\033[0;36m", + self::METRICS => $levelColor = "\033[0;41m", + default => $levelColor = "\033[0;37m" + }; + + return $levelColor; + } + + public static function log(string $message, string $level = "INFO", string $context = "") { + + $db = new Database(); + $stmt = $db::$handle->prepare("INSERT INTO `logs` (`level`, `message`, `context`, `created`) VALUES (:level, :message, :context, strftime('%Y-%m-%d %H:%M:%S','now'))"); + $stmt->bindValue(":level", $level, SQLITE3_TEXT); + $stmt->bindValue(":message", $message, SQLITE3_TEXT); + $stmt->bindValue(":context", $context, SQLITE3_TEXT); + + @$stmt->execute(); + + $levelColor = self::getColor($level); + + echo "\033[0;37m[" . date("Y-m-d H:i:s") . "] " . $levelColor . $level . "\033[0;37m: " . $message . "\033[0m" . PHP_EOL; + } +} \ No newline at end of file diff --git a/lib/Task.php b/lib/Task.php new file mode 100644 index 0000000..f0102d6 --- /dev/null +++ b/lib/Task.php @@ -0,0 +1,10 @@ +execute(); + Logger::log("Finished task " . get_class($this), Logger::DEBUG, "Task"); + } + + abstract protected function execute(); +} \ No newline at end of file diff --git a/lib/Template.php b/lib/Template.php new file mode 100644 index 0000000..9397ccd --- /dev/null +++ b/lib/Template.php @@ -0,0 +1,207 @@ +template = $template; + + if (isset($parentData)) { + $this->data = $parentData; + } + } + + public function set(string $key, $value) { + $this->data[$key] = $value; + } + + public function render($options = null): string { + Logger::log("Rendering template " . $this->template, Logger::IOREAD, "Template"); + + $class = is_object($options) && isset($options->class) ? new $options->class($this, $options) : new EmptyTemplate($this, $options); + $class->load(); + $contents = file_get_contents(TEMPLATE_DIR . "/" . $this->template); + + $class->pretransform(); + // Replace includes of templates + $matches = array(); + preg_match_all("/{{include (.*\.html)(.*)}}/", $contents, $matches); + for ($i = 0; $i < count($matches[1]); $i++) { + $matchString = $matches[0][$i]; + $match = $matches[1][$i]; + $options = trim($matches[2][$i]); + + $include = new Template(dirname($this->template) . "/" . $match, $this->data); + $contents = str_replace($matchString, $include->render( + !empty($options) ? json_decode($options, false, 512, JSON_THROW_ON_ERROR) : null + ), $contents); + } + + $class->transform(); + + // Replace variables + foreach ($this->data as $key => $value) { + if (is_string($value) || is_numeric($value)) { + $contents = str_replace("{{" . $key . "}}", $value, $contents); + } else { + $contents = str_replace("{{" . $key . "}}", "", $contents); + } + } + + // Conditional tags are written as {{?}}some content{{/}}, find conditional tags and evaluate them if they exist in $this->data + $matches = array(); + preg_match_all("/{{\?(.*)}}(.*){{\/(.*)}}/sU", $contents, $matches); + for ($i = 0; $i < count($matches[1]); $i++) { + $matchString = $matches[0][$i]; + $match = $matches[1][$i]; + $content = $matches[2][$i]; + + if (isset($this->data[$match])) { + $contents = str_replace($matchString, $content, $contents); + } else { + $contents = str_replace($matchString, "", $contents); + } + } + + $class->save(); + + return $contents; + } + + public function save(string $filename, string $contents, bool $minify = true) { + Logger::log("Saving template to " . $filename, Logger::IOWRITE, "Template"); + + if ($minify) { + $size = strlen($contents); + + $doc = new DOMDocument(); + $doc->preserveWhiteSpace = false; + $doc->formatOutput = false; + $doc->normalizeDocument(); + + libxml_use_internal_errors(true); + + $doc->loadHTML($contents); + + libxml_use_internal_errors(false); + + $options = [ + 'keep_whitespace_around' => [ + // keep whitespace around inline elements + 'b', 'big', 'i', 'small', 'tt', + 'abbr', 'acronym', 'cite', 'code', 'dfn', 'em', 'kbd', 'strong', 'samp', 'var', + 'a', 'bdo', 'br', 'img', 'map', 'object', 'q', 'span', 'sub', 'sup', + 'button', 'input', 'label', 'select', 'textarea' + ], + 'keep_whitespace_in' => [/*'script', 'style',*/ 'pre'], + 'remove_empty_attributes' => ['style', 'class'], + 'indent_characters' => "\t" + ]; + + //Comments,empty,whitespace + $xpath = new \DOMXPath($doc); + foreach ($xpath->query('//comment()') as $comment) { + $comment->parentNode->removeChild($comment); + } + + $xpath = new \DOMXPath($doc); + foreach ($options['remove_empty_attributes'] as $attr) { + foreach ($xpath->query('//*[@' . $attr . ']') as $el) { + if (trim($el->getAttribute($attr)) == '') { + $el->removeAttribute($attr); + } + } + } + + $x = new \DOMXPath($doc); + $nodeList = $x->query("//text()"); + foreach ($nodeList as $node) { + /** @var \DOMNode $node */ + + if (in_array($node->parentNode->nodeName, $options['keep_whitespace_in'])) { + continue; + }; + + $node->nodeValue = str_replace(["\r", "\n", "\t"], ' ', $node->nodeValue); + + while (strpos($node->nodeValue, ' ') !== false) { + $node->nodeValue = str_replace(' ', ' ', $node->nodeValue); + } + + if (!in_array($node->parentNode->nodeName, $options['keep_whitespace_around'])) { + if (!($node->previousSibling && in_array( + $node->previousSibling->nodeName, + $options['keep_whitespace_around'] + ))) { + $node->nodeValue = ltrim($node->nodeValue); + } + + if (!($node->nextSibling && in_array( + $node->nextSibling->nodeName, + $options['keep_whitespace_around'] + ))) { + $node->nodeValue = rtrim($node->nodeValue); + } + } + + if ((strlen($node->nodeValue) == 0)) { + $node->parentNode->removeChild($node); + } + } + + $contents = $doc->saveHTML(); + + Logger::log("Minified template from " . $size . " to " . strlen($contents) . " bytes", Logger::METRICS, "Template"); + } + + file_put_contents(PUBLIC_DIR . "/" . $filename, $contents); + } + + public function saveCss(string $filename, string $contents, bool $minify = true) + { + Logger::log("Saving css to " . $filename, Logger::IOWRITE, "Template"); + + if ($minify) { + $size = strlen($contents); + + $contents = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $contents); + $contents = str_replace(': ', ':', $contents); + $contents = str_replace(array("\r\n", "\r", "\n", "\t", ' ', ' ', ' '), '', $contents); + + Logger::log("Minified " . $filename . " from " . $size . " to " . strlen($contents) . " bytes", Logger::METRICS, "Template"); + } + + file_put_contents(PUBLIC_DIR . "/" . $filename, $contents); + } + + public function saveScript(string $filename, string $contents, bool $minify = true) + { + Logger::log("Saving script to " . $filename, Logger::IOWRITE, "Template"); + + if ($minify) { + $size = strlen($contents); + + $contents = preg_replace('/([-\+])\s+\+([^\s;]*)/', '$1 (+$2)', $contents); + $contents = preg_replace("/\s*\n\s*/", "\n", $contents); + $contents = preg_replace("/\h+/", " ", $contents); + $contents = preg_replace("/\h([^A-Za-z0-9\_\$])/", '$1', $contents); + $contents = preg_replace("/([^A-Za-z0-9\_\$])\h/", '$1', $contents); + $contents = preg_replace("/\s?([\(\[{])\s?/", '$1', $contents); + $contents = preg_replace("/\s([\)\]}])/", '$1', $contents); + $contents = preg_replace("/\s?([\.=:\-+,])\s?/", '$1', $contents); + $contents = preg_replace("/;\n/", ";", $contents); + $contents = preg_replace('/;}/', '}', $contents); + $contents = preg_replace('/\s+/', ' ', $contents); + $contents = preg_replace('/\s*([;{}])\s*/', '$1', $contents); + + $contents = preg_replace('/\s*(;{})\s*/', '$1', $contents); + + $contents = str_replace(array("\r\n", "\r", "\n", "\t", ' ', ' ', ' '), '', $contents); + + Logger::log("Minified " . $filename . " from " . $size . " to " . strlen($contents) . " bytes", Logger::METRICS, "Template"); + } + + file_put_contents(PUBLIC_DIR . "/" . $filename, $contents); + } + +} \ No newline at end of file diff --git a/schema/_schema.sql b/schema/_schema.sql new file mode 100644 index 0000000..d4a9739 --- /dev/null +++ b/schema/_schema.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS `migrations` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `table` TEXT NOT NULL, + `version` INTEGER NOT NULL, + `schemafile` TEXT NOT NULL, + `created` TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS `migrations_table` ON `migrations` (`table`, `version`); +CREATE INDEX IF NOT EXISTS `migrations_created` ON `migrations` (`created`); + +CREATE TABLE IF NOT EXISTS `logs` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `level` TEXT NOT NULL, + `message` TEXT NOT NULL, + `context` TEXT, + `created` TEXT +); + +CREATE INDEX IF NOT EXISTS `logs_level` ON `logs` (`level`, `created`); + +CREATE TABLE IF NOT EXISTS `keyvalue` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `key` TEXT NOT NULL, + `channel` TEXT DEFAULT NULL, + `value` TEXT NOT NULL, + `created` TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS `keyvalue_key` ON `keyvalue` (`key`, `channel`); diff --git a/schema/logs-000.sql b/schema/logs-000.sql new file mode 100644 index 0000000..e69de29 diff --git a/schema/migrations-000.sql b/schema/migrations-000.sql new file mode 100644 index 0000000..e69de29