mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-26 13:31:56 +07:00
chore: bump version to v1.2.0, cleanup repo, and update docs refs
This commit is contained in:
@@ -44,7 +44,7 @@ class InstallController extends Controller {
|
||||
Migrations::up();
|
||||
|
||||
// 2. Generate Key if default
|
||||
if (SiteConfig::getSecretKey() === 'mikhmonv3remake_secret_key_32bytes') {
|
||||
if (SiteConfig::getSecretKey() === 'mivo_official_secret_key_32bytes') {
|
||||
$this->generateKey();
|
||||
}
|
||||
|
||||
@@ -90,11 +90,11 @@ class InstallController extends Controller {
|
||||
$envPath = ROOT . '/.env';
|
||||
if (!file_exists($envPath)) {
|
||||
// Check if SiteConfig has a manual override (legacy)
|
||||
return SiteConfig::getSecretKey() !== 'mikhmonv3remake_secret_key_32bytes';
|
||||
return SiteConfig::getSecretKey() !== 'mivo_official_secret_key_32bytes';
|
||||
}
|
||||
|
||||
$key = getenv('APP_KEY');
|
||||
$keyChanged = ($key && $key !== 'mikhmonv3remake_secret_key_32bytes');
|
||||
$keyChanged = ($key && $key !== 'mivo_official_secret_key_32bytes');
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
@@ -191,10 +191,10 @@ class QuickPrintController extends Controller {
|
||||
// Check if M or G
|
||||
// Simple logic for now, assuming raw if number, or passing string if Mikrotik accepts it (usually requires bytes)
|
||||
// Let's assume user inputs "100M" or "1G" which usually needs parsing.
|
||||
// For now, let's assume input is NUMBER in MB as per standard Mikhmon practice, OR generic string.
|
||||
// For now, let's assume input is NUMBER in MB as per standard Mivo practice, OR generic string.
|
||||
// We'll pass as is for strings, or multiply if strictly numeric?
|
||||
// Let's rely on standard Mikrotik parsing if string passed, or convert.
|
||||
// Mikhmon v3 usually uses dropdown "MB/GB".
|
||||
// Mivo usually uses dropdown "MB/GB".
|
||||
// Implementing simple conversion:
|
||||
$val = intval($package['data_limit']);
|
||||
if (strpos(strtolower($package['data_limit']), 'g') !== false) {
|
||||
|
||||
@@ -11,44 +11,89 @@ class ReportController extends Controller
|
||||
{
|
||||
public function index($session)
|
||||
{
|
||||
$configModel = new Config();
|
||||
$config = $configModel->getSession($session);
|
||||
|
||||
if (!$config) {
|
||||
$data = $this->getSellingReportData($session);
|
||||
if (!$data) {
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
return $this->view('reports/selling', $data);
|
||||
}
|
||||
|
||||
public function sellingExport($session, $type)
|
||||
{
|
||||
$data = $this->getSellingReportData($session);
|
||||
if (!$data) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'No data found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$report = $data['report'];
|
||||
$exportData = [];
|
||||
|
||||
foreach ($report as $row) {
|
||||
$exportData[] = [
|
||||
'Date/Batch' => $row['date'],
|
||||
'Status' => $row['status'] ?? '-',
|
||||
'Qty (Stock)' => $row['count'],
|
||||
'Used' => $row['realized_count'],
|
||||
'Realized Income' => $row['realized_total'],
|
||||
'Total Stock' => $row['total']
|
||||
];
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($exportData);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function getSellingReportData($session)
|
||||
{
|
||||
$configModel = new Config();
|
||||
$config = $configModel->getSession($session);
|
||||
|
||||
if (!$config) return null;
|
||||
|
||||
$API = new RouterOSAPI();
|
||||
$users = [];
|
||||
|
||||
$profilePriceMap = [];
|
||||
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
||||
// Fetch All Users
|
||||
// Optimized print: get .id, name, price, comment
|
||||
$users = $API->comm("/ip/hotspot/user/print");
|
||||
$profiles = $API->comm("/ip/hotspot/user/profile/print");
|
||||
$API->disconnect();
|
||||
|
||||
// Build Price Map from Profile Scripts
|
||||
foreach ($profiles as $p) {
|
||||
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
|
||||
if (!empty($meta['price'])) {
|
||||
$profilePriceMap[$p['name']] = intval($meta['price']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate Data
|
||||
$report = [];
|
||||
$totalIncome = 0;
|
||||
$totalVouchers = 0;
|
||||
|
||||
// Realized (Used) Metrics
|
||||
$totalRealizedIncome = 0;
|
||||
$totalUsedVouchers = 0;
|
||||
|
||||
foreach ($users as $user) {
|
||||
// Skip if no price
|
||||
if (empty($user['price']) || $user['price'] == '0') continue;
|
||||
// Smart Price Detection
|
||||
$price = $this->detectPrice($user, $profilePriceMap);
|
||||
if ($price <= 0) continue;
|
||||
|
||||
// Inject price back to user array for downstream logic
|
||||
$user['price'] = $price;
|
||||
|
||||
// Determine Date from Comment
|
||||
// Mikhmon format usually: "mikhmon-MM/DD/YYYY" or just "MM/DD/YYYY" or plain comment
|
||||
// We will try to parse a date from the comment, or use "Unknown Date"
|
||||
$date = 'Unknown Date';
|
||||
$comment = $user['comment'] ?? '';
|
||||
|
||||
// Regex for date patterns (d-m-Y or m/d/Y or Y-m-d)
|
||||
// Simplify: Group by Comment content itself if it looks like a date/batch
|
||||
// Or try to extract M-Y.
|
||||
|
||||
// For feature parity, Mikhmon often groups by the exact comment string as the "Batch/Date"
|
||||
if (!empty($comment)) {
|
||||
$date = $comment;
|
||||
}
|
||||
@@ -57,28 +102,59 @@ class ReportController extends Controller
|
||||
$report[$date] = [
|
||||
'date' => $date,
|
||||
'count' => 0,
|
||||
'total' => 0
|
||||
'total' => 0,
|
||||
'realized_total' => 0,
|
||||
'realized_count' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$price = intval($user['price']);
|
||||
|
||||
// Check if Used
|
||||
// Criteria: uptime != 0s OR bytes-out > 0 OR bytes-in > 0
|
||||
$isUsed = false;
|
||||
if ((isset($user['uptime']) && $user['uptime'] != '0s') ||
|
||||
(isset($user['bytes-out']) && $user['bytes-out'] > 0)) {
|
||||
$isUsed = true;
|
||||
}
|
||||
|
||||
$report[$date]['count']++;
|
||||
$report[$date]['total'] += $price;
|
||||
|
||||
$totalIncome += $price;
|
||||
$totalVouchers++;
|
||||
|
||||
if ($isUsed) {
|
||||
$report[$date]['realized_count']++;
|
||||
$report[$date]['realized_total'] += $price;
|
||||
$totalRealizedIncome += $price;
|
||||
$totalUsedVouchers++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Status for each batch
|
||||
foreach ($report as &$row) {
|
||||
if ($row['realized_count'] === 0) {
|
||||
$row['status'] = 'New';
|
||||
} elseif ($row['realized_count'] >= $row['count']) {
|
||||
$row['status'] = 'Sold Out';
|
||||
} else {
|
||||
$row['status'] = 'Selling';
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
// Sort by key (Date/Comment) desc
|
||||
krsort($report);
|
||||
|
||||
return $this->view('reports/selling', [
|
||||
return [
|
||||
'session' => $session,
|
||||
'report' => $report,
|
||||
'totalIncome' => $totalIncome,
|
||||
'totalVouchers' => $totalVouchers,
|
||||
'totalRealizedIncome' => $totalRealizedIncome,
|
||||
'totalUsedVouchers' => $totalUsedVouchers,
|
||||
'currency' => $config['currency'] ?? 'Rp'
|
||||
]);
|
||||
];
|
||||
}
|
||||
public function resume($session)
|
||||
{
|
||||
@@ -93,9 +169,18 @@ class ReportController extends Controller
|
||||
$API = new RouterOSAPI();
|
||||
$users = [];
|
||||
|
||||
$profilePriceMap = [];
|
||||
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
||||
$users = $API->comm("/ip/hotspot/user/print");
|
||||
$profiles = $API->comm("/ip/hotspot/user/profile/print");
|
||||
$API->disconnect();
|
||||
|
||||
foreach ($profiles as $p) {
|
||||
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
|
||||
if (!empty($meta['price'])) {
|
||||
$profilePriceMap[$p['name']] = intval($meta['price']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Aggregates
|
||||
@@ -103,28 +188,69 @@ class ReportController extends Controller
|
||||
$monthly = [];
|
||||
$yearly = [];
|
||||
$totalIncome = 0;
|
||||
|
||||
// Realized Metrics for Resume?
|
||||
// Usually Resume is just general financial overview.
|
||||
// We'll stick to Stock for now unless requested, as Resume mimics Mikhmon's logic closer.
|
||||
// Or we can just calculate standard revenue based on Stock if that's what user expects for "Resume",
|
||||
// OR we can add Realized. Let's keep Resume simple first, focus on Selling Report.
|
||||
|
||||
foreach ($users as $user) {
|
||||
if (empty($user['price']) || $user['price'] == '0') continue;
|
||||
$price = $this->detectPrice($user, $profilePriceMap);
|
||||
if ($price <= 0) continue;
|
||||
|
||||
// Try to parse Date from Comment (Mikhmon format: mikhmon-10/25/2023 or just 10/25/2023)
|
||||
$user['price'] = $price;
|
||||
|
||||
// Try to parse Date from Comment
|
||||
// Supported formats:
|
||||
// - MM/DD/YYYY or MM.DD.YYYY (US)
|
||||
// - DD-MM-YYYY (EU/ID)
|
||||
// - YYYY-MM-DD (ISO)
|
||||
// Regex explanations:
|
||||
// 1. \b starts word boundary to avoid matching parts of batch IDs (e.g. 711-...)
|
||||
// 2. We look for 3 groups of digits separated by / . or -
|
||||
$comment = $user['comment'] ?? '';
|
||||
$dateObj = null;
|
||||
|
||||
// Simple parser: try to find MM/DD/YYYY
|
||||
if (preg_match('/(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})/', $comment, $matches)) {
|
||||
// Assuming MM/DD/YYYY based on typical Mikhmon, but could be DD-MM-YYYY
|
||||
// Let's standardise on checking valid date.
|
||||
// Standard Mikhmon V3 is MM/DD/YYYY.
|
||||
$m = $matches[1];
|
||||
$d = $matches[2];
|
||||
$y = $matches[3];
|
||||
if (strlen($y) == 2) $y = '20' . $y;
|
||||
$dateObj = \DateTime::createFromFormat('m/d/Y', "$m/$d/$y");
|
||||
if (preg_match('/\b(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})\b/', $comment, $matches)) {
|
||||
// Heuristic: If 3rd part is year (4 digits or > 31), use it.
|
||||
// If 1st part > 12, it's likely Day (DD-MM-YYYY).
|
||||
// Mivo Generator format often: MM.DD.YY or DD.MM.YY
|
||||
|
||||
$p1 = intval($matches[1]);
|
||||
$p2 = intval($matches[2]);
|
||||
$p3 = intval($matches[3]);
|
||||
|
||||
$year = $p3;
|
||||
$month = $p1;
|
||||
$day = $p2;
|
||||
|
||||
// Adjust 2-digit year
|
||||
if ($year < 100) $year += 2000;
|
||||
|
||||
// Guess format
|
||||
// If p1 > 12, it must be Day. (DD-MM-YYYY)
|
||||
if ($p1 > 12) {
|
||||
$day = $p1;
|
||||
$month = $p2;
|
||||
}
|
||||
|
||||
// Validate date
|
||||
if (checkdate($month, $day, $year)) {
|
||||
$dateObj = (new \DateTime())->setDate($year, $month, $day);
|
||||
}
|
||||
}
|
||||
// Check for ISO YYYY-MM-DD
|
||||
elseif (preg_match('/\b(\d{4})[\/.-](\d{1,2})[\/.-](\d{1,2})\b/', $comment, $matches)) {
|
||||
if (checkdate($matches[2], $matches[3], $matches[1])) {
|
||||
$dateObj = (new \DateTime())->setDate($matches[1], $matches[2], $matches[3]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If no date found in comment, maybe created at?
|
||||
// Usually Mikhmon relies strictly on comment.
|
||||
// Fallback: If no date found -> "Unknown Date" in resume?
|
||||
// Resume requires Month/Year keys. If we can't parse date, we can't add to daily/monthly.
|
||||
// We'll skip or add to "Unknown"?
|
||||
// Current logic skips if !$dateObj
|
||||
if (!$dateObj) continue;
|
||||
|
||||
$price = intval($user['price']);
|
||||
@@ -162,4 +288,38 @@ class ReportController extends Controller
|
||||
'currency' => $config['currency'] ?? 'Rp'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart Price Detection Logic
|
||||
* Hierarchy:
|
||||
* 1. Comment Override (p:5000)
|
||||
* 2. Profile Script (Standard Profile)
|
||||
* 3. Profile Name Fallback (50K) -- REMOVED loose number matching to avoid garbage data
|
||||
*/
|
||||
private function detectPrice($user, $profileMap)
|
||||
{
|
||||
$comment = $user['comment'] ?? '';
|
||||
|
||||
// 1. Comment Override (p:5000 or price:5000)
|
||||
// Updated: Added \b to prevent matching "up-123" as "p-123"
|
||||
if (preg_match('/\b(?:p|price)[:-]\s*(\d+)/i', $comment, $matches)) {
|
||||
return intval($matches[1]);
|
||||
}
|
||||
|
||||
// 2. Profile Script
|
||||
$profile = $user['profile'] ?? 'default';
|
||||
if (isset($profileMap[$profile])) {
|
||||
return $profileMap[$profile];
|
||||
}
|
||||
|
||||
// 3. Fallback: Parse Profile Name (Strict "K" notation only)
|
||||
// Matches "5K", "5k" -> 5000
|
||||
if (preg_match('/(\d+)k\b/i', $profile, $m)) {
|
||||
return intval($m[1]) * 1000;
|
||||
}
|
||||
|
||||
// DEPRECATED: Loose number matching caused garbage data (e.g. "up-311" -> 311)
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class SettingsController extends Controller {
|
||||
$db = \App\Core\Database::getInstance();
|
||||
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
// Assuming we are updating the default 'admin' user or the currently logged in user
|
||||
// Original Mikhmon usually has one main user. Let's update 'admin' for now.
|
||||
// Original Mivo usually has one main user. Let's update 'admin' for now.
|
||||
$db->query("UPDATE users SET password = ? WHERE username = 'admin'", [$hash]);
|
||||
\App\Helpers\FlashHelper::set('success', 'toasts.password_updated', 'toasts.password_updated_desc', [], true);
|
||||
}
|
||||
@@ -431,4 +431,194 @@ class SettingsController extends Controller {
|
||||
}
|
||||
header('Location: /settings/api-cors');
|
||||
}
|
||||
|
||||
// --- Plugin Management ---
|
||||
|
||||
public function plugins() {
|
||||
$pluginManager = new \App\Core\PluginManager();
|
||||
// Since PluginManager loads everything in constructor/loadPlugins,
|
||||
// we can just scan the directory to list them and check status (implied active for now)
|
||||
$pluginsDir = ROOT . '/plugins';
|
||||
$plugins = [];
|
||||
|
||||
if (is_dir($pluginsDir)) {
|
||||
$folders = scandir($pluginsDir);
|
||||
foreach ($folders as $folder) {
|
||||
if ($folder === '.' || $folder === '..') continue;
|
||||
|
||||
$path = $pluginsDir . '/' . $folder;
|
||||
if (is_dir($path) && file_exists($path . '/plugin.php')) {
|
||||
// Try to read header from plugin.php
|
||||
$content = file_get_contents($path . '/plugin.php', false, null, 0, 1024); // Read first 1KB
|
||||
preg_match('/Plugin Name:\s*(.*)$/mi', $content, $nameMatch);
|
||||
preg_match('/Version:\s*(.*)$/mi', $content, $verMatch);
|
||||
preg_match('/Description:\s*(.*)$/mi', $content, $descMatch);
|
||||
preg_match('/Author:\s*(.*)$/mi', $content, $authMatch);
|
||||
|
||||
$plugins[] = [
|
||||
'id' => $folder,
|
||||
'name' => trim($nameMatch[1] ?? $folder),
|
||||
'version' => trim($verMatch[1] ?? '1.0.0'),
|
||||
'description' => trim($descMatch[1] ?? '-'),
|
||||
'author' => trim($authMatch[1] ?? '-'),
|
||||
'path' => $path
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->view('settings/plugins', ['plugins' => $plugins]);
|
||||
}
|
||||
|
||||
public function uploadPlugin() {
|
||||
if (!isset($_FILES['plugin_file']) || $_FILES['plugin_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.no_file_selected', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['plugin_file'];
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext !== 'zip') {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Only .zip files are allowed', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($file['tmp_name']) === TRUE) {
|
||||
$extractPath = ROOT . '/plugins/';
|
||||
if (!is_dir($extractPath)) mkdir($extractPath, 0755, true);
|
||||
|
||||
// TODO: Better validation to prevent overwriting existing plugins without confirmation?
|
||||
// For now, extraction overwrites.
|
||||
|
||||
// Validate content before extracting everything
|
||||
// Check if zip has a root folder or just files
|
||||
// Logic:
|
||||
// 1. Extract to temp.
|
||||
// 2. Find plugin.php
|
||||
// 3. Move to plugins dir.
|
||||
|
||||
$tempExtract = sys_get_temp_dir() . '/mivo_plugin_' . uniqid();
|
||||
if (!mkdir($tempExtract, 0755, true)) {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Failed to create temp dir', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$zip->extractTo($tempExtract);
|
||||
$zip->close();
|
||||
|
||||
// Search for plugin.php
|
||||
$pluginFile = null;
|
||||
$pluginRoot = $tempExtract;
|
||||
|
||||
// Recursive iterator to find plugin.php (max depth 2 to avoid deep scanning)
|
||||
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tempExtract));
|
||||
foreach ($rii as $file) {
|
||||
if ($file->isDir()) continue;
|
||||
if ($file->getFilename() === 'plugin.php') {
|
||||
$pluginFile = $file->getPathname();
|
||||
$pluginRoot = dirname($pluginFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($pluginFile) {
|
||||
// Determine destination name
|
||||
// If the immediate parent of plugin.php is NOT the temp dir, use that folder name.
|
||||
// Else use the zip name.
|
||||
$folderName = basename($pluginRoot);
|
||||
if ($pluginRoot === $tempExtract) {
|
||||
$folderName = pathinfo($_FILES['plugin_file']['name'], PATHINFO_FILENAME);
|
||||
}
|
||||
|
||||
$dest = $extractPath . $folderName;
|
||||
|
||||
// Move/Copy
|
||||
// Using helper or rename. Rename might fail across volumes (temp to project).
|
||||
// Use custom recursive copy then delete temp.
|
||||
$this->recurseCopy($pluginRoot, $dest);
|
||||
|
||||
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_installed', 'toasts.plugin_installed_desc', ['name' => $folderName], true);
|
||||
} else {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.install_failed', 'toasts.invalid_plugin_desc', [], true);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
$this->recurseDelete($tempExtract);
|
||||
|
||||
} else {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.zip_open_failed_desc', [], true);
|
||||
}
|
||||
|
||||
header('Location: /settings/plugins');
|
||||
}
|
||||
|
||||
public function deletePlugin() {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_POST['plugin_id'] ?? '';
|
||||
if (empty($id)) {
|
||||
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Security check: validate id is just a folder name, no path traversal
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $id)) {
|
||||
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID format', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$pluginDir = ROOT . '/plugins/' . $id;
|
||||
|
||||
if (is_dir($pluginDir)) {
|
||||
$this->recurseDelete($pluginDir);
|
||||
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_deleted', 'toasts.plugin_deleted_desc', [], true);
|
||||
} else {
|
||||
\App\Helpers\FlashHelper::set('error', 'common.error', 'Plugin directory not found', [], true);
|
||||
}
|
||||
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Helper for recursive copy (since rename/move_uploaded_file limit across partitions)
|
||||
private function recurseCopy($src, $dst) {
|
||||
$dir = opendir($src);
|
||||
@mkdir($dst);
|
||||
while(false !== ( $file = readdir($dir)) ) {
|
||||
if (( $file != '.' ) && ( $file != '..' )) {
|
||||
if ( is_dir($src . '/' . $file) ) {
|
||||
$this->recurseCopy($src . '/' . $file,$dst . '/' . $file);
|
||||
}
|
||||
else {
|
||||
copy($src . '/' . $file,$dst . '/' . $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir($dir);
|
||||
}
|
||||
|
||||
private function recurseDelete($dir) {
|
||||
if (!is_dir($dir)) return;
|
||||
$scan = scandir($dir);
|
||||
foreach ($scan as $file) {
|
||||
if ($file == '.' || $file == '..') continue;
|
||||
if (is_dir($dir . "/" . $file)) {
|
||||
$this->recurseDelete($dir . "/" . $file);
|
||||
} else {
|
||||
unlink($dir . "/" . $file);
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user