Initial Release v1.0.0: Full feature set with Docker automation, Nginx/Alpine stack

This commit is contained in:
dyzulk
2026-01-16 11:21:32 +07:00
commit 45623973a8
139 changed files with 24302 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Helpers;
class EncryptionHelper {
public static function encrypt($text) {
if (empty($text)) return '';
$key = \App\Config\SiteConfig::getSecretKey();
// Simple OpenSSL encryption
$iv_length = openssl_cipher_iv_length('aes-256-cbc');
$iv = openssl_random_pseudo_bytes($iv_length);
$encrypted = openssl_encrypt($text, 'aes-256-cbc', $key, 0, $iv);
return base64_encode($encrypted . '::' . $iv);
}
public static function decrypt($text) {
if (empty($text)) return '';
$key = \App\Config\SiteConfig::getSecretKey();
try {
$decoded = base64_decode($text, true);
if ($decoded === false) return $text; // Not valid base64
$parts = explode('::', $decoded, 2);
if (count($parts) !== 2) {
return $text; // Not our encrypted format, likely legacy/plain
}
list($encrypted_data, $iv) = $parts;
return openssl_decrypt($encrypted_data, 'aes-256-cbc', $key, 0, $iv);
} catch (\Exception $e) {
return $text; // Fallback
}
}
public static function formatBytes($bytes, $precision = 2) {
$units = array('B', 'KB', 'MB', 'GB', 'TB');
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
// Uncomment one of the following alternatives
$bytes /= pow(1024, $pow);
// $bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Helpers;
class ErrorHelper {
public static function show($code = 404, $message = 'Page Not Found', $description = null) {
http_response_code($code);
// Provide default descriptions for common codes
if ($description === null) {
switch ($code) {
case 403:
$description = "You do not have permission to access this resource.";
break;
case 500:
$description = "Something went wrong on our end. Please try again later.";
break;
case 503:
$description = "Service Unavailable. The server is currently unable to handle the request due to maintenance or overload.";
break;
case 404:
default:
$description = "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.";
break;
}
}
// Variables extracted in view
$errorCode = $code;
$errorMessage = $message;
$errorDescription = $description;
// Ensure strictly NO output before this if keeping clean, but we are in view mode.
// Clean buffer if active to remove partial content
if (ob_get_level()) {
ob_end_clean();
}
require ROOT . '/app/Views/errors/default.php';
exit;
}
public static function showException($exception) {
http_response_code(500);
// Clean output buffer to ensure clean error page
if (ob_get_level()) {
ob_end_clean();
}
require ROOT . '/app/Views/errors/development.php';
exit;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Helpers;
class FlashHelper {
const SESSION_KEY = 'flash_notification';
/**
* Set a flash message.
*
* @param string $type Notification type: 'success', 'error', 'warning', 'info', 'question'
* @param string $title Title of the notification (or i18n key)
* @param string $message (Optional) Body text of the notification (or i18n key)
* @param array $params (Optional) Parameters for translation interpolation
* @param bool $isTranslated Whether to treat title and message as translation keys
*/
public static function set($type, $title, $message = null, $params = [], $isTranslated = false) {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION[self::SESSION_KEY] = [
'type' => $type,
'title' => $title,
'message' => $message,
'params' => $params,
'isTranslated' => $isTranslated
];
}
/**
* Check if a flash message exists.
*
* @return boolean
*/
public static function has() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return isset($_SESSION[self::SESSION_KEY]);
}
/**
* Get the flash message and clear it from session.
*
* @return array|null
*/
public static function get() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (self::has()) {
$notification = $_SESSION[self::SESSION_KEY];
unset($_SESSION[self::SESSION_KEY]);
return $notification;
}
return null;
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Helpers;
class FormatHelper
{
/**
* Convert MikroTik duration string to human readable format.
* Example: "3w1d8h56m19s" -> "3 Weeks 1 Day 8 Hours 56 Minutes 19 Seconds"
*
* @param string $string
* @return string
*/
public static function elapsedTime($string)
{
if (empty($string)) return '-';
// Mikrotik formats:
// 1. "3w1d8h56m19s" (Full)
// 2. "00:05:00" (Simple H:i:s)
// 3. "1d 05:00:00" (Hybrid)
// 4. "sep/02/2023 10:00:00" (Absolute date, rarely used for uptime but useful to catch)
// Maps Mikrotik abbreviations to Human terms (Plural handled in logic)
$maps = [
'w' => 'Week',
'd' => 'Day',
'h' => 'Hour',
'm' => 'Minute',
's' => 'Second'
];
// Result container
$parts = [];
// Check for simple colon format (H:i:s)
if (strpos($string, ':') !== false && strpos($string, 'w') === false && strpos($string, 'd') === false) {
return $string; // Return as is or parse H:i:s if needed
}
// Parse regex for w, d, h, m, s
//preg_match_all('/(\d+)([wdhms])/', $string, $matches, PREG_SET_ORDER);
// Manual parsing to handle mixed cases more robustly or just regex
foreach ($maps as $key => $label) {
if (preg_match('/(\d+)'.$key.'/', $string, $match)) {
$value = intval($match[1]);
if ($value > 0) {
$parts[] = $value . ' ' . $label . ($value > 1 ? 's' : '');
}
}
}
// If no matches found, straightforward return (maybe it's raw seconds or weird format)
if (empty($parts)) {
if ($string === '0s' || $string === '00:00:00') return '-';
return $string;
}
return implode(' ', $parts);
}
/**
* Capitalize each word (Title Case)
* @param string $string
* @return string
*/
public static function capitalize($string)
{
return ucwords(strtolower($string));
}
/**
* Format Currency
* @param int|float $number
* @param string $prefix
* @return string
*/
public static function formatCurrency($number, $prefix = '')
{
return $prefix . ' ' . number_format($number, 0, ',', '.');
}
/**
* Format Bytes to KB, MB, GB
* @param int $bytes
* @param int $precision
* @return string
*/
public static function formatBytes($bytes, $precision = 2)
{
if ($bytes <= 0) return '-';
$base = log($bytes, 1024);
$suffixes = array('B', 'KB', 'MB', 'GB', 'TB');
return round(pow(1024, $base - floor($base)), $precision) . ' ' . $suffixes[floor($base)];
}
/**
* Format Date
* @param string $dateStr
* @param string $format
* @return string
*/
public static function formatDate($dateStr, $format = 'd M Y H:i')
{
if(empty($dateStr)) return '-';
// Handle Mikrotik default date formats if needed, usually they are readable
// e.g. "jan/02/1970 00:00:00"
$time = strtotime($dateStr);
if(!$time) return $dateStr;
return date($format, $time);
}
/**
* Convert Seconds to Human Readable format
* @param int $seconds
* @return string
*/
public static function formatSeconds($seconds) {
if ($seconds <= 0) return '0s';
$w = floor($seconds / 604800);
$d = floor(($seconds % 604800) / 86400);
$h = floor(($seconds % 86400) / 3600);
$m = floor(($seconds % 3600) / 60);
$s = $seconds % 60;
$parts = [];
if ($w > 0) $parts[] = $w . 'w';
if ($d > 0) $parts[] = $d . 'd';
if ($h > 0) $parts[] = $h . 'h';
if ($m > 0) $parts[] = $m . 'm';
if ($s > 0 || empty($parts)) $parts[] = $s . 's';
return implode('', $parts);
}
/**
* Parse MikroTik duration string to Seconds (int)
* Supports: 1d2h3m, 00:00:00, 1d 00:00:00
*/
public static function parseDuration($string) {
if (empty($string)) return 0;
$string = trim($string);
$totalSeconds = 0;
// 1. Handle "00:00:00" or "1d 00:00:00" (Colons)
if (strpos($string, ':') !== false) {
$parts = explode(' ', $string);
$timePart = end($parts); // 00:00:00
// Calc time part
$t = explode(':', $timePart);
if (count($t) === 3) {
$totalSeconds += ($t[0] * 3600) + ($t[1] * 60) + $t[2];
} elseif (count($t) === 2) { // 00:00 (mm:ss or hh:mm? usually hh:mm in routeros logs, but 00:00:59 is uptime)
// Assumption: if 2 parts, treat as MM:SS if small, or HH:MM?
// RouterOS uptime is usually HH:MM:SS. Let's assume standard time ref.
// Actually RouterOS uptime often drops hours if 0.
// SAFE BET: Just Parse standard 3 parts.
$totalSeconds += ($t[0] * 60) + $t[1];
}
// Calc Day part "1d"
if (count($parts) > 1) {
$dayPart = $parts[0]; // 1d
$totalSeconds += intval($dayPart) * 86400;
}
return $totalSeconds;
}
// 2. Handle "1w2d3h4m5s" (Letters)
if (preg_match_all('/(\d+)([wdhms])/', $string, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$val = intval($m[1]);
$unit = $m[2];
switch ($unit) {
case 'w': $totalSeconds += $val * 604800; break;
case 'd': $totalSeconds += $val * 86400; break;
case 'h': $totalSeconds += $val * 3600; break;
case 'm': $totalSeconds += $val * 60; break;
case 's': $totalSeconds += $val; break;
}
}
return $totalSeconds;
}
// 3. Raw number?
return intval($string);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Helpers;
class HotspotHelper
{
/**
* Parse profile on-login script metadata (Mikhmon format)
* Format: :put (",mode,price,validity,selling_price,lock_user,")
*/
public static function parseProfileMetadata($script) {
if (empty($script)) return [];
// Look for :put (",...,") pattern
preg_match('/:put \("([^"]+)"\)/', $script, $matches);
if (isset($matches[1])) {
// Explode CSV: ,mode,price,validity,selling_price,lock_user,
$data = explode(',', $matches[1]);
$clean = function($val) {
return ($val === '0' || $val === '0d' || $val === '0h' || $val === '0m') ? '' : $val;
};
return [
'expired_mode' => $data[1] ?? '',
'price' => $clean($data[2] ?? ''),
'validity' => self::formatValidity($clean($data[3] ?? '')),
'selling_price' => $clean($data[4] ?? ''),
'lock_user' => $data[6] ?? '',
];
}
return [];
}
/**
* Format validity string (e.g., 3d2h5m -> 3d 2h 5m)
*/
public static function formatValidity($val) {
if (empty($val)) return '';
// Insert space after letters
$val = preg_replace('/([a-z]+)/i', '$1 ', $val);
return trim($val);
}
/**
* Format expired mode code to readable text
*/
public static function formatExpiredMode($mode) {
switch ($mode) {
case 'rem': return 'Remove';
case 'ntf': return 'Notice';
case 'remc': return 'Remove & Record';
case 'ntfc': return 'Notice & Record';
default: return $mode;
}
}
/**
* Format bytes to human readable string (KB, MB, GB)
*/
public static function formatBytes($bytes, $precision = 2) {
if (empty($bytes) || $bytes === '0') return '0 B';
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
/**
* Get User Status Code
* Returns: active, limited, locked, expired
*/
public static function getUserStatus($user) {
// 1. Check for specific comment keywords (Highest Priority - usually set by scripts)
$comment = strtolower($user['comment'] ?? '');
// "exp" explicitly means expired by script
if (strpos($comment, 'exp') !== false) {
return 'expired';
}
// 2. Check Data Limit (Quota)
$limitBytes = isset($user['limit-bytes-total']) ? (int)$user['limit-bytes-total'] : 0;
if ($limitBytes > 0) {
$bytesIn = isset($user['bytes-in']) ? (int)$user['bytes-in'] : 0;
$bytesOut = isset($user['bytes-out']) ? (int)$user['bytes-out'] : 0;
if (($bytesIn + $bytesOut) >= $limitBytes) {
return 'limited';
}
}
// 3. Check Disabled state
if (($user['disabled'] ?? 'false') === 'true') {
return 'locked';
}
// 4. Default
return 'active';
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Helpers;
class LanguageHelper
{
/**
* Get list of available languages from public/lang directory
*
* @return array Array of languages with code and name
*/
public static function getAvailableLanguages()
{
$langDir = ROOT . '/public/lang';
$languages = [];
if (!is_dir($langDir)) {
return [];
}
$files = scandir($langDir);
foreach ($files as $file) {
if ($file === '.' || $file === '..') continue;
if (pathinfo($file, PATHINFO_EXTENSION) === 'json') {
$code = pathinfo($file, PATHINFO_FILENAME);
// Read file to get language name if defined, otherwise use code
$content = file_get_contents($langDir . '/' . $file);
$data = json_decode($content, true);
$name = $data['_meta']['name'] ?? strtoupper($code);
$flag = $data['_meta']['flag'] ?? '🌐';
$languages[] = [
'code' => $code,
'name' => $name,
'flag' => $flag
];
}
}
return $languages;
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Helpers;
class TemplateHelper {
public static function getDefaultTemplate() {
return '
<style>
.voucher { width: 250px; background: #fff; padding: 10px; border: 1px solid #ccc; font-family: "Courier New", Courier, monospace; color: #000; }
.header { text-align: center; font-weight: bold; margin-bottom: 5px; font-size: 14px; }
.row { display: flex; justify-content: space-between; margin-bottom: 2px; font-size: 12px; }
.code { font-size: 16px; font-weight: bold; text-align: center; margin: 10px 0; border: 1px dashed #000; padding: 5px; }
.qr { text-align: center; margin-top: 5px; }
</style>
<div class="voucher">
<div class="header">{{server_name}}</div>
<div class="row"><span>Profile:</span> <span>{{profile}}</span></div>
<div class="row"><span>Valid:</span> <span>{{validity}}</span></div>
<div class="row"><span>Price:</span> <span>{{price}}</span></div>
<div class="code">
User: {{username}}<br>
Pass: {{password}}
</div>
<div class="qr">{{qrcode}}</div>
<div style="text-align:center; font-size: 10px; margin-top:5px;">
Login: http://{{dns_name}}/login
</div>
</div>';
}
public static function getMockContent($content) {
if (empty($content)) return '';
// Dummy Data
$dummyData = [
'{{server_name}}' => 'Hotspot',
'{{dns_name}}' => 'hotspot.lan',
'{{username}}' => 'u-5829',
'{{password}}' => '5912',
'{{price}}' => '5.000',
'{{validity}}' => '12 Hours',
'{{profile}}' => 'Small-Packet',
'{{time_limit}}' => '12h',
'{{data_limit}}' => '1 GB',
'{{ip_address}}' => '192.168.88.254',
'{{mac_address}}' => 'AA:BB:CC:DD:EE:FF',
'{{comment}}' => 'Thank You',
'{{copyright}}' => 'Mikhmon',
];
$content = str_replace(array_keys($dummyData), array_values($dummyData), $content);
// QR Code replacement
$content = preg_replace('/\{\{\s*qrcode.*?\}\}/i', '<img src="https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=http://hotspot.lan/login?user=u-5829" style="width:80px;height:80px;display:inline-block;">', $content);
return $content;
}
public static function getPreviewPage($content) {
$mockContent = self::getMockContent($content);
return '
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
body { display: flex; align-items: center; justify-content: center; background-color: transparent; }
#wrapper { display: inline-block; transform-origin: center center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
</style>
</head>
<body>
<div id="wrapper">' . $mockContent . '</div>
<script>
window.addEventListener("load", () => {
const wrap = document.getElementById("wrapper");
if(!wrap) return;
const updateScale = () => {
const w = wrap.offsetWidth;
const h = wrap.offsetHeight;
const winW = window.innerWidth - 24;
const winH = window.innerHeight - 24;
let scale = 1;
if (w > winW || h > winH) {
scale = Math.min(winW / w, winH / h);
} else {
scale = Math.min(winW / w, winH / h);
}
wrap.style.transform = `scale(${scale})`;
};
updateScale();
window.addEventListener("resize", updateScale);
});
</script>
</body>
</html>';
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Helpers;
class ViewHelper
{
/**
* Render a generic badge with icon
* @param string $status (active, locked, expired, limited, etc.)
* @param string|null $label Optional override text
*/
public static function badge($status, $label = null) {
// Define styles for each status key
$styles = [
'active' => ['class' => 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 'icon' => 'check-circle'],
'limited' => ['class' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', 'icon' => 'pie-chart'],
'locked' => ['class' => 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', 'icon' => 'lock'],
'expired' => ['class' => 'bg-accents-2 text-accents-6', 'icon' => 'clock'],
'default' => ['class' => 'bg-blue-100 text-blue-800', 'icon' => 'info']
];
$style = $styles[$status] ?? $styles['default'];
$text = $label ?? ucfirst($status === 'limited' ? 'Quota' : $status);
return sprintf(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s gap-1"><i data-lucide="%s" class="w-3 h-3"></i> %s</span>',
$style['class'],
$style['icon'],
$text
);
}
}