Initial commit
This commit is contained in:
22
rss/php/autoload.php
Normal file
22
rss/php/autoload.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(function (string $class): void {
|
||||
|
||||
$root = rtrim($_SERVER['DOCUMENT_ROOT'], '/');
|
||||
|
||||
// PSR-4-ish:
|
||||
$prefix = 'Vor\\';
|
||||
$baseDir = $root . '/rss/php/classes/';
|
||||
|
||||
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
}, true, true);
|
||||
110
rss/php/classes/application/Frontend.php
Normal file
110
rss/php/classes/application/Frontend.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
namespace Vor\application;
|
||||
|
||||
use Vor\core\Sys;
|
||||
use Vor\core\Main;
|
||||
|
||||
class Frontend{
|
||||
|
||||
public static function render() {
|
||||
Sys::start();
|
||||
$pageName = $_GET['page'] ?? 'index';
|
||||
$jsonPath = BASE . "/rss/json/pages/$pageName.json";
|
||||
|
||||
if (!file_exists($jsonPath)) {
|
||||
http_response_code(404);
|
||||
$pageName = '404';
|
||||
$jsonPath = BASE . "/rss/json/pages/404.json";
|
||||
}
|
||||
|
||||
$conf = json_decode(file_get_contents($jsonPath), true);
|
||||
|
||||
if (!self::validate($conf)) {
|
||||
header('Location: ' . $conf['rules']['redirect_login']);
|
||||
exit;
|
||||
}
|
||||
|
||||
self::authRedirect($conf);
|
||||
|
||||
$viewData = [];
|
||||
if (isset($conf['init']) && is_array($conf['init'])) {
|
||||
foreach ($conf['init'] as $task) {
|
||||
$viewData[$task['return']] = self::execute($task);
|
||||
}
|
||||
}
|
||||
|
||||
$validatedScripts = [];
|
||||
|
||||
$pageScript = "/rss/js/pages/{$conf['layout']['body']}.js";
|
||||
if(file_exists(BASE . $pageScript)){
|
||||
$validatedScripts[] = ['src' => $pageScript, 'type' => 'module'];
|
||||
}
|
||||
|
||||
foreach(($conf['scripts'] ?? []) as $s){
|
||||
if($src = self::getScriptPath($s)){
|
||||
$validatedScripts[] = ['src' => $src, 'type' => $s['type'] == 'module' ? 'module' : 'text/javascript'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'header' => BASE . "/rss/php/views/headers/" . ($conf['layout']['header'] ?? 'default') . ".php",
|
||||
'view' => BASE . "/rss/php/views/pages/$pageName.php",
|
||||
'footer' => BASE . "/rss/php/views/footers/" . ($conf['layout']['footer'] ?? 'default') . ".php",
|
||||
'data' => self::clean($viewData),
|
||||
'scripts' => $validatedScripts,
|
||||
'conf' => $conf
|
||||
];
|
||||
}
|
||||
|
||||
private static function validate($c) {
|
||||
$restricted = $c['rules']['restricted'] ?? false;
|
||||
if (!$restricted) return true;
|
||||
|
||||
return $_SERVER['VOR_AUTH'];
|
||||
}
|
||||
|
||||
private static function authRedirect($c){
|
||||
$loginRestricted = $c['rules']['login_restricted'] ?? false;
|
||||
if($loginRestricted && isset($_SERVER['VOR_AUTH'])){
|
||||
header('location: ' . $c['rules']['login_redirect'] ?? '/');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
private static function execute($f) {
|
||||
$className = "\\Vor\\application\\" . $f['class'];
|
||||
|
||||
if (class_exists($className)) {
|
||||
$instance = new $className();
|
||||
$method = $f['function'];
|
||||
|
||||
if (method_exists($instance, $method)) {
|
||||
return $instance->$method();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function clean($data){
|
||||
if(is_array($data)){
|
||||
return array_map([self::class, 'clean'], $data);
|
||||
}
|
||||
return htmlspecialchars(trim((string)$data), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
public static function loadScripts($conf) {
|
||||
if (isset($conf['scripts']) && is_array($conf['scripts'])) {
|
||||
foreach ($conf['scripts'] as $script) {
|
||||
echo '<script src="/rss/js/' . $script . '.js"></script>' . PHP_EOL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function getScriptPath($script){
|
||||
$folder = ($script['type'] ?? '') === 'module' ? 'modules' : 'scripts';
|
||||
$name = $script['name'];
|
||||
$path = "/rss/js/$folder/$name";
|
||||
|
||||
return file_exists(BASE . $path) ? $path : null;
|
||||
}
|
||||
}
|
||||
63
rss/php/classes/application/user/Auth.php
Normal file
63
rss/php/classes/application/user/Auth.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
namespace Vor\application\user;
|
||||
|
||||
use Vor\core\Sys;
|
||||
use Vor\core\Main;
|
||||
use Exception;
|
||||
|
||||
class Auth{
|
||||
public $username;
|
||||
public $email;
|
||||
public $password;
|
||||
|
||||
public static function isAuth(){
|
||||
$authData = $_SERVER['VOR_AUTH'] ?? false;
|
||||
|
||||
if(!$authData){
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)Sys::session('uid') === (int)$authData['uid'];
|
||||
}
|
||||
|
||||
public function login(){
|
||||
if(isset($this->email) && isset($this->password)){
|
||||
$userData = Main::select('users', ['email', 'username', 'password', 'id'], ['email' => trim($this->email)]);
|
||||
if(!$userData){
|
||||
return false;
|
||||
}
|
||||
|
||||
if(password_verify($this->password, $userData['password'])){
|
||||
$sid = bin2hex(random_bytes(16));
|
||||
|
||||
$payload = [
|
||||
'uid' => (int)$userData['id'],
|
||||
'sid' => $sid,
|
||||
'exp' => time() + 86400
|
||||
];
|
||||
|
||||
if(Sys::cookieSet('v_auth', $payload)){
|
||||
Sys::validateSession($sid);
|
||||
|
||||
Sys::session('uid', (int)$userData['id']);
|
||||
Sys::session('logged_in_at', time());
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function logout(){
|
||||
Sys::cookieClear('v_auth');
|
||||
if(session_status() === PHP_SESSION_ACTIVE){
|
||||
session_unset();
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
?>
|
||||
212
rss/php/classes/core/Main.php
Normal file
212
rss/php/classes/core/Main.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace Vor\core;
|
||||
|
||||
use PDO;
|
||||
use Exception;
|
||||
use Vor\core\Sys;
|
||||
|
||||
class Main{
|
||||
/**
|
||||
* Static query functions
|
||||
*/
|
||||
|
||||
public static function insert($table, $data, $whitelist = []){
|
||||
if(!self::assertIdent($table)){
|
||||
throw new \InvalidArgumentException('Invalid table name');
|
||||
}
|
||||
|
||||
$db = Sys::getConnection();
|
||||
|
||||
if(!empty($whitelist)){
|
||||
$data = array_intersect_key($data, array_flip($whitelist));
|
||||
}
|
||||
|
||||
if(empty($data)){
|
||||
return false;
|
||||
}
|
||||
|
||||
$fields = array_keys($data);
|
||||
$cols = implode(', ', $fields);
|
||||
$placeholders = ':' . implode(', :' , $fields);
|
||||
|
||||
$query = "INSERT INTO $table ($cols) VALUES ($placeholders)";
|
||||
|
||||
try{
|
||||
$stmt = $db->prepare($query);
|
||||
foreach($data as $key => $val){
|
||||
$stmt->bindValue(':' . $key, $val);
|
||||
}
|
||||
|
||||
if($stmt->execute()){
|
||||
return $db->lastInsertId();
|
||||
}
|
||||
}catch(Exception $e) {
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function update($table, $data, $where, $whitelist = []){
|
||||
if(!self::assertIdent($table)){
|
||||
throw new \InvalidArgumentException('Invalid table name');
|
||||
}
|
||||
|
||||
$db = Sys::getConnection();
|
||||
if(!empty($whitelist)){
|
||||
$data = array_intersect_key($data, array_flip($whitelist));
|
||||
}
|
||||
|
||||
if(empty($data)){
|
||||
return false;
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$whereParts = [];
|
||||
|
||||
// Set
|
||||
foreach($data as $key => $val){
|
||||
$setParts[] = "$key = :$key";
|
||||
}
|
||||
$setSql = implode(', ', $setParts);
|
||||
|
||||
// Where
|
||||
foreach($where as $key => $val){
|
||||
$whereParts[] = "$key = :w_$key";
|
||||
}
|
||||
|
||||
$whereSql = implode(' AND ', $whereParts);
|
||||
$query = "UPDATE $table SET $setSql WHERE $whereSql";
|
||||
|
||||
try{
|
||||
$stmt = $db->prepare($query);
|
||||
foreach($data as $key => $val){
|
||||
$stmt->bindValue(':' . $key, $val);
|
||||
}
|
||||
|
||||
foreach($where as $key => $val){
|
||||
$stmt->bindValue(':w_' . $key, $val);
|
||||
}
|
||||
|
||||
return $stmt->execute();
|
||||
}catch(Exception $e){
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function select($table, $list = [], $where = [], $multiple = false, $assoc = true){
|
||||
if(!self::assertIdent($table)){
|
||||
throw new \InvalidArgumentException('Invalid table name');
|
||||
}
|
||||
|
||||
foreach($list as $col){
|
||||
if(!self::assertIdent($col)){
|
||||
throw new \InvalidArgumentException('Invalid where name');
|
||||
}
|
||||
}
|
||||
|
||||
foreach($where as $k => $_){
|
||||
if(!self::assertIdent($k)){
|
||||
throw new \InvalidArgumentException('Invalid where key');
|
||||
}
|
||||
}
|
||||
|
||||
$db = Sys::getConnection();
|
||||
|
||||
if(empty($list)){
|
||||
$query = "SELECT * FROM $table";
|
||||
}else{
|
||||
$querySelect = implode(', ', $list);
|
||||
$query = "SELECT $querySelect FROM $table";
|
||||
}
|
||||
|
||||
if(!empty($where)){
|
||||
$whereParts = [];
|
||||
foreach($where as $key => $val){
|
||||
$whereParts[] = "$key = :w_$key";
|
||||
}
|
||||
|
||||
$whereSql = implode(' AND ', $whereParts);
|
||||
$query .= " WHERE $whereSql";
|
||||
}
|
||||
|
||||
try{
|
||||
$stmt = $db->prepare($query);
|
||||
foreach($where as $key => $val){
|
||||
$stmt->bindValue(':w_' . $key, $val);
|
||||
}
|
||||
|
||||
if($stmt->execute()){
|
||||
$mode = $assoc ? PDO::FETCH_ASSOC : PDO::FETCH_COLUMN;
|
||||
return $multiple ? $stmt->fetchAll($mode) : $stmt->fetch($mode);
|
||||
}
|
||||
return false;
|
||||
}catch(Exception $e){
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function query($sql, $params = [], $multiple = true, $assoc = true){
|
||||
$db = Sys::getConnection();
|
||||
try{
|
||||
$stmt = $db->prepare($sql);
|
||||
if(!empty($params)){
|
||||
foreach($params as $key => $val){
|
||||
$stmt->bindValue(':' . $key, $val);
|
||||
}
|
||||
}
|
||||
if($stmt->execute()){
|
||||
$mode = $assoc ? PDO::FETCH_ASSOC : PDO::FETCH_COLUMN;
|
||||
return $multiple ? $stmt->fetchAll($mode) : $stmt->fetch($mode);
|
||||
}
|
||||
return false;
|
||||
}catch(Exception $e){
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function curl($url, $data = [], $headers = []){
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
if(!empty($data)){
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
|
||||
if(!empty($headers)){
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
}
|
||||
|
||||
try{
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
|
||||
if($error){
|
||||
error_log('Curl erro: ' . $error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $httpCode,
|
||||
'body' => json_decode($response, true) ?? $response
|
||||
];
|
||||
}catch(Exception $e){
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function assertIdent($s){
|
||||
return is_string($s) && preg_match('/^[a-zA-Z0-9_]+$/', $s);
|
||||
}
|
||||
}
|
||||
539
rss/php/classes/core/Sys.php
Normal file
539
rss/php/classes/core/Sys.php
Normal file
@@ -0,0 +1,539 @@
|
||||
<?php
|
||||
|
||||
namespace Vor\core;
|
||||
|
||||
use Exception;
|
||||
use PDO;
|
||||
use RangeException;
|
||||
|
||||
class Sys
|
||||
{
|
||||
// Public
|
||||
public $userConfigs;
|
||||
|
||||
// Protected
|
||||
protected static $conn = null;
|
||||
|
||||
// Methods
|
||||
|
||||
/**
|
||||
* System enviroment handling
|
||||
*/
|
||||
public static function start(){
|
||||
self::loadEnv();
|
||||
$auth = self::hydrate('v_auth');
|
||||
if($auth){
|
||||
$_SERVER['VOR_AUTH'] = $auth;
|
||||
}
|
||||
|
||||
return $auth;
|
||||
}
|
||||
|
||||
public static function getUserConfigs(){
|
||||
if (file_exists(BASE . '/rss/json/config/groups.json')) {
|
||||
return json_decode(file_get_contents(BASE . '/rss/json/config/groups.json'), true);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static function loadEnv(){
|
||||
$orgPath = $_SERVER['SERVER_ADDR'] == '127.0.0.1' ? '/' : '/../../';
|
||||
if(file_exists(BASE . $orgPath . '.env')) {
|
||||
$lines = file(BASE . $orgPath . '.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if (isset($lines) && !empty($lines)) {
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) {
|
||||
continue;
|
||||
}
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
$value = trim($value, '"\'');
|
||||
|
||||
putenv("$name=$value");
|
||||
}
|
||||
}
|
||||
}else{
|
||||
die('Env was not found at: ' . BASE . $orgPath . '.env');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sys database methods.
|
||||
* Don't edit this file. If you want to make system changes, extend from this class.
|
||||
*/
|
||||
public static function getConnection(){
|
||||
if (!self::$conn) {
|
||||
self::createConnection();
|
||||
}
|
||||
return self::$conn;
|
||||
}
|
||||
|
||||
private static function createConnection(){
|
||||
self::loadEnv();
|
||||
if (self::validateDBEnv()) {
|
||||
try {
|
||||
$dbHost = getenv('DB_HOST');
|
||||
$dbUser = getenv('DB_USER');
|
||||
$dbPass = getenv('DB_PASS');
|
||||
$dbName = getenv('DB_NAME');
|
||||
|
||||
self::$conn = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4;", $dbUser, $dbPass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::ATTR_PERSISTENT => true // Remember to validate max connections
|
||||
]);
|
||||
|
||||
self::validateMigration();
|
||||
} catch (Exception $e) {
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function validateDBEnv(){
|
||||
return (getenv('DB_HOST') && getenv('DB_USER') && getenv('DB_PASS') && getenv('DB_NAME'));
|
||||
}
|
||||
|
||||
private static function validateMigration(){
|
||||
// Validate engine information
|
||||
try{
|
||||
self::$conn->exec('CREATE TABLE IF NOT EXISTS engine_info (
|
||||
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||
meta_key VARCHAR(95),
|
||||
meta_value TEXT
|
||||
)');
|
||||
}catch(Exception $e){
|
||||
// Could not initiate first query
|
||||
error_log($e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate or set engine version
|
||||
try{
|
||||
$currentVersion = self::$conn->query('SELECT meta_value FROM engine_info WHERE meta_key = "vor_db_version"')->fetch(PDO::FETCH_COLUMN);
|
||||
if($currentVersion === false){
|
||||
self::$conn->exec('INSERT INTO engine_info (meta_key, meta_value) VALUES ("vor_db_version", "0")');
|
||||
$currentVersion = 0;
|
||||
}
|
||||
|
||||
// Validate latest version
|
||||
if(file_exists(BASE . '/rss/json/config/migrations.json')){
|
||||
$migrations = json_decode(file_get_contents(BASE . '/rss/json/config/migrations.json'), true);
|
||||
$latestVersion = array_key_last($migrations);
|
||||
if((int)$latestVersion > (int)$currentVersion){
|
||||
self::migrate();
|
||||
}
|
||||
}
|
||||
}catch(Exception $e){
|
||||
// First query ran fine but something with the updates messes up
|
||||
error_log($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static function migrate(){
|
||||
try{
|
||||
$currentVersion = (int) self::$conn->query('SELECT meta_value FROM engine_info WHERE meta_key = "vor_db_version"')->fetch(PDO::FETCH_COLUMN) ?? 0;
|
||||
}catch(Exception $e){
|
||||
// Could not get version
|
||||
error_log($e->getMessage());
|
||||
}
|
||||
|
||||
try{
|
||||
$migrations = json_decode(file_get_contents(BASE . '/rss/json/config/migrations.json'), true);
|
||||
foreach($migrations as $index => $migration){
|
||||
if((int)$index > (int)$currentVersion){
|
||||
foreach($migration as $query){
|
||||
self::$conn->exec($query);
|
||||
}
|
||||
$stmt = self::$conn->prepare("UPDATE engine_info SET meta_value = :index WHERE meta_key = 'vor_db_version'");
|
||||
$stmt->bindValue(':index', (int)$index);
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
||||
}catch(Exception $e){
|
||||
// Updates failed but DB is fine
|
||||
error_log($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System utilies
|
||||
*/
|
||||
|
||||
public static function post($key = null){
|
||||
return $key === null ? $_POST : (isset($_POST[$key]) ? $_POST[$key] : null);
|
||||
}
|
||||
|
||||
public static function get($key = null){
|
||||
return $key === null ? $_GET : (isset($_GET[$key]) ? $_GET[$key] : null);
|
||||
}
|
||||
|
||||
public static function go($path){
|
||||
header('Location: ' . $path);
|
||||
exit();
|
||||
}
|
||||
|
||||
public static function json($data, $code = 200){
|
||||
header('Content-Type: application/json');
|
||||
http_response_code($code);
|
||||
echo json_encode($data);
|
||||
exit();
|
||||
}
|
||||
|
||||
public static function collect($json = false){
|
||||
$input = file_get_contents('php://input');
|
||||
if($json){
|
||||
$input = json_decode($input, true);
|
||||
}
|
||||
return $input ?? [];
|
||||
}
|
||||
|
||||
private static function isHttps(){
|
||||
return
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
||||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') ||
|
||||
(isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443);
|
||||
}
|
||||
|
||||
public static function session($key, $value = null){
|
||||
if(session_status() === PHP_SESSION_NONE){
|
||||
self::validateSession();
|
||||
}
|
||||
|
||||
if($value === null){
|
||||
return $_SESSION[$key] ?? null;
|
||||
}
|
||||
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
|
||||
public static function hydrate($cookieName = 'v_auth'){
|
||||
$token = $_COOKIE[$cookieName] ?? false;
|
||||
if(!$token){
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode('.', $token, 2);
|
||||
if(count($parts) !== 2){
|
||||
return false;
|
||||
}
|
||||
|
||||
$payloadB64 = str_replace(['-', '_'], ['+', '/'], $parts[0]);
|
||||
$macB64 = str_replace(['-', '_'], ['+', '/'], $parts[1]);
|
||||
|
||||
$payload = base64_decode($payloadB64, true);
|
||||
$mac = base64_decode($macB64, true);
|
||||
|
||||
if(!$payload || !$mac){
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = getenv('TOKEN_SECRET') ?? false;
|
||||
if(!$key){
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedMac = hash_hmac('sha256', $payload, $key, true);
|
||||
if(!hash_equals($expectedMac, $mac)){
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($payload, true);
|
||||
if(!is_array($data)){
|
||||
return false;
|
||||
}
|
||||
|
||||
$uid = isset($data['uid']) ? (int)$data['uid'] : 0;
|
||||
$exp = isset($data['exp']) ? (int)$data['exp'] : 0;
|
||||
$sid = isset($data['sid']) ? $data['sid'] : false;
|
||||
|
||||
if($uid <= 0 || $exp <= 0 || !$sid){
|
||||
return false;
|
||||
}
|
||||
|
||||
if($exp < time()){
|
||||
return false;
|
||||
}
|
||||
|
||||
self::validateSession($sid);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function validateSession($sid = null){
|
||||
if(session_status() !== PHP_SESSION_NONE){
|
||||
return true;
|
||||
}
|
||||
|
||||
ini_set('session.use_cookies', 0);
|
||||
ini_set('session.use_only_cookies', 0);
|
||||
|
||||
if($sid){
|
||||
session_id($sid);
|
||||
}
|
||||
|
||||
return session_start();
|
||||
}
|
||||
|
||||
public static function cookieSet($name, $val, $opts = []){
|
||||
$isHttps = self::isHttps();
|
||||
|
||||
if(is_array($val)){
|
||||
$key = getenv('TOKEN_SECRET') ?? false;
|
||||
if(!$key){
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_encode($val, JSON_UNESCAPED_SLASHES);
|
||||
$mac = hash_hmac('sha256', $payload, $key, true);
|
||||
|
||||
$safePayload = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($payload));
|
||||
$safeMac = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($mac));
|
||||
|
||||
$val = $safePayload . '.' . $safeMac;
|
||||
}
|
||||
|
||||
$defaults = [
|
||||
'expires' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $isHttps,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
];
|
||||
|
||||
$opts = array_intersect_key((array)$opts, array_flip(['expires', 'path', 'domain', 'secure', 'httponly', 'samesite']));
|
||||
$final = array_merge($defaults, $opts);
|
||||
|
||||
if(strcasecmp($final['samesite'], 'None') === 0){
|
||||
$final['secure'] = true;
|
||||
}
|
||||
|
||||
$success = setcookie($name, (string)$val, $final);
|
||||
if($success){
|
||||
$_COOKIE[$name] = (string)$val;
|
||||
}
|
||||
|
||||
return $success;
|
||||
|
||||
}
|
||||
|
||||
public static function cookieClear($name = 0){
|
||||
$isHttps = self::isHttps();
|
||||
|
||||
$clear = function($cookie) use ($isHttps){
|
||||
setcookie($cookie, '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'secure' => $isHttps,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
|
||||
unset($_COOKIE[$cookie]);
|
||||
};
|
||||
|
||||
if(!$name){
|
||||
foreach($_COOKIE as $cookie => $v){
|
||||
$clear($cookie);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$clear($name);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function upload($preset, $file){
|
||||
Sys::loadEnv();
|
||||
|
||||
if(!isset($file['error']) || is_array($file['error'])){
|
||||
return ['ok' => false, 'error' => 'Invalid upload'];
|
||||
}
|
||||
|
||||
if($file['error'] !== UPLOAD_ERR_OK){
|
||||
return ['ok' => false, 'error' => 'Upload failed'];
|
||||
}
|
||||
|
||||
$dir = getenv("UPLOAD_{$preset}_DIR");
|
||||
$allowed = getenv("UPLOAD_{$preset}_ALLOWED");
|
||||
$maxMb = getenv("UPLOAD_{$preset}_MAX_MB");
|
||||
|
||||
if(!$dir || !$allowed){
|
||||
return ['ok' => false, 'error' => 'Upload preset missing'];
|
||||
}
|
||||
|
||||
$allowedExt = array_map('trim', explode(',', strtolower($allowed)));
|
||||
$maxBytes = (int)$maxMb * 1024 * 1024;
|
||||
|
||||
if($maxBytes > 0 && $file['size'] > $maxBytes){
|
||||
return ['ok' => false, 'error' => 'File too large'];
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if(!in_array($ext, $allowedExt)){
|
||||
return ['ok' => false, 'error' => 'Invalid file type'];
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
|
||||
if(!$mime){
|
||||
return ['ok' => false, 'error' => 'Invalid file'];
|
||||
}
|
||||
|
||||
$absDir = rtrim(BASE, '/') . '/' . ltrim($dir, '/');
|
||||
if(!is_dir($absDir)){
|
||||
mkdir($absDir, 0755, true);
|
||||
}
|
||||
|
||||
$name = bin2hex(random_bytes(32)) . '.' . $ext;
|
||||
$dest = rtrim($absDir, '/') . '/' . $name;
|
||||
|
||||
if(!move_uploaded_file($file['tmp_name'], $dest)){
|
||||
return ['ok' => false, 'error' => 'Move failed'];
|
||||
}
|
||||
|
||||
return[
|
||||
'ok' => true,
|
||||
'path' => $dir . '/' . $name,
|
||||
'filename' => $name,
|
||||
'ext' => $ext,
|
||||
'mime' => $mime,
|
||||
'size' => $file['size']
|
||||
];
|
||||
}
|
||||
|
||||
public static function serveFile($absPath, $opts = []){
|
||||
$defaults = [
|
||||
'filename' => null,
|
||||
'mime' => null,
|
||||
'inline' => true,
|
||||
'cacheSeconds' => 86400,
|
||||
'etag' => true,
|
||||
'allowRange' => true,
|
||||
'baseDirs' => [realpath(BASE)],
|
||||
];
|
||||
|
||||
$o = array_merge($defaults, (array)$opts);
|
||||
$real = realpath($absPath);
|
||||
if($real === false || !is_file($real) || !is_readable($real)){
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$allowed = false;
|
||||
foreach((array)$o['baseDirs'] as $base){
|
||||
$baseReal = realpath($base);
|
||||
if($baseReal && str_starts_with($real, rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)){
|
||||
$allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(!$allowed){
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$mime = $o['mime'];
|
||||
if(!$mime){
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($real) ?: 'application/octet-stream';
|
||||
}
|
||||
|
||||
$size = filesize($real);
|
||||
$mtime = filemtime($real);
|
||||
|
||||
if($o['etag']){
|
||||
$etag = '"' . sha1($real . '|' . $size . '|' . $mtime) . '"';
|
||||
header('ETag: ' . $etag);
|
||||
if(isset($_SERVER['HTTP_IF_NONE_MATCH']) &&trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag){
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if((int)$o['cacheSeconds'] > 0){
|
||||
header('Cache-Control: public, max-age=' . (int)$o['cacheSeconds']);
|
||||
}else{
|
||||
header('Cache-Control: no-store');
|
||||
}
|
||||
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Type: ' . $mime);
|
||||
|
||||
$downloadName = $o['filename'] ?: basename($real);
|
||||
$downloadName = str_replace(["\r","\n"], '', $downloadName);
|
||||
|
||||
$disp = $o['inline'] ? 'inline' : 'attachment';
|
||||
header('Content-Disposition: ' . $disp . '; filename="' . addslashes($downloadName) . '"');
|
||||
header('Accept-Ranges: bytes');
|
||||
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
$status = 200;
|
||||
|
||||
if($o['allowRange'] && isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)){
|
||||
$rangeStart = $m[1] !== '' ? (int)$m[1] : null;
|
||||
$rangeEnd = $m[2] !== '' ? (int)$m[2] : null;
|
||||
|
||||
if($rangeStart === null && $rangeEnd !== null){
|
||||
$start = max(0, $size - $rangeEnd);
|
||||
}else{
|
||||
$start = max(0, (int)$rangeStart);
|
||||
if($rangeEnd !== null){
|
||||
$end = min($end, (int)$rangeEnd);
|
||||
}
|
||||
}
|
||||
|
||||
if($start > $end || $start >= $size){
|
||||
header('Content-Range: bytes */' . $size);
|
||||
http_response_code(416);
|
||||
exit;
|
||||
}
|
||||
|
||||
$status = 206;
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||
}
|
||||
|
||||
$length = ($end - $start) + 1;
|
||||
header('Content-Length: ' . $length);
|
||||
http_response_code($status);
|
||||
|
||||
$fp = fopen($real, 'rb');
|
||||
if($fp === false){
|
||||
http_response_code(500);
|
||||
exit();
|
||||
}
|
||||
|
||||
if($start > 0){
|
||||
fseek($fp, $start);
|
||||
}
|
||||
|
||||
$chunk = 8192;
|
||||
while(!feof($fp) && $length > 0){
|
||||
$read = ($length > $chunk) ? $chunk : $length;
|
||||
$buf = fread($fp, $read);
|
||||
if($buf === false){
|
||||
break;
|
||||
}
|
||||
|
||||
echo $buf;
|
||||
$length -= strlen($buf);
|
||||
if(function_exists('fastcgi_finish_request') === false){
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
78
rss/php/classes/core/Validator.php
Normal file
78
rss/php/classes/core/Validator.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Vor\core;
|
||||
|
||||
class Validator{
|
||||
|
||||
public static function int($val) : bool{
|
||||
return filter_var($val, FILTER_VALIDATE_INT) !== false;
|
||||
}
|
||||
|
||||
public static function float($val) : bool{
|
||||
return filter_var($val, FILTER_VALIDATE_FLOAT) !== false;
|
||||
}
|
||||
|
||||
public static function string($val, int $minLen = 1): bool{
|
||||
if(!is_string($val)){
|
||||
return false;
|
||||
}
|
||||
|
||||
return mb_strlen(trim($val)) >= $minLen;
|
||||
}
|
||||
|
||||
public static function date($date, string $format = 'Y-m-d'): bool {
|
||||
try{
|
||||
$d = \DateTime::createFromFormat($format, (string)$date);
|
||||
return $d && $d->format($format) === (string)$date;
|
||||
}catch(\Throwable $e){
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function json($val): bool{
|
||||
if(!is_string($val) || $val === ''){
|
||||
return false;
|
||||
}
|
||||
|
||||
try{
|
||||
json_decode($val, true, 512, JSON_THROW_ON_ERROR);
|
||||
return true;
|
||||
}catch(\JsonException $e){
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function email($val): bool{
|
||||
$val = trim((string)$val);
|
||||
|
||||
if(preg_match("/[\r\n]/", $val)){
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) filter_var($val, FILTER_VALIDATE_EMAIL);
|
||||
}
|
||||
|
||||
public static function domain($val): bool{
|
||||
$val = trim((string)$val);
|
||||
|
||||
if(preg_match("/[\r\n]/", $val)){
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) filter_var($val, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
|
||||
}
|
||||
|
||||
public static function minLen($val, int $min): bool{
|
||||
return mb_strlen(trim((string)$val)) >= $min;
|
||||
}
|
||||
|
||||
public static function maxLen($val, int $max): bool{
|
||||
return mb_strlen(trim((string)$val)) <= $max;
|
||||
}
|
||||
|
||||
public static function regex($val, string $pattern): bool{
|
||||
return (bool) preg_match($pattern, (string)$val);
|
||||
}
|
||||
}
|
||||
56
rss/php/handler.php
Normal file
56
rss/php/handler.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
define('BASE', realpath(dirname(__FILE__) . '/../../'));
|
||||
require_once(BASE . '/rss/php/autoload.php');
|
||||
use Vor\core\Sys;
|
||||
Sys::start();
|
||||
|
||||
$classMap = [
|
||||
// Add public allowed classes
|
||||
'Frontend' => \Vor\application\Frontend::class,
|
||||
];
|
||||
|
||||
$methodMap = [
|
||||
// Add public allowed methods
|
||||
'Frontend' => ['clean'],
|
||||
];
|
||||
|
||||
$target = trim((string)($_POST['target'] ?? ''));
|
||||
$method = trim((string)($_POST['method'] ?? ''));
|
||||
|
||||
if($target === '' || $method === ''){
|
||||
Sys::json(['status' => 'fail', 'message' => 'Missing target'], 400);
|
||||
}
|
||||
|
||||
if(!isset($classMap[$target])){
|
||||
Sys::json(['status' => 'fail', 'message' => 'Invalid class'], 404);
|
||||
}
|
||||
|
||||
if(!in_array($method, $methodMap[$target] ?? [], true)){
|
||||
Sys::json(['status' => 'fail', 'message' => 'Invalid method'], 404);
|
||||
}
|
||||
|
||||
$obj = new $classMap[$target];
|
||||
|
||||
foreach($_POST as $k => $v){
|
||||
if($k === 'target' || $k === 'method'){
|
||||
continue;
|
||||
}
|
||||
|
||||
if(property_exists($obj, $k)){
|
||||
$obj->$k = $v;
|
||||
}
|
||||
}
|
||||
|
||||
foreach(($_FILES ?? []) as $k => $v){
|
||||
if(property_exists($obj, $k)){
|
||||
$obj->$k = $v;
|
||||
}
|
||||
}
|
||||
|
||||
try{
|
||||
$resp = $obj->$method();
|
||||
Sys::json(['status' => $resp ? 'success' : 'fail', 'message' => $resp], 200);
|
||||
}catch(\Throwable $e){
|
||||
error_log($e->getMessage());
|
||||
Sys::json(['status' => 'fail', 'message' => 'Server error'], 500);
|
||||
}
|
||||
148
rss/php/pagehandler.php
Normal file
148
rss/php/pagehandler.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/rss/php/autoload.php');
|
||||
use Vor\core\Sys;
|
||||
use Vor\application\Users;
|
||||
|
||||
// Set up class map
|
||||
$classList = array(
|
||||
'Sys' => Sys::class,
|
||||
'Users' => Users::class
|
||||
);
|
||||
|
||||
|
||||
$page = getPage();
|
||||
$res = array();
|
||||
$userConfigs = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/rss/json/config/groups.json'), true);
|
||||
|
||||
if(isset($page['init']) && !empty($page['init'])){
|
||||
foreach($page['init'] as $func){
|
||||
$res[$func['return']] = initCaller($func);
|
||||
}
|
||||
}
|
||||
|
||||
// Create init function based on json files
|
||||
if($page){
|
||||
if(!isset($_GET['page'])){
|
||||
$usr = new Users();
|
||||
$sys = new Sys();
|
||||
$myInfo = $usr->getMyself();
|
||||
if($usr->isAuth()){
|
||||
header('location: /'. $sys::getUserConfigs()[$myInfo['user_type']]['home'].'/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPage(){
|
||||
$sys = new Sys();
|
||||
$user = new Users();
|
||||
global $myInfo;
|
||||
if(isset($_GET['page']) && !empty($_GET['page'])){
|
||||
if($conf = pages($_GET['page'])){
|
||||
if(validatePage($conf)){
|
||||
return $conf;
|
||||
}else{
|
||||
$usr = new Users();
|
||||
if($usr->isAuth()){
|
||||
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/'.$sys::getUserConfigs()[$myInfo['user_type']]['home'].'.json')){
|
||||
header('location: /'.$sys::getUserConfigs()[$myInfo['user_type']]['home'].'/');
|
||||
}else{
|
||||
header('location: /');
|
||||
}
|
||||
}else{
|
||||
header('location: /');
|
||||
}
|
||||
}
|
||||
}else{
|
||||
header('location: /404/');
|
||||
}
|
||||
}else{
|
||||
return pages('index');
|
||||
}
|
||||
}
|
||||
|
||||
function pages($p){
|
||||
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/' . $p . '.json')){
|
||||
return(json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/' . $p . '.json'), true));
|
||||
}else{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validatePage($c){
|
||||
if($c['rules']['restricted'] === true){
|
||||
if($c['rules']['loginCookie'] == true){
|
||||
$usr = new Users();
|
||||
if($usr->isAuth()){
|
||||
$ui = $usr->getMyself();
|
||||
$allowed_user_types = $c['rules']['userTypes'];
|
||||
$allowed_user_groups = $c['rules']['userGroups'];
|
||||
|
||||
if(in_array($ui['user_type'], $allowed_user_groups) || in_array('*', $allowed_user_groups)){
|
||||
return true;
|
||||
}else{
|
||||
return false;
|
||||
}
|
||||
}else{
|
||||
return false;
|
||||
}
|
||||
}else{
|
||||
return true;
|
||||
}
|
||||
}else{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function loadModals(){
|
||||
global $page;
|
||||
global $res;
|
||||
if(isset($page['modals']) && !empty($page['modals'])){
|
||||
foreach($page['modals'] as $modal){
|
||||
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/php/models/components/modals/' . $modal . '.php')){
|
||||
include($_SERVER['DOCUMENT_ROOT'] . '/rss/php/models/components/modals/' . $modal . '.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCanonical(){
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$requestUri = $_SERVER['REQUEST_URI'];
|
||||
$canonicalUrl = $scheme . "://" . $host . $requestUri;
|
||||
return $canonicalUrl;
|
||||
}
|
||||
|
||||
function loadScripts(){
|
||||
if(isset($_GET['page']) && !empty($_GET['page'])){
|
||||
$page_name = $_GET['page'];
|
||||
}else{
|
||||
$page_name = 'index';
|
||||
}
|
||||
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/js/' . $page_name . '.js')){
|
||||
echo '<script src="/rss/js/'.$page_name.'.js"></script>';
|
||||
}
|
||||
}
|
||||
|
||||
function initCaller($func){
|
||||
global $classList;
|
||||
$class = $func['class'];
|
||||
$method = $func['function'];
|
||||
$cl = new $classList[$class];
|
||||
|
||||
// Set get parameters
|
||||
$run = true;
|
||||
if(isset($func['getValues']) && !empty($func['getValues'])){
|
||||
$run = false;
|
||||
foreach($func['getValues'] as $get){
|
||||
if(isset($_GET[$get]) && !empty($_GET[$get])){
|
||||
$run = true;
|
||||
$cl->$get = $_GET[$get];
|
||||
}
|
||||
}
|
||||
}
|
||||
if($run){
|
||||
$rs = $cl->$method();
|
||||
return $rs;
|
||||
}
|
||||
}
|
||||
6
rss/php/views/footers/default.php
Normal file
6
rss/php/views/footers/default.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<script src="/rss/js/main.js"></script>
|
||||
<?php foreach($render['scripts'] as $s):?>
|
||||
<script src="<?= $s['src'] ?>" <?= $s['type'] == 'module' ? 'type="module"' : '' ?>></script>
|
||||
<?php endforeach;?>
|
||||
</body>
|
||||
</html>
|
||||
11
rss/php/views/headers/default.php
Normal file
11
rss/php/views/headers/default.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VOR CRM — UI Kit Showcase (Full)</title>
|
||||
|
||||
<link rel="stylesheet" href="/rss/css/main.css" />
|
||||
<link rel="stylesheet" href="/rss/css/template.css" />
|
||||
</head>
|
||||
|
||||
592
rss/php/views/pages/index.php
Normal file
592
rss/php/views/pages/index.php
Normal file
@@ -0,0 +1,592 @@
|
||||
<?php include(BASE . '/rss/php/views/components/frame.php');?>
|
||||
<div class="p-2">
|
||||
<div class="page">
|
||||
|
||||
<section class="card hero-pro mb-4">
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<div class="kicker">
|
||||
<span class="pill">MDI 2026-ish</span>
|
||||
<span class="small text-muted">tokens · utilities · components</span>
|
||||
</div>
|
||||
|
||||
<h1 class="h3 mt-3 mb-0">Dashboard overview</h1>
|
||||
<p class="body text-muted mt-2 mb-0">
|
||||
This page stress-tests: grid, spacing, sizing, typography, tables, buttons, forms,
|
||||
badges, scrollbar, cards, accordion, switch, drawer, modal, and theme tokens.
|
||||
</p>
|
||||
|
||||
<div class="chip-row mt-3">
|
||||
<div class="chip is-active" data-chip>Last 7 days</div>
|
||||
<div class="chip" data-chip>Last 30 days</div>
|
||||
<div class="chip" data-chip>This year</div>
|
||||
<div class="chip" data-chip>Custom</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center gap-2 mt-3">
|
||||
<span class="badge badge-gray-light">EU region</span>
|
||||
<span class="badge badge-teal badge-solid">Online</span>
|
||||
<span class="badge badge-red-light text-red-900">Alerts 2</span>
|
||||
<span class="small text-muted">Updated Feb 16, 2026</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="small text-muted">Inline code token</div>
|
||||
<div class="body">
|
||||
Try: <code>btn-gray-light btn-sm</code>, <code>col-lg-4</code>, <code>data-toggle</code>, <code>data-theme-toggle</code>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-quick">
|
||||
<div class="small text-muted">Quick actions</div>
|
||||
<div class="d-flex flex-column gap-2 mt-2">
|
||||
<button class="btn-teal">Create invoice</button>
|
||||
<button class="btn-blue-light" data-toggle="#modalExample">Edit customer</button>
|
||||
<button class="btn-gray-light">Log activity</button>
|
||||
</div>
|
||||
|
||||
<hr class="hr">
|
||||
|
||||
<div class="small text-muted">System</div>
|
||||
<div class="d-flex align-center gap-2 mt-2">
|
||||
<label class="switch">
|
||||
<input type="checkbox" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="body m-0">Realtime sync</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="small text-muted">Open modal / drawer</div>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button class="btn-gray-light btn-sm" data-toggle="#modalExample">Modal</button>
|
||||
<button class="btn-gray-light btn-sm" data-toggle="#mobileDrawer">Drawer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-xs-12 col-lg-6">
|
||||
<div class="alert-teal">alert-teal — Success style container.</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-lg-6">
|
||||
<div class="alert-red">alert-red — Danger style container.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<div class="card hoverable mb-3 card-quick">
|
||||
<div class="stat-title">Total revenue</div>
|
||||
<div class="stat-value">€ 142,500</div>
|
||||
<div class="stat-sub"><span class="text-teal-600">+12%</span> vs last month</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<div class="card hoverable mb-3">
|
||||
<div class="stat-title">Active users</div>
|
||||
<div class="stat-value">1,204</div>
|
||||
<div class="stat-sub">
|
||||
<span class="badge badge-gray-light">Stable</span>
|
||||
<span class="small text-muted"> churn 1.1%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<div class="card hoverable mb-3 card-quick">
|
||||
<div class="stat-title">System status</div>
|
||||
<div class="d-flex align-center gap-2 mt-2">
|
||||
<span class="badge badge-teal badge-solid">Healthy</span>
|
||||
<span class="small text-muted">API 124ms · DB 8ms</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-2">Last checked: Feb 16, 2026</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-xs-12 col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<h4 class="h4 m-0">Forms</h4>
|
||||
<div class="small text-muted mt-1">input, select, input-group, focus ring</div>
|
||||
|
||||
<div class="form-group mt-3">
|
||||
<label class="label">Email</label>
|
||||
<input class="input" type="email" placeholder="name@domain.com" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">Status</label>
|
||||
<select class="select">
|
||||
<option>Active</option>
|
||||
<option>Pending</option>
|
||||
<option>Paused</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">Search (input-group)</label>
|
||||
<div class="input-group">
|
||||
<input class="input" type="text" placeholder="Search..." />
|
||||
<button class="btn-teal">Go</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn-teal">Primary</button>
|
||||
<button class="btn-gray-light">Secondary</button>
|
||||
<button class="btn-gray-light btn-sm">Small</button>
|
||||
<button class="btn-teal btn-lg">Large</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-lg-6">
|
||||
<div class="card mb-3 card-quick">
|
||||
<h4 class="h4 m-0">Typography & Utilities</h4>
|
||||
<div class="small text-muted mt-1">sizes, weights, alignment, spacing helpers</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="h1 m-0">h1</div>
|
||||
<div class="h2 m-0">h2</div>
|
||||
<div class="h3 m-0">h3</div>
|
||||
<div class="h4 m-0">h4</div>
|
||||
<div class="h5 m-0">h5</div>
|
||||
<div class="h6 m-0">h6</div>
|
||||
<div class="body mt-2">body text — <span class="text-muted">muted</span> — <span class="text-light">light</span></div>
|
||||
<div class="small text-muted mt-1">small text</div>
|
||||
</div>
|
||||
|
||||
<hr class="hr">
|
||||
|
||||
<div class="d-flex justify-between align-center">
|
||||
<div class="small text-muted">Badges</div>
|
||||
<div class="d-flex gap-2">
|
||||
<span class="badge badge-teal">badge-teal</span>
|
||||
<span class="badge badge-teal badge-solid">solid</span>
|
||||
<span class="badge badge-orange-light text-orange-900">warn</span>
|
||||
<span class="badge badge-red-light text-red-900">danger</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="small text-muted mb-2">Spacing / size quick grid</div>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="bg-surface border rounded p-2 w-25 text-center small">w-25</div>
|
||||
<div class="bg-surface border rounded p-2 w-33 text-center small">w-33</div>
|
||||
<div class="bg-surface border rounded p-2 w-50 text-center small">w-50</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<div class="bg-surface border rounded p-2 w-66 text-center small">w-66</div>
|
||||
<div class="bg-surface border rounded p-2 w-33 text-center small">w-33</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-xs-12 col-lg-8">
|
||||
<div class="card table-card mb-3">
|
||||
<div class="p-3 border-bottom d-flex justify-between align-center">
|
||||
<h5 class="h5 m-0">Recent transactions</h5>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<button class="btn-blue-light btn-sm" id="tableEditAllBtn">Edit mode</button>
|
||||
<button class="btn-gray-light btn-sm">Export</button>
|
||||
<button class="btn-gray-light btn-sm">View all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div id="transactionsTable"></div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border-top">
|
||||
<div class="small text-muted">Small variant</div>
|
||||
<div class="table-wrap mt-2">
|
||||
<table class="table table-sm striped hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Meta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Theme</td>
|
||||
<td>data-theme</td>
|
||||
<td><code>light / dark</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Drawer</td>
|
||||
<td>.drawer</td>
|
||||
<td><code>is-open</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Modal</td>
|
||||
<td>.modal-overlay</td>
|
||||
<td><code>is-active</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Table edit</td>
|
||||
<td>.table-edit-btn</td>
|
||||
<td><code>welcome.js</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-lg-4">
|
||||
<div class="card mb-3">
|
||||
<h5 class="h5 m-0">Pipeline</h5>
|
||||
<div class="small text-muted mt-1">progress bars + buttons</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-between">
|
||||
<span class="small text-muted">Qualified</span>
|
||||
<span class="small">18</span>
|
||||
</div>
|
||||
<div class="progress mt-1">
|
||||
<div class="progress-bar progress-72"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-between">
|
||||
<span class="small text-muted">Proposal</span>
|
||||
<span class="small">9</span>
|
||||
</div>
|
||||
<div class="progress mt-1">
|
||||
<div class="progress-bar progress-46 soft"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-between">
|
||||
<span class="small text-muted">Negotiation</span>
|
||||
<span class="small">4</span>
|
||||
</div>
|
||||
<div class="progress mt-1">
|
||||
<div class="progress-bar progress-25 warn"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center gap-2 mt-4">
|
||||
<button class="btn-teal btn-sm">Add deal</button>
|
||||
<button class="btn-gray-light btn-sm">View</button>
|
||||
<button class="btn-blue-light btn-sm" data-toggle="#modalExample">Edit</button>
|
||||
<button class="btn-gray-light btn-sm" data-toggle="#modalExample">Open modal</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<h5 class="h5 m-0">Borders / radius / elevation</h5>
|
||||
<div class="small text-muted mt-1">border helpers + rounded + elevation</div>
|
||||
|
||||
<div class="row mt-3 g-0">
|
||||
<div class="col-xs-6">
|
||||
<div class="bg-surface border rounded p-3 elevation-1">
|
||||
<div class="small text-muted">elevation-1</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<div class="bg-surface border rounded-lg p-3 elevation-4">
|
||||
<div class="small text-muted">elevation-4</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<div class="bg-surface border rounded-sm p-2 small">rounded-sm</div>
|
||||
<div class="bg-surface border rounded p-2 small">rounded</div>
|
||||
<div class="bg-surface border rounded-lg p-2 small">rounded-lg</div>
|
||||
<div class="bg-surface border rounded-pill p-2 small">pill</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-xs-12 col-lg-8">
|
||||
<div class="card">
|
||||
<h4 class="h4 m-0">Framework FAQ</h4>
|
||||
<div class="small text-muted mt-1">accordion + utilities + theme tokens</div>
|
||||
|
||||
<div class="accordion-item mt-3">
|
||||
<button class="accordion-header" data-accordion>
|
||||
<span>How does the grid work?</span>
|
||||
<span class="accordion-icon">▼</span>
|
||||
</button>
|
||||
<div class="accordion-body">
|
||||
Negative margins + column padding keeps gutters consistent and safe for nesting.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-header" data-accordion>
|
||||
<span>Does ESC close modals?</span>
|
||||
<span class="accordion-icon">▼</span>
|
||||
</button>
|
||||
<div class="accordion-body">
|
||||
Yes — with your VOR event delegation, ESC closes modal first, then drawer.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-header" data-accordion>
|
||||
<span>Theme tokens?</span>
|
||||
<span class="accordion-icon">▼</span>
|
||||
</button>
|
||||
<div class="accordion-body">
|
||||
Toggle <code>data-theme</code> on <code><html></code>. CSS vars update automatically.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-lg-4">
|
||||
<div class="card">
|
||||
<h5 class="h5 m-0">Notes</h5>
|
||||
<div class="small text-muted mt-1">quick sanity checks</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="badge badge-teal">badge-teal</div>
|
||||
<div class="badge badge-gray-light mt-2">badge-gray-light</div>
|
||||
<div class="badge badge-orange-light text-orange-900 mt-2">badge-orange-light + text-orange-900</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn-gray-light w-100">btn-gray-light</button>
|
||||
<button class="btn-gray-light btn-sm w-100 mt-2">btn-gray-light btn-sm</button>
|
||||
<button class="btn-teal w-100 mt-2">btn-teal</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="small text-muted mb-2">Visibility / overflow</div>
|
||||
<div class="bg-surface border rounded p-2 overflow-auto" style="max-height: 90px;">
|
||||
<div class="small">overflow-auto box</div>
|
||||
<div class="small text-muted">Line 1</div>
|
||||
<div class="small text-muted">Line 2</div>
|
||||
<div class="small text-muted">Line 3</div>
|
||||
<div class="small text-muted">Line 4</div>
|
||||
<div class="small text-muted">Line 5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modalExample" class="modal-overlay" aria-hidden="true">
|
||||
<div class="modal-content" role="dialog" aria-modal="true" aria-label="Create or edit entry">
|
||||
<div class="p-4 border-bottom d-flex justify-between align-center">
|
||||
<div>
|
||||
<h5 class="h5 m-0" id="modalTitle">Create new project</h5>
|
||||
<div class="small text-muted mt-1" id="modalSubTitle">Quick add / edit flow</div>
|
||||
</div>
|
||||
<button class="btn-close" data-toggle="#modalExample" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="alert-blue mb-3">alert-blue — Info message inside modal.</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">Client</label>
|
||||
<input type="text" class="input" id="modalClient" placeholder="Enter client..." />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">Status</label>
|
||||
<select class="select" id="modalStatus">
|
||||
<option>Paid</option>
|
||||
<option>Pending</option>
|
||||
<option>Processing</option>
|
||||
<option>Overdue</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">Amount</label>
|
||||
<input type="text" class="input" id="modalAmount" value="" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">Date</label>
|
||||
<input type="text" class="input" id="modalDate" placeholder="2026-02-16" />
|
||||
<div class="small text-muted mt-1">Used by the table edit action and quick-add flow.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-surface border-top text-right">
|
||||
<button class="btn-gray-light mr-2" data-toggle="#modalExample">Cancel</button>
|
||||
<button class="btn-teal">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mobileDrawer" class="drawer" aria-hidden="true">
|
||||
<div class="p-4">
|
||||
<div class="d-flex justify-between align-center">
|
||||
<h3 class="h3 m-0">VOR Mobile</h3>
|
||||
<button class="btn-close" data-toggle="#mobileDrawer" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<nav class="d-flex flex-column gap-2">
|
||||
<a href="#" class="nav-item is-active">Dashboard</a>
|
||||
<a href="#" class="nav-item">Customers</a>
|
||||
<a href="#" class="nav-item">Invoices</a>
|
||||
<a href="#" class="nav-item">Settings</a>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="small text-muted mb-2">Actions</div>
|
||||
<button class="btn-teal w-100 mb-2" data-theme-toggle>Toggle theme</button>
|
||||
<button class="btn-blue-light w-100 mb-2" data-toggle="#modalExample">Edit item</button>
|
||||
<button class="btn-teal w-100 mb-2" data-toggle="#modalExample">Open modal</button>
|
||||
<button class="btn-gray-light w-100" data-toggle="#mobileDrawer">Close menu</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
VOR.uiTable({
|
||||
el: "#transactionsTable",
|
||||
classArr: ["table", "striped", "hover"],
|
||||
data: [{
|
||||
client: "Acme Corp",
|
||||
status: "Paid",
|
||||
amount: "€ 1,200.00",
|
||||
date: "Feb 14, 2026"
|
||||
},
|
||||
{
|
||||
client: "Globex Inc",
|
||||
status: "Pending",
|
||||
amount: "€ 850.00",
|
||||
date: "Feb 15, 2026"
|
||||
},
|
||||
{
|
||||
client: "Initech",
|
||||
status: "Processing",
|
||||
amount: "€ 2,450.00",
|
||||
date: "Feb 16, 2026"
|
||||
},
|
||||
{
|
||||
client: "Umbrella",
|
||||
status: "Overdue",
|
||||
amount: "€ 640.00",
|
||||
date: "Feb 10, 2026"
|
||||
}
|
||||
],
|
||||
headers: [{
|
||||
key: "client",
|
||||
label: "Client"
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status"
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "Amount"
|
||||
},
|
||||
{
|
||||
key: "date",
|
||||
label: "Date"
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
sortable: false,
|
||||
action: "edit-expand",
|
||||
format: () => "Edit"
|
||||
}
|
||||
],
|
||||
editRenderer: (row) => {
|
||||
const wrap = VOR.createEl("div", ["p-4", "bg-surface"]);
|
||||
|
||||
const title = VOR.createEl("div", ["h6", "m-0", "mb-3"], `Edit ${row.client}`);
|
||||
|
||||
const clientGroup = VOR.createEl("div", ["form-group"]);
|
||||
clientGroup.append(
|
||||
VOR.createEl("label", ["label"], "Client"),
|
||||
VOR.createEl("input", ["input"], null, {
|
||||
type: "text",
|
||||
value: row.client
|
||||
})
|
||||
);
|
||||
|
||||
const statusGroup = VOR.createEl("div", ["form-group"]);
|
||||
const statusSelect = VOR.createEl("select", ["select"]);
|
||||
["Paid", "Pending", "Processing", "Overdue"].forEach((status) => {
|
||||
const option = VOR.createEl("option", null, status, {
|
||||
value: status
|
||||
});
|
||||
if (status === row.status) option.selected = true;
|
||||
statusSelect.append(option);
|
||||
});
|
||||
|
||||
statusGroup.append(
|
||||
VOR.createEl("label", ["label"], "Status"),
|
||||
statusSelect
|
||||
);
|
||||
|
||||
const amountGroup = VOR.createEl("div", ["form-group"]);
|
||||
amountGroup.append(
|
||||
VOR.createEl("label", ["label"], "Amount"),
|
||||
VOR.createEl("input", ["input"], null, {
|
||||
type: "text",
|
||||
value: row.amount
|
||||
})
|
||||
);
|
||||
|
||||
const dateGroup = VOR.createEl("div", ["form-group"]);
|
||||
dateGroup.append(
|
||||
VOR.createEl("label", ["label"], "Date"),
|
||||
VOR.createEl("input", ["input"], null, {
|
||||
type: "text",
|
||||
value: row.date
|
||||
})
|
||||
);
|
||||
|
||||
const actions = VOR.createEl("div", ["d-flex", "gap-2", "mt-3"]);
|
||||
const saveBtn = VOR.createEl("button", ["btn-teal"], "Save", {
|
||||
type: "button"
|
||||
});
|
||||
const closeBtn = VOR.createEl("button", ["btn-gray-light"], "Close", {
|
||||
type: "button"
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", () => {
|
||||
const editorRow = wrap.closest(".row-editor");
|
||||
editorRow?.remove();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", () => {
|
||||
VOR.uiAlert(`Saved ${row.client}`, "success");
|
||||
});
|
||||
|
||||
actions.append(saveBtn, closeBtn);
|
||||
|
||||
wrap.append(title, clientGroup, statusGroup, amountGroup, dateGroup, actions);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user