Initial setup for framework

This commit is contained in:
Martijn de Boer 2024-02-03 14:08:03 +01:00
parent 3ddce5e41f
commit 3cc233de3a
16 changed files with 720 additions and 0 deletions

6
DatabaseMigrations.php Normal file
View file

@ -0,0 +1,6 @@
<?php
require_once("Settings.php");
$db = new Database(!file_exists(SQLITE_DB));
$db->runMigrations();
$db->close();

19
LastLog.php Normal file
View file

@ -0,0 +1,19 @@
<?php
require_once("Settings.php");
$limit = 100;
if (isset($argv[1]) && is_numeric($argv[1])) {
$limit = $argv[1];
}
$db = new Database();
$stmt = $db::$handle->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();

63
Loader.php Normal file
View file

@ -0,0 +1,63 @@
<?php
class Loader {
protected static array $dirs = [
"LIB_MIGRATIONS_DIR",
"LIB_TASKS_DIR",
"LIB_TEMPLATE_DIR",
"LIB_LIB_DIR",
"TASKS_DIR",
"TEMPLATE_DIR",
"LIB_DIR"
];
protected static $classmap = [];
public static function _autoload($class) {
if (count(self::$classmap) === 0) {
self::buildRecursiveClassMap();
}
if (isset(self::$classmap[$class])) {
require_once(self::$classmap[$class]);
return;
}
}
protected static function buildRecursiveClassMap() {
foreach (self::$dirs as $dir) {
if (!defined($dir)) {
continue;
}
$dir = constant($dir);
if (!is_dir($dir) || !is_readable($dir)) {
continue;
}
self::buildClassMap($dir);
}
}
protected static function buildClassMap($dir) {
$files = scandir($dir);
foreach ($files as $file) {
if ($file === "." || $file === "..") {
continue;
}
if (is_dir($file)) {
self::buildClassMap($file);
} else {
$parts = explode(".", $file);
$ext = array_pop($parts);
if ($ext === "php") {
$classname = array_shift($parts);
self::$classmap[$classname] = $dir . "/" . $file;
}
}
}
}
}
spl_autoload_register("Loader::_autoload");

17
RunTask.php Normal file
View file

@ -0,0 +1,17 @@
<?php
require_once("Settings.php");
if (!isset($argv[1]) || empty($argv[1])) {
Logger::log("No task specified", Logger::ERROR, "RunTask");
exit(1);
}
$task = $argv[1] . "Task";
if (!file_exists(LIB_DIR . "/tasks/" . $task . ".php")) {
Logger::log("Task " . $task . " does not exist", Logger::ERROR, "RunTask");
exit(1);
}
require_once(LIB_DIR . "/tasks/" . $task . ".php");
$task = new $task();
$task->run();

24
Settings.php Normal file
View file

@ -0,0 +1,24 @@
<?php
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
set_exception_handler(function ($exception) {
echo $exception->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");

120
lib/Cache.php Normal file
View file

@ -0,0 +1,120 @@
<?php
class Cache {
public static function getCacheFile(string $name, $maxAge = 3600) {
$cacheFile = CACHE_DIR . "/" . $name;
if (file_exists($cacheFile) && filemtime($cacheFile) > 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;
}
}
}

95
lib/Database.php Normal file
View file

@ -0,0 +1,95 @@
<?php
class Database
{
public static $handle = null;
public function __construct($createDatabase = false)
{
if (self::$handle === null) {
self::$handle = new SQLite3(SQLITE_DB);
self::$handle->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();
}
}

28
lib/EmptyTemplate.php Normal file
View file

@ -0,0 +1,28 @@
<?php
class EmptyTemplate implements ITemplate
{
protected Template $template;
protected ?object $options = null;
public function __construct(Template &$template, ?object $options = null)
{
$this->template = $template;
$this->options = $options;
}
public function load()
{
}
public function pretransform()
{
}
public function transform()
{
}
public function save()
{
}
}

8
lib/ITemplate.php Normal file
View file

@ -0,0 +1,8 @@
<?php
interface ITemplate {
public function __construct(Template &$template, ?object $options = null);
public function load();
public function pretransform();
public function transform();
public function save();
}

53
lib/KeyValue.php Normal file
View file

@ -0,0 +1,53 @@
<?php
class KeyValue {
protected static $kv = null;
public static function get(string $keyname, ?string $channel = null) {
if (self::$kv === null) {
self::load();
}
if ($channel === null) {
$channel = "all";
}
if (isset(self::$kv[$keyname][$channel])) {
return self::$kv[$keyname][$channel];
}
return null;
}
public static function save(string $keyname, string $value, ?string $channel = null) {
if (self::$kv === null) {
self::load();
}
if ($channel === null) {
$channel = "all";
}
$db = new Database();
$stmt = $db::$handle->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"];
}
}
}

40
lib/Logger.php Normal file
View file

@ -0,0 +1,40 @@
<?php
class Logger {
const INFO = "INFO";
const WARNING = "WARNING";
const ERROR = "ERROR";
const DEBUG = "DEBUG";
const IOREAD = "IOREAD";
const IOWRITE = "IOWRITE";
const METRICS = "METRICS";
public static function getColor(string $level):string {
match ($level) {
self::INFO => $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;
}
}

10
lib/Task.php Normal file
View file

@ -0,0 +1,10 @@
<?php
abstract class Task {
public function run() {
Logger::log("Running task " . get_class($this), Logger::DEBUG, "Task");
$this->execute();
Logger::log("Finished task " . get_class($this), Logger::DEBUG, "Task");
}
abstract protected function execute();
}

207
lib/Template.php Normal file
View file

@ -0,0 +1,207 @@
<?php
class Template {
protected $template = null;
protected $data = array();
public function __construct(string $template, ?array $parentData = null) {
$this->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 {{?<variablename>}}some content{{/<variablename>}}, 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);
}
}

30
schema/_schema.sql Normal file
View file

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

0
schema/logs-000.sql Normal file
View file

View file