diff --git a/.github/workflows/phpapp.yml b/.github/workflows/phpapp.yml index d9da25c..3a5133e 100755 --- a/.github/workflows/phpapp.yml +++ b/.github/workflows/phpapp.yml @@ -2,7 +2,7 @@ name: PHP CI/CD on: push: - branches: [ main ] + branches: [ main, dev ] pull_request: branches: [ main ] @@ -26,7 +26,7 @@ jobs: --health-retries=3 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -43,59 +43,53 @@ jobs: display_startup_errors=On error_reporting=E_ALL - - name: Check PHP installation - run: | - php -v - php -m - php --ini - - name: Validate composer.json run: composer validate --strict - name: Install dependencies run: composer install --prefer-dist --no-progress + - name: Lint PHP files + run: | + find . \ + -path ./core/vendor -prune -o \ + -path ./node_modules -prune -o \ + -name '*.php' -print0 \ + | xargs -0 -n1 -P4 php -l > /dev/null + echo "All PHP files lint clean" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Build frontend assets + run: | + npm ci + npm run build + - name: Create .env file run: | cp .env.example .env - sed -i 's/^DB_HOST=.*/DB_HOST=127.0.0.1/' .env - sed -i 's/^DB_DATABASE=.*/DB_DATABASE=phpapp/' .env - sed -i 's/^DB_USERNAME=.*/DB_USERNAME=root/' .env - sed -i 's/^DB_PASSWORD=.*/DB_PASSWORD=root/' .env + sed -i 's/^DB_HOST=.*/DB_HOST="127.0.0.1"/' .env + sed -i 's/^DB_NAME=.*/DB_NAME="phpapp"/' .env + sed -i 's/^DB_USER=.*/DB_USER="root"/' .env + sed -i 's/^DB_PASS=.*/DB_PASS="root"/' .env + cat .env - - name: Verify project structure + - name: Run migrations run: | - echo "Current directory structure:" - ls -la - if [ -d "public" ]; then - echo "Contents of public directory:" - ls -la public - else - echo "public directory not found!" - echo "Contents of root directory:" - ls -la - fi + php migrate.php migrate + php migrate.php status - name: Start PHP server run: | - # Determine the correct document root - if [ -d "public" ]; then - DOCROOT="public" - elif [ -d "www" ]; then - DOCROOT="www" - else - DOCROOT="." - fi - echo "Using document root: $DOCROOT" - - # Start PHP server with error logging - php -S 127.0.0.1:8000 -t $DOCROOT > php-server.log 2>&1 & + php -S 127.0.0.1:8000 -t www > php-server.log 2>&1 & echo $! > php-server.pid - - # Give it a moment to start + sleep 2 - - # Check if process is running + if ps -p $(cat php-server.pid) > /dev/null; then echo "PHP server process is running" else @@ -104,31 +98,52 @@ jobs: exit 1 fi - - name: Wait for server and check health + - name: Wait for server run: | max_attempts=30 attempt=1 - + while [ $attempt -le $max_attempts ]; do - echo "Attempt $attempt of $max_attempts" - if curl -s -f http://127.0.0.1:8000 > /dev/null 2>&1; then echo "Server is responding!" exit 0 fi - - # Check if server is still running + if ! ps -p $(cat php-server.pid) > /dev/null; then echo "PHP server has died. Server log:" cat php-server.log exit 1 fi - + sleep 1 attempt=$((attempt + 1)) done - + echo "Server failed to respond after $max_attempts attempts" - echo "PHP Server log:" cat php-server.log - exit 1 \ No newline at end of file + exit 1 + + - name: Smoke test pages + run: | + check() { + url="$1" + expected="$2" + code=$(curl -s --path-as-is -o /dev/null -w '%{http_code}' "http://127.0.0.1:8000$url") + if [ "$code" != "$expected" ]; then + echo "FAIL: $url returned $code, expected $expected" + cat php-server.log + exit 1 + fi + echo "OK: $url -> $code" + } + + check "/" 200 + check "/catalog/csrf_protection" 200 + check "/catalog/pagination" 200 + check "/this-page-does-not-exist" 404 + check "/../config/web" 404 + check "/index/__get" 404 + + - name: Show server log + if: failure() + run: cat php-server.log || true diff --git a/app/controller/catalog/pagination.php b/app/controller/catalog/pagination.php index bcd4686..92067af 100755 --- a/app/controller/catalog/pagination.php +++ b/app/controller/catalog/pagination.php @@ -8,7 +8,7 @@ public function index() { $this->meta->setTitle($this->language->get('meta_title')); $this->meta->setDescription($this->language->get('meta_description')); - $page = isset($this->request->get['page']) ? $this->request->get['page'] : 1; + $page = isset($this->request->get['page']) ? max(1, (int)$this->request->get['page']) : 1; $this->data['heading_title'] = $page > 1 ? $this->language->get('heading_title') . ' - ' . $page : $this->language->get('heading_title'); diff --git a/app/controller/common/footer.php b/app/controller/common/footer.php index 1189acf..69b7d5a 100755 --- a/app/controller/common/footer.php +++ b/app/controller/common/footer.php @@ -2,8 +2,6 @@ class ControllerCommonFooter extends Controller { public function index() { - $this->response->addScript('https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js', 'footer'); - $scripts = $this->response->getScripts(); $this->data['scripts'] = array(); diff --git a/app/controller/common/header.php b/app/controller/common/header.php index 9ce20f4..7d7812a 100755 --- a/app/controller/common/header.php +++ b/app/controller/common/header.php @@ -2,11 +2,11 @@ class ControllerCommonHeader extends Controller { public function index() { - $this->data['meta'] = $this->meta->getMetaTags(); + $this->data['meta'] = $this->view->raw($this->meta->getMetaTags()); $this->data['styles'] = $this->response->getStyles(); - $this->response->addScript('/assets/js/app.js'); + $this->response->addScript($this->staticfile->getAssetUri('js/app.js')); $scripts = $this->response->getScripts(); @@ -20,7 +20,7 @@ public function index() { $this->data['menu'] = $this->load->controller('common/menu'); - $this->response->addStyle('/assets/css/app.css'); + $this->response->addStyle($this->staticfile->getAssetUri('css/app.css')); $this->data['styles'] = $this->response->getStyles(); diff --git a/app/view/www/assets/css/app b/app/view/www/assets/css/app deleted file mode 100644 index ac616a7..0000000 --- a/app/view/www/assets/css/app +++ /dev/null @@ -1,2 +0,0 @@ -@tailwind base;@tailwind components;@tailwind utilities; -/*$vite$:1*/ \ No newline at end of file diff --git a/app/view/www/assets/js/app.js b/app/view/www/assets/js/app.js deleted file mode 100644 index 8b13789..0000000 --- a/app/view/www/assets/js/app.js +++ /dev/null @@ -1 +0,0 @@ - diff --git a/core/autoload.php b/core/autoload.php index 52012c9..6f190d9 100755 --- a/core/autoload.php +++ b/core/autoload.php @@ -1,29 +1,33 @@ error("PHP Error: [$errno] $errstr in $errfile on line $errline"); - return false; + + return true; }); set_exception_handler(function ($exception) use ($log) { $log->exception($exception); + + if (!headers_sent()) { + http_response_code(500); + } + + // Never leak exception details to the client + echo 'Internal Server Error'; }); +ini_set('display_errors', '0'); ini_set('log_errors', '1'); ini_set('error_log', DIR_LOG . 'php_errors.log'); @@ -34,9 +46,12 @@ $registry->set('pagination', new Pagination()); $registry->set('google_auth', new google_auth($registry)); $registry->set('meta', new Meta()); +$registry->set('cache', new Cache()); $registry->set('view', new View($registry)); +require_once DIR_CORE . 'router.php'; + $route = new Router($registry); $route->start(); diff --git a/core/classes/view.php b/core/classes/view.php index aee6401..1bcc29c 100755 --- a/core/classes/view.php +++ b/core/classes/view.php @@ -3,7 +3,7 @@ class View { protected $registry; protected $twig; - + public function __construct($registry) { $this->registry = $registry; @@ -12,8 +12,8 @@ public function __construct($registry) { $this->twig = new \Twig\Environment($loader, [ 'cache' => DIR_CACHE . 'twig', 'auto_reload' => true, - 'debug' => true, - 'autoescape' => false + 'debug' => false, + 'autoescape' => 'html' ]); } @@ -25,7 +25,17 @@ public function template($template, $data = []) { return $template->render($data); } + /** + * Wrap a trusted HTML string so autoescaping does not escape it, + * e.g. $this->data['header'] = $this->view->raw($html); + */ + public function raw($html) { + return new \Twig\Markup((string)$html, 'UTF-8'); + } + protected function filter(&$data) { - $data['csrf'] = ''; + $token = htmlspecialchars((string)$this->registry->get('session')->get('token'), ENT_QUOTES, 'UTF-8'); + + $data['csrf'] = $this->raw(''); } -} \ No newline at end of file +} diff --git a/core/library/cache.php b/core/library/cache.php new file mode 100644 index 0000000..93e6655 --- /dev/null +++ b/core/library/cache.php @@ -0,0 +1,63 @@ +path($key); + + if (!is_file($file)) { + return null; + } + + $data = unserialize(file_get_contents($file)); + + if (!is_array($data) || !array_key_exists('value', $data)) { + return null; + } + + if ($data['expires'] && $data['expires'] < time()) { + unlink($file); + + return null; + } + + return $data['value']; + } + + public function set($key, $value, $ttl = 0) { + file_put_contents($this->path($key), serialize(array( + 'expires' => $ttl ? time() + (int)$ttl : 0, + 'value' => $value + )), LOCK_EX); + } + + public function delete($key) { + $file = $this->path($key); + + if (is_file($file)) { + unlink($file); + } + } + + public function deleteAll($prefix = '') { + $files = glob(DIR_CACHE . $this->sanitize($prefix) . '*.cache'); + + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + + public function clear() { + $this->deleteAll(); + } + + protected function path($key) { + return DIR_CACHE . $this->sanitize($key) . '.cache'; + } + + protected function sanitize($key) { + // Keys become file names: strip anything that could escape DIR_CACHE + return ltrim(preg_replace('/[^a-zA-Z0-9._-]/', '_', (string)$key), '.'); + } +} diff --git a/core/library/db.php b/core/library/db.php index 658bacb..a186958 100755 --- a/core/library/db.php +++ b/core/library/db.php @@ -7,7 +7,7 @@ class Db { public function __construct($registry) { $this->registry = $registry; - + require_once DIR_CONFIG . 'database.php'; $this->prefix = DB_PREFIX ? DB_PREFIX : $this->env->get('DB_PREFIX'); @@ -17,16 +17,53 @@ public function __construct($registry) { $DB_PASS = DB_PASS ? DB_PASS : $this->env->get('DB_PASS'); $DB_NAME = DB_NAME ? DB_NAME : $this->env->get('DB_NAME'); $DB_PORT = DB_PORT ? DB_PORT : $this->env->get('DB_PORT'); - - $this->adaptor = new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT); + + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + try { + $this->adaptor = new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, (int)$DB_PORT); + } catch (mysqli_sql_exception $e) { + throw new RuntimeException('Could not connect to the database: ' . $e->getMessage(), 0, $e); + } + + // Without an explicit connection charset real_escape_string() can be + // bypassed with certain multi-byte encodings + $this->adaptor->set_charset('utf8mb4'); } public function __get($key) { return $this->registry->get($key); } - + public function query($sql, $rows = false) { $result = $this->adaptor->query($sql); + + return $this->fetch($result, $rows); + } + + /** + * Prepared statement query. Placeholders: ? + * Example: $db->execute("SELECT * FROM users WHERE email = ?", [$email], true) + */ + public function execute($sql, array $params = array(), $rows = false) { + $statement = $this->adaptor->prepare($sql); + + if ($params) { + $statement->bind_param(str_repeat('s', count($params)), ...$params); + } + + $statement->execute(); + + $result = $statement->get_result(); + + $output = $this->fetch($result === false ? true : $result, $rows); + + $statement->close(); + + return $output; + } + + private function fetch($result, $rows) { if ($result instanceof mysqli_result) { if ($rows) { $output = $result->num_rows ? $result->fetch_all(MYSQLI_ASSOC) : array(); @@ -36,6 +73,7 @@ public function query($sql, $rows = false) { } else { $output = false; } + return $output; } @@ -44,16 +82,16 @@ public function escape($value) { } public function last() { - $last = $this->adaptor->insert_id; - return $last; + return $this->adaptor->insert_id; } public function count() { - $count = $this->adaptor->affected_rows; - return $count; + return $this->adaptor->affected_rows; } public function __destruct() { - $this->adaptor->close(); + if ($this->adaptor instanceof mysqli) { + $this->adaptor->close(); + } } } diff --git a/core/library/env.php b/core/library/env.php index d231dab..9256f83 100755 --- a/core/library/env.php +++ b/core/library/env.php @@ -5,10 +5,10 @@ class Env { public function __construct() { $this->dotenv = Dotenv\Dotenv::createImmutable(DIR_ROOT); - $this->dotenv->load(); + $this->dotenv->safeLoad(); } - public function get($key) { - return !empty($_ENV[$key]) ? $_ENV[$key] : null; + public function get($key, $default = null) { + return isset($_ENV[$key]) && $_ENV[$key] !== '' ? $_ENV[$key] : $default; } -} \ No newline at end of file +} diff --git a/core/library/google_auth.php b/core/library/google_auth.php index fdd69ed..07a100b 100755 --- a/core/library/google_auth.php +++ b/core/library/google_auth.php @@ -18,6 +18,10 @@ public function __get($key) { } public function init() { + if ($this->client) { + return; + } + $this->client = new GoogleClient(); $this->client->setClientId($this->env->get('GOOGLE_AUTH_CLIENT_ID')); $this->client->setClientSecret($this->env->get('GOOGLE_AUTH_CLIENT_SECRET')); @@ -27,20 +31,29 @@ public function init() { } public function getAuthUrl() { + $this->init(); + return $this->client->createAuthUrl(); } public function authenticate($code) { + $this->init(); + $this->client->authenticate($code); - $_SESSION['access_token'] = $this->client->getAccessToken(); + $this->session->set('access_token', $this->client->getAccessToken()); + + // A fresh session id after login prevents session fixation + $this->session->regenerate(); } public function getUserInfo() { - if (!isset($_SESSION['access_token'])) { + if (!$this->session->has('access_token')) { throw new Exception('User not authenticated'); } - $this->client->setAccessToken($_SESSION['access_token']); + $this->init(); + + $this->client->setAccessToken($this->session->get('access_token')); $oauth2 = new \Google_Service_Oauth2($this->client); return $oauth2->userinfo->get(); } diff --git a/core/library/language.php b/core/library/language.php index 2beeab5..31528d5 100755 --- a/core/library/language.php +++ b/core/library/language.php @@ -7,7 +7,8 @@ class Language { public function __construct($lang) { $this->lang = $lang; - $this->load(DEFAULT_LANGUAGE); + // The base language file is named after the language code, e.g. en/en.php + $this->load($this->lang); } public function get($key) { @@ -15,15 +16,20 @@ public function get($key) { } public function load($file) { - $file = DIR_LANGUAGE . $this->lang . '/' . $file . '.php'; + if (!preg_match('/^[a-zA-Z0-9_\/-]+$/', (string)$file) || strpos($file, '..') !== false) { + throw new InvalidArgumentException('Error: Invalid language route ' . $file . '!'); + } + + $path = DIR_LANGUAGE . $this->lang . '/' . $file . '.php'; - if (file_exists($file)) { - $data = include $file; + if (is_file($path)) { + $data = include $path; - $this->data = array_merge($this->data, $data); + if (is_array($data)) { + $this->data = array_merge($this->data, $data); + } } else { - trigger_error('Error: Could not load language ' . $file . '!'); - exit(); + trigger_error('Error: Could not load language ' . $path . '!', E_USER_WARNING); } return $this; @@ -32,4 +38,4 @@ public function load($file) { public function all() { return $this->data; } -} \ No newline at end of file +} diff --git a/core/library/load.php b/core/library/load.php index 20a27c5..729e4de 100755 --- a/core/library/load.php +++ b/core/library/load.php @@ -2,7 +2,7 @@ class Load { protected $registry; - + public function __construct($registry) { $this->registry = $registry; } @@ -16,9 +16,11 @@ public function __set($name, $value) { } public function controller($route, $data = array()) { + $route = $this->validateRoute($route); + $output = ''; - $file = DIR_CONTROLLER . str_replace(array('../', '..\\', '..'), '', $route) . '.php'; + $file = DIR_CONTROLLER . $route . '.php'; $class = 'Controller' . preg_replace('/[^a-zA-Z0-9]/', '', $route); if (is_file($file)) { @@ -31,22 +33,33 @@ public function controller($route, $data = array()) { } } - return $output; + // Sub-controller output is trusted HTML: keep it unescaped + // when passed into an autoescaped Twig template + return new \Twig\Markup((string)$output, 'UTF-8'); } public function model($route) { - $file = DIR_MODEL . str_replace(array('../', '..\\', '..'), '', $route) . '.php'; + $route = $this->validateRoute($route); + + $file = DIR_MODEL . $route . '.php'; $class = 'Model' . preg_replace('/[^a-zA-Z0-9]/', '', $route); if (is_file($file)) { include_once($file); - $model = new $class($this->registry); + return new $class($this->registry); + } + + throw new RuntimeException('Error: Could not load model ' . $route . '!'); + } + + protected function validateRoute($route) { + $route = (string)$route; - return $model; - } else { - trigger_error('Error: Could not load model ' . $route . '!'); - exit(); + if (!preg_match('/^[a-zA-Z0-9_\/-]+$/', $route) || strpos($route, '..') !== false) { + throw new InvalidArgumentException('Error: Invalid route ' . $route . '!'); } + + return $route; } -} \ No newline at end of file +} diff --git a/core/library/log.php b/core/library/log.php index 46631f9..c2f35d1 100755 --- a/core/library/log.php +++ b/core/library/log.php @@ -7,14 +7,13 @@ class Log { private $logger; public function __construct($channelName = 'phpapp', $logFile = 'app.log') { + if (!is_dir(DIR_LOG)) { + mkdir(DIR_LOG, 0755, true); + } + $this->logger = new Logger($channelName); $this->logger->pushHandler(new StreamHandler(DIR_LOG . $logFile, Logger::DEBUG)); - - $logDir = DIR_LOG; - if (!is_dir($logDir)) { - mkdir($logDir, 0777, true); - } } public function info($message, array $context = []) { @@ -36,4 +35,4 @@ public function log($level, $message, array $context = []) { public function exception(\Throwable $exception) { $this->logger->error($exception->getMessage(), ['exception' => $exception]); } -} \ No newline at end of file +} diff --git a/core/library/request.php b/core/library/request.php index 5ac7e31..e3dfa4a 100755 --- a/core/library/request.php +++ b/core/library/request.php @@ -8,42 +8,29 @@ class Request { public $cookie; public function __construct() { - $this->setGet(); - $this->setPost(); - $this->setFiles(); - $this->setServer(); - $this->setCookie(); - } - - private function setGet() { - $this->get = $this->sanitize($_GET); - } - - private function setPost() { - $this->post = $this->sanitize($_POST); - } - - private function setFiles() { + // Input is kept raw; escaping happens at the output layer + // (Twig autoescape for HTML, Db::escape/execute for SQL) + $this->get = $this->clean($_GET); + $this->post = $this->clean($_POST); $this->files = $_FILES; + $this->server = $this->clean($_SERVER); + $this->cookie = $this->clean($_COOKIE); } - private function setServer() { - $this->server = $this->sanitize($_SERVER); - } - - private function setCookie() { - $this->cookie = $this->sanitize($_COOKIE); - } - - private function sanitize($data) { + private function clean($data) { if (is_array($data)) { foreach ($data as $key => $value) { - $data[$key] = $this->sanitize($value); + $data[$key] = $this->clean($value); } } else { - $data = htmlspecialchars($data, ENT_QUOTES, 'UTF-8'); + // Strip null bytes and invalid UTF-8 sequences + $data = str_replace("\0", '', (string)$data); + + if (!mb_check_encoding($data, 'UTF-8')) { + $data = mb_convert_encoding($data, 'UTF-8', 'UTF-8'); + } } - + return $data; } -} \ No newline at end of file +} diff --git a/core/library/response.php b/core/library/response.php index 8cab28f..6ce10a1 100755 --- a/core/library/response.php +++ b/core/library/response.php @@ -92,17 +92,17 @@ public function html($html) { header($header); } - echo html_entity_decode($html, ENT_QUOTES, 'UTF-8'); + echo $html; } public function json($data, $pretty = false) { - $this->addHeader('Content-Type: application/json'); + $this->addHeader('Content-Type: application/json; charset=utf-8'); foreach ($this->headers as $header) { header($header); } - echo json_encode($data, $pretty ? JSON_PRETTY_PRINT : 0); + echo json_encode($data, JSON_UNESCAPED_UNICODE | ($pretty ? JSON_PRETTY_PRINT : 0)); } public function redirect($url, $status = 302) { diff --git a/core/library/session.php b/core/library/session.php index 3553090..e52f8f2 100755 --- a/core/library/session.php +++ b/core/library/session.php @@ -13,8 +13,8 @@ public function __construct($registry) { } public function start() { - if (isset($this->request->cookie[SESSION_NAME]) && $this->request->cookie[SESSION_NAME] && $this->request->cookie[SESSION_NAME] != session_id() && file_exists(DIR_SESSION . '/sess_' . $this->request->cookie[SESSION_NAME])) { - session_id($this->request->cookie[SESSION_NAME]); + if (session_status() === PHP_SESSION_ACTIVE) { + return; } ini_set('session.use_only_cookies', 1); @@ -23,34 +23,44 @@ public function start() { ini_set('session.save_path', DIR_SESSION); if (!is_dir(DIR_SESSION)) { - mkdir(DIR_SESSION, 0777); + mkdir(DIR_SESSION, 0700, true); } session_name(SESSION_NAME); - session_set_cookie_params(0, '/'); - session_start([ 'cookie_lifetime' => 86400, + 'cookie_path' => '/', 'cookie_httponly' => true, + 'cookie_secure' => defined('SSL') && SSL, 'cookie_samesite' => 'Strict', 'use_strict_mode' => true, 'sid_length' => 64, 'sid_bits_per_character' => 6 ]); - setcookie(SESSION_NAME, session_id(), 0, '/'); - if (!$this->has('token')) { $this->refreshToken(); } } + public function regenerate() { + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + } + public function destroy() { if (session_id()) { session_destroy(); - - setcookie(SESSION_NAME, '', time() - 42000, '/'); + + setcookie(SESSION_NAME, '', [ + 'expires' => time() - 42000, + 'path' => '/', + 'httponly' => true, + 'secure' => defined('SSL') && SSL, + 'samesite' => 'Strict' + ]); } } @@ -77,11 +87,11 @@ public function remove($key) { } public function refreshToken() { - $this->set('token', md5(uniqid(mt_rand(), true))); + $this->set('token', bin2hex(random_bytes(32))); } public function validateToken($token) { - return $this->has('token') && $this->get('token') && $this->get('token') == $token; + return is_string($token) && $this->has('token') && hash_equals((string)$this->get('token'), $token); } public function __destruct() { diff --git a/core/library/staticfile.php b/core/library/staticfile.php index 0c66758..cbe4b2b 100755 --- a/core/library/staticfile.php +++ b/core/library/staticfile.php @@ -3,50 +3,74 @@ class StaticFile { protected $static_dir = DIR_STATIC; protected $static_www_dir = DIR_WWW . 'static/'; - + public function __construct() { - if (!file_exists($this->static_dir)) { + if (!is_dir($this->static_dir)) { mkdir($this->static_dir, 0755, true); } - if (!file_exists($this->static_dir . 'css')) { + if (!is_dir($this->static_dir . 'css')) { mkdir($this->static_dir . 'css', 0755, true); } } public function getFile($file) { - $file = $this->static_dir . $file; - - if (file_exists($file)) { + $file = $this->static_dir . $this->sanitize($file); + + if (is_file($file)) { return file_get_contents($file); } - + return false; } public function getUri($file) { + $file = $this->sanitize($file); + + if (!is_file($this->static_dir . $file)) { + return URL_STATIC . $file; + } + $this->saveFile($file); $last_modified = filemtime($this->static_dir . $file); - + return URL_STATIC . $file . '?last_modified=' . $last_modified; } + /** + * URI for a Vite-built asset in www/assets/ with a cache-busting + * version parameter, e.g. getAssetUri('css/app.css') + */ + public function getAssetUri($file) { + $file = $this->sanitize($file); + + $path = DIR_WWW . 'assets/' . $file; + + $uri = '/assets/' . $file; + + if (is_file($path)) { + $uri .= '?v=' . filemtime($path); + } + + return $uri; + } + + protected function sanitize($file) { + return str_replace(array('..', "\0"), '', (string)$file); + } + private function saveFile($file) { - if (file_exists($this->static_dir . $file) && !file_exists($this->static_www_dir . $file)) { + if (!is_file($this->static_www_dir . $file)) { $dir = dirname($this->static_www_dir . $file); - if (!file_exists($dir)) { + + if (!is_dir($dir)) { mkdir($dir, 0755, true); } copy($this->static_dir . $file, $this->static_www_dir . $file); - } elseif (file_exists($this->static_dir . $file) && file_exists($this->static_www_dir . $file)) { - $last_modified = filemtime($this->static_dir . $file); - $last_modified_www = filemtime($this->static_www_dir . $file); - - if ($last_modified > $last_modified_www) { - copy($this->static_dir . $file, $this->static_www_dir . $file); - } + } elseif (filemtime($this->static_dir . $file) > filemtime($this->static_www_dir . $file)) { + copy($this->static_dir . $file, $this->static_www_dir . $file); } } -} \ No newline at end of file +} diff --git a/core/registry.php b/core/registry.php index b40285c..314b74b 100755 --- a/core/registry.php +++ b/core/registry.php @@ -1,14 +1,14 @@ data[$key] = $value; } public function get($key) { - return $this->data[$key]; + return isset($this->data[$key]) ? $this->data[$key] : null; } public function has($key) { @@ -18,4 +18,4 @@ public function has($key) { public function remove($key) { unset($this->data[$key]); } -} \ No newline at end of file +} diff --git a/core/router.php b/core/router.php index 8eacc65..1679882 100755 --- a/core/router.php +++ b/core/router.php @@ -2,7 +2,6 @@ class Router { private $registry; - private $path; private $args = array(); public $file; @@ -19,16 +18,11 @@ public function start() { } private function getController() { - // Get the request URI - $request = html_entity_decode($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8'); - - // Remove query string from the request URI - if (strpos($request, '?') !== false) { - $request = substr($request, 0, strpos($request, '?')); - } + // Get the request path (without query string) and decode it + $request = rawurldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/'); // Split the URI into parts - $route_parts = array_values(array_filter(explode('/', $request))); + $route_parts = array_values(array_filter(explode('/', $request), 'strlen')); // Set default controller and action $this->file = 'index'; @@ -40,6 +34,15 @@ private function getController() { return; } + // Reject route parts with characters outside the allowed set + // (protects against path traversal like /../config/web) + foreach ($route_parts as $part) { + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $part)) { + $this->setNotFound(implode('/', $route_parts), $this->action, 'Invalid route'); + return; + } + } + // Parse the route parts $current_path = ''; $found_controller = false; @@ -49,7 +52,7 @@ private function getController() { $current_path = trim($current_path . '/' . $part, '/'); $controller_file = DIR_CONTROLLER . $current_path . '.php'; - if (file_exists($controller_file)) { + if (is_file($controller_file)) { $this->file = $current_path; $this->controller = 'Controller' . implode('', array_map('ucfirst', explode('/', str_replace('_', '', $current_path)))); @@ -66,58 +69,56 @@ private function getController() { // If no valid controller was found, redirect to error/not_found if (!$found_controller) { - $this->args['controller'] = $current_path; - $this->args['action'] = $this->action; - $this->args['message'] = 'Controller not found'; - $this->file = 'error/not_found'; - $this->controller = 'ControllerErrorNotFound'; - $this->action = 'index'; + $this->setNotFound($current_path, $this->action, 'Controller not found'); } } + private function setNotFound($controller, $action, $message) { + $this->args['controller'] = $controller; + $this->args['action'] = $action; + $this->args['message'] = $message; + $this->file = 'error/not_found'; + $this->controller = 'ControllerErrorNotFound'; + $this->action = 'index'; + } + private function executeController() { - $controller_file = DIR_CONTROLLER . $this->file . '.php'; - - // Check if the controller file exists - if (!file_exists($controller_file)) { - $this->args['controller'] = $this->file; - $this->args['action'] = $this->action; - $this->args['message'] = 'Controller file not found'; - $this->file = 'error/not_found'; - $this->controller = 'ControllerErrorNotFound'; - } else { - // Include the controller file - require_once $controller_file; - - // Check if the controller class exists - if (!class_exists($this->controller)) { - require_once DIR_CONTROLLER . 'error/not_found.php'; - - $this->args['controller'] = $this->file; - $this->args['action'] = $this->action; - $this->args['message'] = 'Controller class not found'; - $this->file = 'error/not_found'; - $this->controller = 'ControllerErrorNotFound'; - } + // Fall back to the error controller if the file or class is missing + if (!is_file(DIR_CONTROLLER . $this->file . '.php')) { + $this->setNotFound($this->file, $this->action, 'Controller file not found'); } - // Create the controller instance - $controller = new $this->controller($this->registry, $this->args); + require_once DIR_CONTROLLER . $this->file . '.php'; - // Check if the action method exists - if (!method_exists($controller, $this->action)) { - $this->args['controller'] = $this->file; - $this->args['action'] = $this->action; - $this->args['message'] = 'Action method not found'; - $this->file = 'error/not_found'; - require_once DIR_CONTROLLER . 'error/not_found.php'; - $this->controller = 'ControllerErrorNotFound'; - $controller = new $this->controller($this->registry, $this->args); - $this->action = 'index'; + if (!class_exists($this->controller)) { + $this->setNotFound($this->file, $this->action, 'Controller class not found'); + require_once DIR_CONTROLLER . $this->file . '.php'; } - // Call the action method + // Magic and internal methods (__construct, __get, ...) are not routable + if (!$this->isRoutable($this->controller, $this->action)) { + $this->setNotFound($this->file, $this->action, 'Action method not found'); + require_once DIR_CONTROLLER . $this->file . '.php'; + } + + // Create the controller instance and call the action method + $controller = new $this->controller($this->registry, $this->args); + $action = $this->action; $controller->$action(); } + + private function isRoutable($class, $action) { + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $action)) { + return false; + } + + if (!method_exists($class, $action)) { + return false; + } + + $method = new ReflectionMethod($class, $action); + + return $method->isPublic() && !$method->isStatic() && !$method->isConstructor() && !$method->isDestructor(); + } } diff --git a/migrate.php b/migrate.php index 29adbe9..54b156b 100755 --- a/migrate.php +++ b/migrate.php @@ -1,6 +1,10 @@ query("SELECT migration FROM migrations", true), + $db->query("SELECT migration FROM migrations", true) ?: array(), 'migration' ); - $files = glob(__DIR__ . '/migrations/*.php'); - foreach ($files as $file) { + foreach (migrationFiles() as $file) { $migrationName = basename($file, '.php'); if (!in_array($migrationName, $appliedMigrations)) { - require_once $file; - $migration = new Migration(); + $migration = loadMigration($file); $migration->up($db); - $db->query("INSERT INTO migrations (migration, applied_at) VALUES ('$migrationName', NOW())"); + $db->query("INSERT INTO migrations (migration, applied_at) VALUES ('" . $db->escape($migrationName) . "', NOW())"); echo "Applied: $migrationName\n"; } } } +function migrationFiles() { + return array_filter(glob(__DIR__ . '/migrations/*.php'), function ($file) { + return basename($file) !== 'template.php'; + }); +} + +function loadMigration($file) { + $migration = require $file; + + // New style: the file returns an anonymous class instance. + // Old style: the file defines a Migration class. + if (is_object($migration)) { + return $migration; + } + + if (class_exists('Migration')) { + $class = 'Migration'; + + return new $class(); + } + + throw new RuntimeException("Migration file $file must return an object or define a Migration class."); +} + function rollback($db) { echo "Rolling back last migration...\n"; $lastMigration = $db->query("SELECT migration FROM migrations ORDER BY id DESC LIMIT 1", true)[0]['migration'] ?? null; if ($lastMigration) { - require_once __DIR__ . "/migrations/$lastMigration.php"; - $migration = new Migration(); + $lastMigration = basename($lastMigration); + + $migration = loadMigration(__DIR__ . "/migrations/$lastMigration.php"); $migration->down($db); - $db->query("DELETE FROM migrations WHERE migration = '$lastMigration'"); + $db->query("DELETE FROM migrations WHERE migration = '" . $db->escape($lastMigration) . "'"); echo "Rolled back: $lastMigration\n"; } else { echo "No migrations to roll back.\n"; diff --git a/migrations/template.php b/migrations/template.php index 14974ed..f34ce53 100755 --- a/migrations/template.php +++ b/migrations/template.php @@ -1,6 +1,10 @@ =6.0.0" @@ -468,12 +492,14 @@ "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -487,6 +513,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -500,6 +527,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -513,6 +541,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -526,6 +555,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -539,6 +569,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -552,6 +583,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -565,6 +597,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -578,6 +611,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -591,6 +625,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -604,6 +639,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -617,6 +653,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -630,6 +667,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -643,6 +681,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -656,6 +695,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -669,6 +709,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -682,6 +723,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -695,6 +737,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -708,6 +751,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -721,6 +765,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -734,6 +779,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -747,6 +793,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -760,6 +807,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -770,6 +818,7 @@ "version": "4.1.15", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.15.tgz", "integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -785,6 +834,7 @@ "version": "4.1.15", "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.15.tgz", "integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -811,6 +861,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -827,6 +878,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -843,6 +895,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -859,6 +912,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -875,6 +929,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -891,6 +946,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -907,6 +963,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -923,6 +980,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -939,6 +997,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -963,6 +1022,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -984,6 +1044,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1000,6 +1061,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1013,6 +1075,7 @@ "version": "4.1.15", "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.1.15.tgz", "integrity": "sha512-B6s60MZRTUil+xKoZoGe6i0Iar5VuW+pmcGlda2FX+guDuQ1G1sjiIy1W0frneVpeL/ZjZ4KEgWZHNrIm++2qA==", + "dev": true, "license": "MIT", "dependencies": { "@tailwindcss/node": "4.1.15", @@ -1027,110 +1090,8 @@ "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "license": "MIT" }, "node_modules/daisyui": { "version": "5.3.7", @@ -1146,22 +1107,17 @@ "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", - "dev": true, - "license": "ISC" - }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -1175,6 +1131,7 @@ "version": "0.25.11", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.11.tgz", "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1212,20 +1169,11 @@ "@esbuild/win32-x64": "0.25.11" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1239,24 +1187,11 @@ } } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1271,12 +1206,14 @@ "version": "4.2.11", "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1286,6 +1223,7 @@ "version": "1.30.2", "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1318,6 +1256,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1338,6 +1277,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1358,6 +1298,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1378,6 +1319,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1398,6 +1340,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1418,6 +1361,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1438,6 +1382,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1458,6 +1403,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1478,6 +1424,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1498,6 +1445,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1518,6 +1466,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1535,6 +1484,7 @@ "version": "0.30.19", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.19.tgz", "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1544,6 +1494,7 @@ "version": "3.3.11", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -1558,33 +1509,18 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-releases": { - "version": "2.0.25", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.25.tgz", - "integrity": "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1597,6 +1533,7 @@ "version": "8.5.6", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1621,17 +1558,11 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/rollup": { "version": "4.52.5", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.52.5.tgz", "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -1673,6 +1604,7 @@ "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -1682,12 +1614,14 @@ "version": "4.1.15", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.15.tgz", "integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==", + "dev": true, "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1701,6 +1635,7 @@ "version": "0.2.15", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -1713,41 +1648,11 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/vite": { "version": "7.1.11", "resolved": "https://registry.npmmirror.com/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/package.json b/package.json index ed64941..c8eb2ce 100755 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "php-app", "version": "1.0.0", - "description": "**PHPapp** is a lightweight PHP framework designed for quick and efficient web application development. It follows the MVC (Model-View-Controller) architecture and provides core components for data management, routing, and user interaction.", - "main": "index.js", + "description": "Frontend build for PHPapp: Tailwind CSS 4 + daisyUI bundled with Vite into www/assets.", + "private": true, "scripts": { - "dev": "vite", + "dev": "vite build --watch", "build": "vite build" }, "repository": { @@ -13,19 +13,14 @@ }, "keywords": [], "author": "", - "license": "ISC", + "license": "MIT", "bugs": { "url": "https://github.com/and-ri/phpapp/issues" }, "homepage": "https://github.com/and-ri/phpapp#readme", "devDependencies": { - "autoprefixer": "^10.4.21", + "@tailwindcss/vite": "^4.1.15", "daisyui": "^5.3.7", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.15", "vite": "^7.1.11" - }, - "dependencies": { - "@tailwindcss/vite": "^4.1.15" } } diff --git a/vite.config.js b/vite.config.js index 6691425..10432b1 100755 --- a/vite.config.js +++ b/vite.config.js @@ -5,19 +5,23 @@ export default defineConfig({ plugins: [ tailwindcss(), ], - root: './', build: { outDir: './www/assets', emptyOutDir: true, + // keep CSS as a separate file (linked from header.twig) + cssCodeSplit: false, rollupOptions: { input: { app: './static/js/app.js', }, output: { + // iife: plain