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