diff --git a/README.md b/README.md new file mode 100644 index 0000000..774e515 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# GSM Theme Clone (PHP 8.1+, No Composer) + +A lightweight PHP MVC web app with Bootstrap 5 for admin and user panels, and a web-based installer at `/install`. + +## Requirements +- PHP 8.1+ +- MySQL 5.7+/MariaDB 10+ +- Extensions: mysqli, pdo_mysql, curl, openssl, json, mbstring, zip, gd, fileinfo +- Apache with mod_rewrite (for pretty URLs) + +## Quick Install on cPanel +1. Upload the ZIP to your domain root (e.g., `public_html`). +2. Extract the ZIP. +3. Ensure `public/.htaccess` is present and your document root points to `public` (or move contents of `public` to the root if needed). +4. Visit `https://yourdomain.com/install`. +5. Follow the wizard: + - Environment check + - License agreement + - Database credentials + - Site name, URL, admin email & password + - Auto-generate `config.php` + - Import `database.sql` + - Create admin user + - Auto-rename `/install` + +If you skip the installer, default admin is `admin@example.com / Password@123` (update after first login). Copy `config.php.sample` to `config.php` and edit. + +## Cron Jobs (cPanel > Cron Jobs) +Use the following examples, adjusting paths and PHP binary as needed. + +- Hourly API sync: +``` +/usr/bin/php -q /home/USER/public_html/cron/sync_apis.php +``` +- Every 2 min order updates: +``` +/usr/bin/php -q /home/USER/public_html/cron/update_orders.php +``` + +Configure Dhru-like API paths and keys in Admin > Settings > Settings. Default keys assume responses like `{ "order_id": "123", "status": "processing", "result": {...} }` and services under `data`. Adjust if your provider uses different keys. +- Daily subscription checks: +``` +/usr/bin/php -q /home/USER/public_html/cron/subscriptions_check.php +``` +- Hourly failed job alerts: +``` +/usr/bin/php -q /home/USER/public_html/cron/failed_jobs_alert.php +``` + +Alternatively, you can trigger via HTTP with a secret: +``` +https://yourdomain.com/cron/sync_apis.php?secret=YOUR_CRON_SECRET +``` + +## SMTP (PHPMailer) +Place PHPMailer sources under `lib/PHPMailer/src/{PHPMailer.php,SMTP.php,Exception.php}` to enable SMTP. Otherwise the app will fallback to PHP `mail()`. + +## Security +- CSRF tokens on all POST forms +- Prepared statements via PDO +- Basic XSS escaping via `View::e()` +- Optional TOTP 2FA foundation + +## Development +- No Composer required +- MVC structure under `app/` +- Routes in `app/routes.php` + +## License +See `license.txt`. This project is provided as-is. \ No newline at end of file diff --git a/app/Controllers/Admin/ApisController.php b/app/Controllers/Admin/ApisController.php new file mode 100644 index 0000000..e2ca103 --- /dev/null +++ b/app/Controllers/Admin/ApisController.php @@ -0,0 +1,41 @@ +requireRole(['admin', 'super_admin']); + $apis = DB::fetchAll('SELECT * FROM apis ORDER BY name'); + $this->render('admin/apis/index', [ + 'title' => 'APIs', + 'apis' => $apis, + ], 'admin'); + } + + public function createForm(): void + { + $this->requireRole(['admin', 'super_admin']); + $this->render('admin/apis/create', ['title' => 'Add API'], 'admin'); + } + + public function create(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $name = trim($_POST['name'] ?? ''); + $base = trim($_POST['base_url'] ?? ''); + $key = trim($_POST['api_key'] ?? ''); + $type = trim($_POST['type'] ?? 'generic'); + if ($name && $base && $key) { + DB::insert('INSERT INTO apis (name, base_url, api_key, type, status, created_at, updated_at) VALUES (:n,:b,:k,:t,1,NOW(),NOW())', [ + 'n' => $name, 'b' => $base, 'k' => $key, 't' => $type, + ]); + } + $this->redirect('/admin/apis'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/DashboardController.php b/app/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..fa4cee5 --- /dev/null +++ b/app/Controllers/Admin/DashboardController.php @@ -0,0 +1,25 @@ +requireRole(['admin', 'super_admin']); + + $totals = [ + 'users' => (int) DB::fetch('SELECT COUNT(*) AS c FROM users')['c'] ?? 0, + 'orders' => (int) DB::fetch('SELECT COUNT(*) AS c FROM orders')['c'] ?? 0, + 'revenue' => (float) (DB::fetch('SELECT COALESCE(SUM(CASE WHEN amount < 0 THEN 0 ELSE amount END),0) AS s FROM wallet_transactions')['s'] ?? 0), + ]; + + $this->render('admin/dashboard', [ + 'title' => 'Admin Dashboard', + 'totals' => $totals, + ], 'admin'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/LogsController.php b/app/Controllers/Admin/LogsController.php new file mode 100644 index 0000000..0b3666f --- /dev/null +++ b/app/Controllers/Admin/LogsController.php @@ -0,0 +1,18 @@ +requireRole(['admin', 'super_admin']); + $logs = DB::fetchAll('SELECT * FROM cron_logs ORDER BY ran_at DESC, id DESC LIMIT 200'); + $this->render('admin/logs/index', [ + 'title' => 'Logs', + 'logs' => $logs, + ], 'admin'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/OrdersController.php b/app/Controllers/Admin/OrdersController.php new file mode 100644 index 0000000..b7d2ea7 --- /dev/null +++ b/app/Controllers/Admin/OrdersController.php @@ -0,0 +1,18 @@ +requireRole(['admin', 'super_admin']); + $orders = DB::fetchAll('SELECT o.*, u.email, s.name AS service_name FROM orders o JOIN users u ON u.id = o.user_id JOIN services s ON s.id = o.service_id ORDER BY o.id DESC LIMIT 200'); + $this->render('admin/orders/index', [ + 'title' => 'Orders', + 'orders' => $orders, + ], 'admin'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/ReportsController.php b/app/Controllers/Admin/ReportsController.php new file mode 100644 index 0000000..00e8b73 --- /dev/null +++ b/app/Controllers/Admin/ReportsController.php @@ -0,0 +1,24 @@ +requireRole(['admin', 'super_admin']); + $totals = [ + 'credits' => (float) (DB::fetch("SELECT COALESCE(SUM(amount),0) AS s FROM wallet_transactions WHERE type = 'credit'")['s'] ?? 0), + 'debits' => (float) (DB::fetch("SELECT COALESCE(SUM(amount),0) AS s FROM wallet_transactions WHERE type = 'debit'")['s'] ?? 0), + 'orders' => (int) (DB::fetch('SELECT COUNT(*) AS c FROM orders')['c'] ?? 0), + ]; + $recent = DB::fetchAll("SELECT DATE(created_at) d, SUM(CASE WHEN type='credit' THEN amount ELSE 0 END) credits, SUM(CASE WHEN type='debit' THEN amount ELSE 0 END) debits FROM wallet_transactions GROUP BY DATE(created_at) ORDER BY d DESC LIMIT 14"); + $this->render('admin/reports/index', [ + 'title' => 'Reports', + 'totals' => $totals, + 'recent' => $recent, + ], 'admin'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/ServiceCategoriesController.php b/app/Controllers/Admin/ServiceCategoriesController.php new file mode 100644 index 0000000..f6fbdd7 --- /dev/null +++ b/app/Controllers/Admin/ServiceCategoriesController.php @@ -0,0 +1,72 @@ +requireRole(['admin', 'super_admin']); + $cats = DB::fetchAll('SELECT * FROM service_categories ORDER BY sort_order, name'); + $this->render('admin/categories/index', ['title' => 'Service Categories', 'cats' => $cats], 'admin'); + } + + public function createForm(): void + { + $this->requireRole(['admin', 'super_admin']); + $this->render('admin/categories/create', ['title' => 'Create Category'], 'admin'); + } + + public function create(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $name = trim($_POST['name'] ?? ''); + $sort = (int)($_POST['sort_order'] ?? 0); + $status = (int)($_POST['status'] ?? 1); + if ($name !== '') { + DB::insert('INSERT INTO service_categories (name, status, sort_order, created_at, updated_at) VALUES (:n,:s,:o,NOW(),NOW())', [ + 'n' => $name, 's' => $status, 'o' => $sort, + ]); + } + $this->redirect('/admin/services'); + } + + public function editForm(): void + { + $this->requireRole(['admin', 'super_admin']); + $id = (int)($_GET['id'] ?? 0); + $cat = DB::fetch('SELECT * FROM service_categories WHERE id = :id', ['id' => $id]); + $this->render('admin/categories/edit', ['title' => 'Edit Category', 'cat' => $cat], 'admin'); + } + + public function update(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $id = (int)($_POST['id'] ?? 0); + $name = trim($_POST['name'] ?? ''); + $sort = (int)($_POST['sort_order'] ?? 0); + $status = (int)($_POST['status'] ?? 1); + if ($id > 0 && $name !== '') { + DB::query('UPDATE service_categories SET name = :n, status = :s, sort_order = :o, updated_at = NOW() WHERE id = :id', [ + 'n' => $name, 's' => $status, 'o' => $sort, 'id' => $id, + ]); + } + $this->redirect('/admin/services'); + } + + public function delete(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + DB::query('DELETE FROM service_categories WHERE id = :id', ['id' => $id]); + } + $this->redirect('/admin/services'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/ServicesController.php b/app/Controllers/Admin/ServicesController.php new file mode 100644 index 0000000..29098fd --- /dev/null +++ b/app/Controllers/Admin/ServicesController.php @@ -0,0 +1,61 @@ +requireRole(['admin', 'super_admin']); + $categories = DB::fetchAll('SELECT * FROM service_categories ORDER BY sort_order, name'); + $services = DB::fetchAll('SELECT s.*, c.name AS category_name FROM services s JOIN service_categories c ON c.id = s.category_id ORDER BY c.sort_order, s.name'); + $this->render('admin/services/index', [ + 'title' => 'Services', + 'categories' => $categories, + 'services' => $services, + ], 'admin'); + } + + public function mapForm(): void + { + $this->requireRole(['admin', 'super_admin']); + $apis = DB::fetchAll('SELECT * FROM apis WHERE status = 1 ORDER BY name'); + $apiServices = DB::fetchAll('SELECT * FROM api_services ORDER BY api_id, name'); + $services = DB::fetchAll('SELECT id, name FROM services ORDER BY name'); + $this->render('admin/services/map', [ + 'title' => 'Map API Services', + 'apis' => $apis, + 'apiServices' => $apiServices, + 'services' => $services, + ], 'admin'); + } + + public function mapSave(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $pairs = $_POST['map'] ?? []; + foreach ($pairs as $apiServiceId => $serviceId) { + $apiServiceId = (int)$apiServiceId; $serviceId = (int)$serviceId; + if ($apiServiceId > 0 && $serviceId > 0) { + DB::query('UPDATE services SET api_service_id = :asid WHERE id = :sid', ['asid' => $apiServiceId, 'sid' => $serviceId]); + } + } + $this->redirect('/admin/services'); + } + + public function syncPrices(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + // For each mapped service, pull price from api_services + $mapped = DB::fetchAll('SELECT s.id, a.price FROM services s JOIN api_services a ON a.id = s.api_service_id'); + foreach ($mapped as $m) { + DB::query('UPDATE services SET price = :p WHERE id = :id', ['p' => $m['price'], 'id' => $m['id']]); + } + $this->redirect('/admin/services'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/ServicesCrudController.php b/app/Controllers/Admin/ServicesCrudController.php new file mode 100644 index 0000000..d67d022 --- /dev/null +++ b/app/Controllers/Admin/ServicesCrudController.php @@ -0,0 +1,71 @@ +requireRole(['admin', 'super_admin']); + $cats = DB::fetchAll('SELECT id,name FROM service_categories ORDER BY name'); + $this->render('admin/services/create', ['title' => 'Create Service', 'cats' => $cats], 'admin'); + } + + public function create(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $name = trim($_POST['name'] ?? ''); + $cat = (int)($_POST['category_id'] ?? 0); + $price = (float)($_POST['price'] ?? 0); + $desc = trim($_POST['description'] ?? ''); + $status = (int)($_POST['status'] ?? 1); + if ($name && $cat) { + DB::insert('INSERT INTO services (category_id, name, description, price, status, created_at, updated_at) VALUES (:c,:n,:d,:p,:s,NOW(),NOW())', [ + 'c' => $cat, 'n' => $name, 'd' => $desc, 'p' => $price, 's' => $status, + ]); + } + $this->redirect('/admin/services'); + } + + public function editForm(): void + { + $this->requireRole(['admin', 'super_admin']); + $id = (int)($_GET['id'] ?? 0); + $service = DB::fetch('SELECT * FROM services WHERE id = :id', ['id' => $id]); + $cats = DB::fetchAll('SELECT id,name FROM service_categories ORDER BY name'); + $this->render('admin/services/edit', ['title' => 'Edit Service', 'service' => $service, 'cats' => $cats], 'admin'); + } + + public function update(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $id = (int)($_POST['id'] ?? 0); + $name = trim($_POST['name'] ?? ''); + $cat = (int)($_POST['category_id'] ?? 0); + $price = (float)($_POST['price'] ?? 0); + $desc = trim($_POST['description'] ?? ''); + $status = (int)($_POST['status'] ?? 1); + if ($id > 0 && $name && $cat) { + DB::query('UPDATE services SET category_id=:c, name=:n, description=:d, price=:p, status=:s, updated_at=NOW() WHERE id=:id', [ + 'c' => $cat, 'n' => $name, 'd' => $desc, 'p' => $price, 's' => $status, 'id' => $id, + ]); + } + $this->redirect('/admin/services'); + } + + public function delete(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + DB::query('DELETE FROM services WHERE id = :id', ['id' => $id]); + } + $this->redirect('/admin/services'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/SettingsController.php b/app/Controllers/Admin/SettingsController.php new file mode 100644 index 0000000..d0f901f --- /dev/null +++ b/app/Controllers/Admin/SettingsController.php @@ -0,0 +1,47 @@ +requireRole(['admin', 'super_admin']); + $settings = DB::fetchAll('SELECT `key`,`value` FROM settings ORDER BY `key`'); + $this->render('admin/settings/index', [ + 'title' => 'Settings', + 'settings' => $settings, + 'min_balance' => Settings::get('min_balance', '0'), + 'dhru_services_path' => Settings::get('dhru_services_path', '/services'), + 'dhru_place_order_path' => Settings::get('dhru_place_order_path', '/orders'), + 'dhru_order_status_path' => Settings::get('dhru_order_status_path', '/orders/{id}'), + 'dhru_services_list_key' => Settings::get('dhru_services_list_key', 'data'), + 'dhru_req_service_key' => Settings::get('dhru_req_service_key', 'service_id'), + 'dhru_req_input_key' => Settings::get('dhru_req_input_key', 'input'), + 'dhru_res_order_id_key' => Settings::get('dhru_res_order_id_key', 'order_id'), + 'dhru_res_status_key' => Settings::get('dhru_res_status_key', 'status'), + 'dhru_res_result_key' => Settings::get('dhru_res_result_key', 'result'), + ], 'admin'); + } + + public function save(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + Settings::set('min_balance', (string)($_POST['min_balance'] ?? '0')); + Settings::set('dhru_services_path', trim((string)($_POST['dhru_services_path'] ?? '/services'))); + Settings::set('dhru_place_order_path', trim((string)($_POST['dhru_place_order_path'] ?? '/orders'))); + Settings::set('dhru_order_status_path', trim((string)($_POST['dhru_order_status_path'] ?? '/orders/{id}'))); + Settings::set('dhru_services_list_key', trim((string)($_POST['dhru_services_list_key'] ?? 'data'))); + Settings::set('dhru_req_service_key', trim((string)($_POST['dhru_req_service_key'] ?? 'service_id'))); + Settings::set('dhru_req_input_key', trim((string)($_POST['dhru_req_input_key'] ?? 'input'))); + Settings::set('dhru_res_order_id_key', trim((string)($_POST['dhru_res_order_id_key'] ?? 'order_id'))); + Settings::set('dhru_res_status_key', trim((string)($_POST['dhru_res_status_key'] ?? 'status'))); + Settings::set('dhru_res_result_key', trim((string)($_POST['dhru_res_result_key'] ?? 'result'))); + $this->redirect('/admin/settings'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/UsersController.php b/app/Controllers/Admin/UsersController.php new file mode 100644 index 0000000..f1ef6bd --- /dev/null +++ b/app/Controllers/Admin/UsersController.php @@ -0,0 +1,71 @@ +requireRole(['admin', 'super_admin']); + $q = trim((string)($_GET['q'] ?? '')); + $where = '1=1'; + $params = []; + if ($q !== '') { + $where .= ' AND (email LIKE :q OR name LIKE :q)'; + $params['q'] = '%' . $q . '%'; + } + $total = (int)(DB::fetch('SELECT COUNT(*) c FROM users WHERE ' . $where, $params)['c'] ?? 0); + $pg = Pagination::resolve($total, 25); + $users = DB::fetchAll('SELECT id, name, email, role, status, wallet_balance, price_markup_percent, subscription_expires_at, created_at FROM users WHERE ' . $where . ' ORDER BY id DESC LIMIT :lim OFFSET :off', array_merge($params, ['lim' => $pg['perPage'], 'off' => $pg['offset']])); + $this->render('admin/users/index', [ + 'title' => 'Users', + 'users' => $users, + 'q' => $q, + 'page' => $pg['page'], + 'pages' => $pg['pages'], + ], 'admin'); + } + + public function subscriptionForm(): void + { + $this->requireRole(['admin', 'super_admin']); + $this->render('admin/users/subscription', ['title' => 'User Subscription'], 'admin'); + } + + public function updateSubscription(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $userId = (int)($_POST['user_id'] ?? 0); + $months = (int)($_POST['months'] ?? 0); + if ($userId > 0 && in_array($months, [3,6,12], true)) { + DB::query("UPDATE users SET subscription_expires_at = DATE_FORMAT(GREATEST(COALESCE(subscription_expires_at, CURDATE()), CURDATE()), '%Y-%m-%d') + INTERVAL :m MONTH, status = 1 WHERE id = :id", ['m' => $months, 'id' => $userId]); + } + $this->redirect('/admin/users'); + } + + public function editForm(): void + { + $this->requireRole(['admin', 'super_admin']); + $id = (int)($_GET['id'] ?? 0); + $user = DB::fetch('SELECT id, name, email, role, status, wallet_balance, price_markup_percent, subscription_expires_at FROM users WHERE id = :id', ['id' => $id]); + $this->render('admin/users/edit', ['title' => 'Edit User', 'user' => $user], 'admin'); + } + + public function update(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $id = (int)($_POST['id'] ?? 0); + $markup = (float)($_POST['price_markup_percent'] ?? 0); + $status = (int)($_POST['status'] ?? 1); + if ($id > 0) { + DB::query('UPDATE users SET price_markup_percent = :m, status = :s WHERE id = :id', ['m' => $markup, 's' => $status, 'id' => $id]); + } + $this->redirect('/admin/users'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/WalletController.php b/app/Controllers/Admin/WalletController.php new file mode 100644 index 0000000..7065d78 --- /dev/null +++ b/app/Controllers/Admin/WalletController.php @@ -0,0 +1,41 @@ +requireRole(['admin', 'super_admin']); + $txs = DB::fetchAll('SELECT w.*, u.email FROM wallet_transactions w JOIN users u ON u.id = w.user_id ORDER BY w.id DESC LIMIT 200'); + $this->render('admin/wallet/index', [ + 'title' => 'Wallet', + 'txs' => $txs, + ], 'admin'); + } + + public function adjustForm(): void + { + $this->requireRole(['admin', 'super_admin']); + $this->render('admin/wallet/adjust', ['title' => 'Adjust Wallet'], 'admin'); + } + + public function adjust(): void + { + $this->requireRole(['admin', 'super_admin']); + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $userId = (int)($_POST['user_id'] ?? 0); + $amount = (float)($_POST['amount'] ?? 0); + $type = $amount >= 0 ? 'credit' : 'debit'; + if ($userId > 0 && $amount != 0.0) { + DB::query('UPDATE users SET wallet_balance = wallet_balance + :amt WHERE id = :id', ['amt' => $amount, 'id' => $userId]); + DB::insert('INSERT INTO wallet_transactions (user_id, type, method, amount, reference, created_at) VALUES (:id,:t,\'manual\',:amt,:ref,NOW())', [ + 'id' => $userId, 't' => $type, 'amt' => abs($amount), 'ref' => 'ADMIN', + ]); + } + $this->redirect('/admin/wallet'); + } +} \ No newline at end of file diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..b93af09 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,119 @@ +redirect('/dashboard'); + } + $this->render('auth/login', ['title' => 'Login'], 'auth'); + } + + public function login(): void + { + if (!CSRF::validate($_POST['_token'] ?? '')) { + http_response_code(419); + echo 'Invalid CSRF token'; + return; + } + + $email = trim($_POST['email'] ?? ''); + $password = (string)($_POST['password'] ?? ''); + $totp = trim($_POST['totp'] ?? ''); + + $user = DB::fetch('SELECT * FROM users WHERE email = :email LIMIT 1', ['email' => $email]); + if (!$user || !password_verify($password, $user['password_hash'])) { + $this->render('auth/login', ['error' => 'Invalid credentials'], 'auth'); + return; + } + if ((int)$user['status'] !== 1) { + $this->render('auth/login', ['error' => 'Account is inactive or expired'], 'auth'); + return; + } + if (!empty($user['two_factor_secret'])) { + if ($totp === '' || !TOTP::verifyCode($user['two_factor_secret'], $totp)) { + $this->render('auth/login', ['error' => 'Invalid 2FA code'], 'auth'); + return; + } + } + + Auth::login($user); + if (in_array($user['role'], ['admin', 'super_admin'], true)) { + $this->redirect('/admin'); + } else { + $this->redirect('/dashboard'); + } + } + + public function logout(): void + { + Auth::logout(); + $this->redirect('/login'); + } + + public function forgotForm(): void + { + $this->render('auth/forgot', ['title' => 'Forgot Password'], 'auth'); + } + + public function sendReset(): void + { + if (!CSRF::validate($_POST['_token'] ?? '')) { + http_response_code(419); + echo 'Invalid CSRF token'; + return; + } + $email = trim($_POST['email'] ?? ''); + $user = DB::fetch('SELECT * FROM users WHERE email = :email LIMIT 1', ['email' => $email]); + if ($user) { + $token = bin2hex(random_bytes(32)); + $expires = date('Y-m-d H:i:s', time() + 3600); + DB::query('UPDATE users SET reset_token = :t, reset_expires_at = :e WHERE id = :id', [ + 't' => $token, + 'e' => $expires, + 'id' => $user['id'], + ]); + $link = (defined('APP_URL') ? rtrim(APP_URL, '/') : '') . '/reset?token=' . urlencode($token); + $html = '

Click the link below to reset your password:

Reset Password

'; + Mailer::send($email, SITE_NAME . ' - Password Reset', $html); + } + $this->render('auth/forgot', ['success' => 'If the email exists, a reset link has been sent.'], 'auth'); + } + + public function resetForm(): void + { + $token = (string)($_GET['token'] ?? ''); + $this->render('auth/reset', ['token' => $token], 'auth'); + } + + public function resetPassword(): void + { + if (!CSRF::validate($_POST['_token'] ?? '')) { + http_response_code(419); + echo 'Invalid CSRF token'; + return; + } + $token = (string)($_POST['token'] ?? ''); + $password = (string)($_POST['password'] ?? ''); + $user = DB::fetch('SELECT * FROM users WHERE reset_token = :t AND reset_expires_at >= NOW() LIMIT 1', ['t' => $token]); + if (!$user) { + $this->render('auth/reset', ['error' => 'Invalid or expired token', 'token' => $token], 'auth'); + return; + } + DB::query('UPDATE users SET password_hash = :p, reset_token = NULL, reset_expires_at = NULL WHERE id = :id', [ + 'p' => password_hash($password, PASSWORD_DEFAULT), + 'id' => $user['id'], + ]); + $this->render('auth/login', ['success' => 'Password has been reset. Please login.'], 'auth'); + } +} \ No newline at end of file diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php new file mode 100644 index 0000000..abc37b8 --- /dev/null +++ b/app/Controllers/HomeController.php @@ -0,0 +1,14 @@ +render('home/index', [ + 'title' => SITE_NAME, + ], 'public'); + } +} \ No newline at end of file diff --git a/app/Controllers/User/DashboardController.php b/app/Controllers/User/DashboardController.php new file mode 100644 index 0000000..9bdf25e --- /dev/null +++ b/app/Controllers/User/DashboardController.php @@ -0,0 +1,25 @@ +redirect('/login'); + } + $userId = Auth::id(); + $user = DB::fetch('SELECT * FROM users WHERE id = :id', ['id' => $userId]); + $ordersCount = (int) (DB::fetch('SELECT COUNT(*) AS c FROM orders WHERE user_id = :id', ['id' => $userId])['c'] ?? 0); + + $this->render('user/dashboard', [ + 'title' => 'Dashboard', + 'user' => $user, + 'ordersCount' => $ordersCount, + ], 'user'); + } +} \ No newline at end of file diff --git a/app/Controllers/User/NotificationsController.php b/app/Controllers/User/NotificationsController.php new file mode 100644 index 0000000..13f601e --- /dev/null +++ b/app/Controllers/User/NotificationsController.php @@ -0,0 +1,19 @@ +redirect('/login'); } + $items = DB::fetchAll('SELECT * FROM notifications WHERE user_id = :id OR user_id IS NULL ORDER BY id DESC LIMIT 100', ['id' => Auth::id()]); + $this->render('user/notifications/index', [ + 'title' => 'Notifications', + 'items' => $items, + ], 'user'); + } +} \ No newline at end of file diff --git a/app/Controllers/User/OrdersController.php b/app/Controllers/User/OrdersController.php new file mode 100644 index 0000000..7734cdd --- /dev/null +++ b/app/Controllers/User/OrdersController.php @@ -0,0 +1,25 @@ +redirect('/login'); } + $userId = Auth::id(); + $total = (int)(DB::fetch('SELECT COUNT(*) c FROM orders WHERE user_id = :uid', ['uid' => $userId])['c'] ?? 0); + $pg = Pagination::resolve($total, 20); + $orders = DB::fetchAll('SELECT o.*, s.name AS service_name FROM orders o JOIN services s ON s.id = o.service_id WHERE o.user_id = :uid ORDER BY o.id DESC LIMIT :lim OFFSET :off', ['uid' => $userId, 'lim' => $pg['perPage'], 'off' => $pg['offset']]); + $this->render('user/orders/index', [ + 'title' => 'My Orders', + 'orders' => $orders, + 'page' => $pg['page'], + 'pages' => $pg['pages'], + ], 'user'); + } +} \ No newline at end of file diff --git a/app/Controllers/User/PlaceOrderController.php b/app/Controllers/User/PlaceOrderController.php new file mode 100644 index 0000000..5afb73c --- /dev/null +++ b/app/Controllers/User/PlaceOrderController.php @@ -0,0 +1,70 @@ +redirect('/login'); } + $user = DB::fetch('SELECT price_markup_percent FROM users WHERE id = :id', ['id' => Auth::id()]); + $markup = (float)($user['price_markup_percent'] ?? 0); + $services = DB::fetchAll('SELECT s.*, c.name AS category_name FROM services s JOIN service_categories c ON c.id = s.category_id WHERE s.status = 1 ORDER BY c.sort_order, s.name'); + foreach ($services as &$s) { + $s['final_price'] = round((float)$s['price'] * (1 + $markup / 100), 2); + } + $this->render('user/orders/place.php', [ + 'title' => 'Place Order', + 'services' => $services, + ], 'user'); + } + + public function submit(): void + { + if (!Auth::check()) { $this->redirect('/login'); } + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $serviceId = (int)($_POST['service_id'] ?? 0); + $input = trim($_POST['input'] ?? ''); + $service = DB::fetch('SELECT * FROM services WHERE id = :id AND status = 1', ['id' => $serviceId]); + if (!$service) { $this->redirect('/place-order'); } + $user = DB::fetch('SELECT wallet_balance, price_markup_percent FROM users WHERE id = :id FOR UPDATE', ['id' => Auth::id()]); + $price = (float)$service['price'] * (1 + (float)$user['price_markup_percent'] / 100); + $price = round($price, 2); + $minBalance = (float) \App\Core\Settings::get('min_balance', '0'); + if ((float)$user['wallet_balance'] < max($minBalance, $price)) { + $this->render('user/orders/place.php', ['error' => 'Insufficient balance (minimum required: $' . number_format(max($minBalance,$price),2) . ')'], 'user'); + return; + } + $pdo = DB::pdo(); + $pdo->beginTransaction(); + try { + $wallet = (float)$user['wallet_balance']; + if ($wallet < $price) { + $pdo->rollBack(); + $this->render('user/orders/place.php', ['error' => 'Insufficient balance'], 'user'); + return; + } + DB::query('UPDATE users SET wallet_balance = wallet_balance - :amt WHERE id = :id', ['amt' => $price, 'id' => Auth::id()]); + DB::insert('INSERT INTO wallet_transactions (user_id, type, method, amount, reference, created_at) VALUES (:uid,\'debit\',:m,:amt,:ref,NOW())', [ + 'uid' => Auth::id(), 'm' => 'order', 'amt' => $price, 'ref' => 'ORDER', + ]); + $orderId = DB::insert('INSERT INTO orders (user_id, service_id, status, input_data, price, created_at, updated_at) VALUES (:uid,:sid,\'pending\',:inp,:price,NOW(),NOW())', [ + 'uid' => Auth::id(), 'sid' => $serviceId, 'inp' => json_encode(['input' => $input]), 'price' => $price, + ]); + // Attempt API submission (non-blocking best-effort) + try { \App\Services\OrderProcessor::submitToApi($orderId); } catch (\Throwable $e) { /* ignore */ } + $pdo->commit(); + \App\Core\Notifier::notify(Auth::id(), 'Order Placed', 'Your order #' . $orderId . ' has been created.'); + $this->redirect('/orders'); + } catch (\Throwable $e) { + if ($pdo->inTransaction()) { $pdo->rollBack(); } + http_response_code(500); + echo View::e($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/Controllers/User/ProfileController.php b/app/Controllers/User/ProfileController.php new file mode 100644 index 0000000..87dbd74 --- /dev/null +++ b/app/Controllers/User/ProfileController.php @@ -0,0 +1,20 @@ +redirect('/login'); } + $user = DB::fetch('SELECT id, name, email FROM users WHERE id = :id', ['id' => Auth::id()]); + $this->render('user/profile/index', [ + 'title' => 'Profile', + 'user' => $user, + ], 'user'); + } +} \ No newline at end of file diff --git a/app/Controllers/User/ServicesController.php b/app/Controllers/User/ServicesController.php new file mode 100644 index 0000000..840a14e --- /dev/null +++ b/app/Controllers/User/ServicesController.php @@ -0,0 +1,20 @@ +redirect('/login'); } + $services = DB::fetchAll('SELECT s.*, c.name AS category_name FROM services s JOIN service_categories c ON c.id = s.category_id WHERE s.status = 1 ORDER BY c.sort_order, s.name'); + $this->render('user/services/index', [ + 'title' => 'Services', + 'services' => $services, + ], 'user'); + } +} \ No newline at end of file diff --git a/app/Controllers/User/SubscriptionsController.php b/app/Controllers/User/SubscriptionsController.php new file mode 100644 index 0000000..ab7b4a9 --- /dev/null +++ b/app/Controllers/User/SubscriptionsController.php @@ -0,0 +1,19 @@ +redirect('/login'); } + $user = DB::fetch('SELECT subscription_expires_at FROM users WHERE id = :id', ['id' => Auth::id()]); + $this->render('user/subscriptions/index', [ + 'title' => 'Subscriptions', + 'expires' => $user['subscription_expires_at'] ?? null, + ], 'user'); + } +} \ No newline at end of file diff --git a/app/Controllers/User/SupportController.php b/app/Controllers/User/SupportController.php new file mode 100644 index 0000000..39c9cf6 --- /dev/null +++ b/app/Controllers/User/SupportController.php @@ -0,0 +1,34 @@ +redirect('/login'); } + $this->render('user/support/index', [ + 'title' => 'Support', + ], 'user'); + } + + public function send(): void + { + if (!Auth::check()) { $this->redirect('/login'); } + if (!CSRF::validate($_POST['_token'] ?? '')) { http_response_code(419); echo 'Invalid token'; return; } + $subject = trim($_POST['subject'] ?? ''); + $message = trim($_POST['message'] ?? ''); + if ($subject && $message) { + $payload = "Support message\nSubject: {$subject}\nFrom: " . (Auth::user()['email'] ?? 'N/A') . "\n\n{$message}"; + Telegram::send($payload); + } + $this->render('user/support/index', [ + 'title' => 'Support', + 'success' => 'Your message has been sent.', + ], 'user'); + } +} \ No newline at end of file diff --git a/app/Controllers/User/WalletController.php b/app/Controllers/User/WalletController.php new file mode 100644 index 0000000..e37ee1c --- /dev/null +++ b/app/Controllers/User/WalletController.php @@ -0,0 +1,68 @@ +redirect('/login'); } + $user = DB::fetch('SELECT wallet_balance FROM users WHERE id = :id', ['id' => Auth::id()]); + $txs = DB::fetchAll('SELECT * FROM wallet_transactions WHERE user_id = :id ORDER BY id DESC LIMIT 100', ['id' => Auth::id()]); + $this->render('user/wallet/index', [ + 'title' => 'Wallet', + 'balance' => (float)($user['wallet_balance'] ?? 0), + 'txs' => $txs, + ], 'user'); + } + + public function addFunds(): void + { + if (!Auth::check()) { $this->redirect('/login'); } + if (!CSRF::validate($_POST['_token'] ?? '')) { + http_response_code(419); echo 'Invalid token'; return; + } + $amount = (float)($_POST['amount'] ?? 0); + $method = (string)($_POST['method'] ?? 'paypal'); + if ($amount <= 0) { $this->redirect('/wallet'); } + $gateway = match ($method) { + 'bkash' => new BkashGateway(), + 'nagad' => new NagadGateway(), + 'rocket' => new RocketGateway(), + 'binance' => new BinancePayGateway(), + default => new PaypalGateway(), + }; + $res = $gateway->createPayment(Auth::id(), $amount, []); + if (($res['status'] ?? '') === 'redirect') { + header('Location: ' . $res['redirect_url']); + exit; + } + $this->redirect('/wallet'); + } + + public function callback(): void + { + // Generic callback handler (stub) + if (!Auth::check()) { $this->redirect('/login'); } + $status = (string)($_GET['status'] ?? 'success'); + $amount = (float)($_GET['amount'] ?? 0); + if ($status === 'success' && $amount > 0) { + DB::query('UPDATE users SET wallet_balance = wallet_balance + :amt WHERE id = :id', ['amt' => $amount, 'id' => Auth::id()]); + DB::insert('INSERT INTO wallet_transactions (user_id, type, method, amount, reference, created_at) VALUES (:uid,\'credit\',:m,:amt,:ref,NOW())', [ + 'uid' => Auth::id(), 'm' => 'gateway', 'amt' => $amount, 'ref' => 'PG', + ]); + Notifier::notify(Auth::id(), 'Wallet Recharged', 'Your wallet has been credited by $' . number_format($amount, 2)); + } + $this->redirect('/wallet'); + } +} \ No newline at end of file diff --git a/app/Core/App.php b/app/Core/App.php new file mode 100644 index 0000000..f81641c --- /dev/null +++ b/app/Core/App.php @@ -0,0 +1,27 @@ +router = $router; + } + + public function run(): void + { + try { + $this->router->dispatch($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/'); + } catch (\Throwable $e) { + if (defined('APP_DEBUG') && APP_DEBUG) { + http_response_code(500); + echo '
' . htmlspecialchars($e->getMessage() . "\n" . $e->getTraceAsString()) . '
'; + } else { + http_response_code(500); + echo 'An unexpected error occurred.'; + } + } + } +} \ No newline at end of file diff --git a/app/Core/Auth.php b/app/Core/Auth.php new file mode 100644 index 0000000..2a75ee9 --- /dev/null +++ b/app/Core/Auth.php @@ -0,0 +1,46 @@ + $user['id'], + 'email' => $user['email'], + 'name' => $user['name'] ?? '', + 'role' => $user['role'], + ]; + session_regenerate_id(true); + } + + public static function logout(): void + { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']); + } + session_destroy(); + } + + public static function check(): bool + { + return isset($_SESSION['user']); + } + + public static function user(): ?array + { + return $_SESSION['user'] ?? null; + } + + public static function id(): ?int + { + return self::user()['id'] ?? null; + } + + public static function isRole(string $role): bool + { + return self::user()['role'] === $role; + } +} \ No newline at end of file diff --git a/app/Core/CSRF.php b/app/Core/CSRF.php new file mode 100644 index 0000000..dcdd8cb --- /dev/null +++ b/app/Core/CSRF.php @@ -0,0 +1,23 @@ +'; + } + + public static function validate(?string $token): bool + { + return hash_equals($_SESSION[CSRF_TOKEN_KEY] ?? '', (string)$token); + } +} \ No newline at end of file diff --git a/app/Core/Controller.php b/app/Core/Controller.php new file mode 100644 index 0000000..6c21771 --- /dev/null +++ b/app/Core/Controller.php @@ -0,0 +1,35 @@ +view = new View(); + } + + protected function render(string $view, array $data = [], string $layout = 'user'): void + { + $this->view->render($view, $data, $layout); + } + + protected function redirect(string $path): void + { + header('Location: ' . $path); + exit; + } + + protected function requireRole(array $roles): void + { + if (!Auth::check()) { + $this->redirect('/login'); + } + if (!in_array(Auth::user()['role'] ?? '', $roles, true)) { + http_response_code(403); + echo 'Forbidden'; + exit; + } + } +} \ No newline at end of file diff --git a/app/Core/DB.php b/app/Core/DB.php new file mode 100644 index 0000000..cf9f43f --- /dev/null +++ b/app/Core/DB.php @@ -0,0 +1,61 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + try { + self::$pdo = new PDO($dsn, $config['username'], $config['password'], $options); + } catch (PDOException $e) { + throw new \RuntimeException('Database connection failed: ' . $e->getMessage()); + } + } + + public static function pdo(): PDO + { + if (self::$pdo === null) { + throw new \RuntimeException('DB::init must be called before using the database'); + } + return self::$pdo; + } + + public static function query(string $sql, array $params = []): \PDOStatement + { + $stmt = self::pdo()->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + public static function fetch(string $sql, array $params = []): ?array + { + $stmt = self::query($sql, $params); + $row = $stmt->fetch(); + return $row === false ? null : $row; + } + + public static function fetchAll(string $sql, array $params = []): array + { + return self::query($sql, $params)->fetchAll(); + } + + public static function insert(string $sql, array $params = []): int + { + self::query($sql, $params); + return (int) self::pdo()->lastInsertId(); + } +} \ No newline at end of file diff --git a/app/Core/Helpers.php b/app/Core/Helpers.php new file mode 100644 index 0000000..2f78751 --- /dev/null +++ b/app/Core/Helpers.php @@ -0,0 +1,23 @@ +isSMTP(); + $mail->Host = MAIL_HOST; + $mail->Port = MAIL_PORT; + $mail->SMTPAuth = true; + $mail->Username = MAIL_USERNAME; + $mail->Password = MAIL_PASSWORD; + if (MAIL_ENCRYPTION) { + $mail->SMTPSecure = MAIL_ENCRYPTION; + } + } + $mail->setFrom(MAIL_FROM_ADDRESS, MAIL_FROM_NAME); + $mail->addAddress($to); + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlBody; + $mail->AltBody = $textBody ?? strip_tags($htmlBody); + return $mail->send(); + } catch (\Throwable $e) { + return false; + } + } + + // Fallback to PHP mail() + $headers = []; + $headers[] = 'MIME-Version: 1.0'; + $headers[] = 'Content-type: text/html; charset=UTF-8'; + $headers[] = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM_ADDRESS . '>'; + return mail($to, $subject, $htmlBody, implode("\r\n", $headers)); + } +} \ No newline at end of file diff --git a/app/Core/Notifier.php b/app/Core/Notifier.php new file mode 100644 index 0000000..212c9ad --- /dev/null +++ b/app/Core/Notifier.php @@ -0,0 +1,22 @@ + $userId, + 't' => $title, + 'm' => $message, + ]); + } + + public static function adminAlert(string $message): void + { + Telegram::send($message); + if (defined('DEFAULT_ADMIN_EMAIL') && DEFAULT_ADMIN_EMAIL) { + Mailer::send(DEFAULT_ADMIN_EMAIL, SITE_NAME . ' - Alert', nl2br(htmlentities($message))); + } + } +} \ No newline at end of file diff --git a/app/Core/Pagination.php b/app/Core/Pagination.php new file mode 100644 index 0000000..462e40d --- /dev/null +++ b/app/Core/Pagination.php @@ -0,0 +1,38 @@ + $pages) { $page = $pages; } + $offset = ($page - 1) * $perPage; + return ['page' => $page, 'pages' => $pages, 'perPage' => $perPage, 'offset' => $offset]; + } + + public static function render(int $current, int $pages): string + { + if ($pages <= 1) { return ''; } + $qs = $_GET; + $html = ''; + return $html; + } +} \ No newline at end of file diff --git a/app/Core/Router.php b/app/Core/Router.php new file mode 100644 index 0000000..4b8f716 --- /dev/null +++ b/app/Core/Router.php @@ -0,0 +1,65 @@ + [], + 'POST' => [], + ]; + + public function get(string $path, $handler): void + { + $this->routes['GET'][$this->normalize($path)] = $handler; + } + + public function post(string $path, $handler): void + { + $this->routes['POST'][$this->normalize($path)] = $handler; + } + + public function dispatch(string $method, string $path): void + { + $method = strtoupper($method); + $path = $this->normalize($path); + + $handler = $this->routes[$method][$path] ?? null; + if ($handler === null) { + http_response_code(404); + echo '404 Not Found'; + return; + } + + if (is_callable($handler)) { + call_user_func($handler); + return; + } + + if (is_string($handler) && str_contains($handler, '@')) { + [$controller, $action] = explode('@', $handler, 2); + $controllerClass = 'App\\Controllers\\' . $controller; + if (!class_exists($controllerClass)) { + throw new \RuntimeException("Controller {$controllerClass} not found"); + } + $instance = new $controllerClass(); + if (!method_exists($instance, $action)) { + throw new \RuntimeException("Action {$action} not found in {$controllerClass}"); + } + $instance->$action(); + return; + } + + throw new \RuntimeException('Invalid route handler'); + } + + private function normalize(string $path): string + { + if ($path === '') { + return '/'; + } + if ($path[0] !== '/') { + $path = '/' . $path; + } + return rtrim($path, '/') ?: '/'; + } +} \ No newline at end of file diff --git a/app/Core/Settings.php b/app/Core/Settings.php new file mode 100644 index 0000000..fe7b078 --- /dev/null +++ b/app/Core/Settings.php @@ -0,0 +1,31 @@ + $key]); + if ($row) { + self::$cache[$key] = $row['value']; + return $row['value']; + } + return $default; + } + + public static function set(string $key, string $value): void + { + $exists = DB::fetch('SELECT id FROM settings WHERE `key` = :k LIMIT 1', ['k' => $key]); + if ($exists) { + DB::query('UPDATE settings SET `value` = :v WHERE `key` = :k', ['v' => $value, 'k' => $key]); + } else { + DB::insert('INSERT INTO settings (`key`,`value`) VALUES (:k,:v)', ['k' => $key, 'v' => $value]); + } + self::$cache[$key] = $value; + } +} \ No newline at end of file diff --git a/app/Core/TOTP.php b/app/Core/TOTP.php new file mode 100644 index 0000000..80d4595 --- /dev/null +++ b/app/Core/TOTP.php @@ -0,0 +1,58 @@ += 8) { + $bitsLeft -= 8; + $output .= chr(($buffer & (0xFF << $bitsLeft)) >> $bitsLeft); + } + } + return $output; + } +} \ No newline at end of file diff --git a/app/Core/Telegram.php b/app/Core/Telegram.php new file mode 100644 index 0000000..2b2298b --- /dev/null +++ b/app/Core/Telegram.php @@ -0,0 +1,30 @@ + TELEGRAM_CHAT_ID, + 'text' => $message, + 'parse_mode' => 'HTML', + 'disable_web_page_preview' => true, + ]; + $options = [ + 'http' => [ + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => http_build_query($data), + 'timeout' => 10, + ], + ]; + $context = stream_context_create($options); + $result = @file_get_contents($url, false, $context); + return $result !== false; + } +} \ No newline at end of file diff --git a/app/Core/View.php b/app/Core/View.php new file mode 100644 index 0000000..1c3d2b3 --- /dev/null +++ b/app/Core/View.php @@ -0,0 +1,29 @@ + 'redirect', + 'redirect_url' => '/wallet?success=1', + 'reference' => 'BP-' . time() . '-' . $userId, + ]; + } + + public function handleCallback(array $requestData): array + { + return [ 'status' => 'success', 'amount' => (float)($requestData['amount'] ?? 0), 'reference' => (string)($requestData['reference'] ?? '') ]; + } +} \ No newline at end of file diff --git a/app/Payments/BkashGateway.php b/app/Payments/BkashGateway.php new file mode 100644 index 0000000..2fce0e9 --- /dev/null +++ b/app/Payments/BkashGateway.php @@ -0,0 +1,23 @@ + 'redirect', + 'redirect_url' => '/wallet?success=1', + 'reference' => 'BK-' . time() . '-' . $userId, + ]; + } + + public function handleCallback(array $requestData): array + { + return [ 'status' => 'success', 'amount' => (float)($requestData['amount'] ?? 0), 'reference' => (string)($requestData['reference'] ?? '') ]; + } +} \ No newline at end of file diff --git a/app/Payments/NagadGateway.php b/app/Payments/NagadGateway.php new file mode 100644 index 0000000..3c47654 --- /dev/null +++ b/app/Payments/NagadGateway.php @@ -0,0 +1,23 @@ + 'redirect', + 'redirect_url' => '/wallet?success=1', + 'reference' => 'NG-' . time() . '-' . $userId, + ]; + } + + public function handleCallback(array $requestData): array + { + return [ 'status' => 'success', 'amount' => (float)($requestData['amount'] ?? 0), 'reference' => (string)($requestData['reference'] ?? '') ]; + } +} \ No newline at end of file diff --git a/app/Payments/PaypalGateway.php b/app/Payments/PaypalGateway.php new file mode 100644 index 0000000..bdcb2d0 --- /dev/null +++ b/app/Payments/PaypalGateway.php @@ -0,0 +1,32 @@ + 'redirect', + 'redirect_url' => '/wallet?success=1', + 'reference' => 'PP-' . time() . '-' . $userId, + ]; + } + + public function handleCallback(array $requestData): array + { + // Placeholder: validate and return result + return [ + 'status' => 'success', + 'amount' => (float)($requestData['amount'] ?? 0), + 'reference' => (string)($requestData['reference'] ?? ''), + ]; + } +} \ No newline at end of file diff --git a/app/Payments/RocketGateway.php b/app/Payments/RocketGateway.php new file mode 100644 index 0000000..bfccfb9 --- /dev/null +++ b/app/Payments/RocketGateway.php @@ -0,0 +1,23 @@ + 'redirect', + 'redirect_url' => '/wallet?success=1', + 'reference' => 'RC-' . time() . '-' . $userId, + ]; + } + + public function handleCallback(array $requestData): array + { + return [ 'status' => 'success', 'amount' => (float)($requestData['amount'] ?? 0), 'reference' => (string)($requestData['reference'] ?? '') ]; + } +} \ No newline at end of file diff --git a/app/Services/ApiClient.php b/app/Services/ApiClient.php new file mode 100644 index 0000000..79d5580 --- /dev/null +++ b/app/Services/ApiClient.php @@ -0,0 +1,77 @@ +baseUrl = rtrim($baseUrl, '/'); + $this->apiKey = $apiKey; + } + + public function get(string $path, array $params = []): array + { + $url = $this->baseUrl . '/' . ltrim($path, '/'); + if ($params) { + $url .= '?' . http_build_query($params); + } + return $this->request('GET', $url); + } + + public function post(string $path, array $data = []): array + { + $url = $this->baseUrl . '/' . ltrim($path, '/'); + return $this->request('POST', $url, $data); + } + + private function request(string $method, string $url, array $data = []): array + { + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + 'Authorization: Bearer ' . $this->apiKey, + ]; + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + } + $res = curl_exec($ch); + if ($res === false) { + $err = curl_error($ch); + curl_close($ch); + throw new \RuntimeException('API request failed: ' . $err); + } + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + $json = json_decode((string)$res, true); + if ($code >= 400) { + throw new \RuntimeException('API error: HTTP ' . $code); + } + return is_array($json) ? $json : []; + } + + public function fetchServices(string $path = '/services'): array + { + return $this->get($path); + } + + public function placeOrder(string $path, string $remoteServiceId, string $input): array + { + return $this->post($path, [ + 'service_id' => $remoteServiceId, + 'input' => $input, + ]); + } + + public function orderStatus(string $pathWithId): array + { + return $this->get($pathWithId); + } +} \ No newline at end of file diff --git a/app/Services/OrderProcessor.php b/app/Services/OrderProcessor.php new file mode 100644 index 0000000..d4fd0a2 --- /dev/null +++ b/app/Services/OrderProcessor.php @@ -0,0 +1,68 @@ + $orderId]); + if (!$order || empty($order['api_service_id']) || empty($order['api_key']) || empty($order['base_url'])) { + return; // Not mapped to API or missing creds + } + $client = new ApiClient($order['base_url'], $order['api_key']); + $inputData = json_decode((string)$order['input_data'], true) ?: []; + $placePath = \App\Core\Settings::get('dhru_place_order_path', '/orders'); + $serviceKey = \App\Core\Settings::get('dhru_req_service_key', 'service_id'); + $inputKey = \App\Core\Settings::get('dhru_req_input_key', 'input'); + // Send request honoring custom keys + $res = $client->post($placePath, [ + $serviceKey => (string)($order['remote_service_id'] ?? ''), + $inputKey => (string)($inputData['input'] ?? ''), + ]); + + $idKey = \App\Core\Settings::get('dhru_res_order_id_key', 'order_id'); + $statusKey = \App\Core\Settings::get('dhru_res_status_key', 'status'); + $remoteId = (string)($res['id'] ?? $res[$idKey] ?? ''); + $remoteStatus = (string)($res[$statusKey] ?? 'processing'); + if ($remoteId) { + DB::query('UPDATE orders SET api_id = :api, api_order_id = :rid, status = :st, updated_at = NOW() WHERE id = :id', [ + 'api' => $order['api_id'], 'rid' => $remoteId, 'st' => self::mapStatus($remoteStatus), 'id' => $orderId, + ]); + } + } + + public static function refreshOrder(int $orderId): void + { + $order = DB::fetch('SELECT o.*, ap.base_url, ap.api_key FROM orders o JOIN apis ap ON ap.id = o.api_id WHERE o.id = :id AND o.api_order_id IS NOT NULL', ['id' => $orderId]); + if (!$order) { return; } + $client = new ApiClient($order['base_url'], $order['api_key']); + $statusPath = \App\Core\Settings::get('dhru_order_status_path', '/orders/{id}'); + $statusPath = str_replace('{id}', urlencode((string)$order['api_order_id']), $statusPath); + $res = $client->orderStatus($statusPath); + $statusKey = \App\Core\Settings::get('dhru_res_status_key', 'status'); + $resultKey = \App\Core\Settings::get('dhru_res_result_key', 'result'); + $remoteStatus = (string)($res[$statusKey] ?? 'processing'); + $result = $res[$resultKey] ?? null; + DB::query('UPDATE orders SET status = :st, result_data = :res, updated_at = NOW() WHERE id = :id', [ + 'st' => self::mapStatus($remoteStatus), + 'res' => $result ? json_encode($result) : null, + 'id' => $orderId, + ]); + } + + public static function mapStatus(string $remote): string + { + $remote = strtolower($remote); + return match ($remote) { + 'queued' => 'pending', + 'in_progress', 'processing' => 'processing', + 'done', 'completed', 'success' => 'completed', + 'partial' => 'partial', + 'canceled', 'cancelled' => 'cancelled', + 'failed', 'error' => 'failed', + default => 'processing', + }; + } +} \ No newline at end of file diff --git a/app/Views/admin/apis/create.php b/app/Views/admin/apis/create.php new file mode 100644 index 0000000..0c3954c --- /dev/null +++ b/app/Views/admin/apis/create.php @@ -0,0 +1,28 @@ + +

Add API

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
\ No newline at end of file diff --git a/app/Views/admin/apis/index.php b/app/Views/admin/apis/index.php new file mode 100644 index 0000000..0e72995 --- /dev/null +++ b/app/Views/admin/apis/index.php @@ -0,0 +1,28 @@ + +

API Management

+
+ + + + + + + + + + + + + + + + + + + + + + + +
IDNameBase URLTypeStatusLast Sync
LinkActive' : 'Disabled' ?>
+
\ No newline at end of file diff --git a/app/Views/admin/categories/create.php b/app/Views/admin/categories/create.php new file mode 100644 index 0000000..e29c9b6 --- /dev/null +++ b/app/Views/admin/categories/create.php @@ -0,0 +1,13 @@ + +

Create Category

+
+
+ +
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/app/Views/admin/categories/edit.php b/app/Views/admin/categories/edit.php new file mode 100644 index 0000000..a64f295 --- /dev/null +++ b/app/Views/admin/categories/edit.php @@ -0,0 +1,14 @@ + +

Edit Category

+
+
+ + +
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/app/Views/admin/categories/index.php b/app/Views/admin/categories/index.php new file mode 100644 index 0000000..436e078 --- /dev/null +++ b/app/Views/admin/categories/index.php @@ -0,0 +1,28 @@ + +
+

Service Categories

+ Create +
+
+ + + + + + + + + + + + + +
IDNameOrderStatusActions
Active' : 'Disabled' ?> + Edit +
+ + + +
+
+
\ No newline at end of file diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php new file mode 100644 index 0000000..6ed452b --- /dev/null +++ b/app/Views/admin/dashboard.php @@ -0,0 +1,43 @@ + +

Welcome to Admin Dashboard

+
+
+
+
+
+
+
Users
+
+
+ +
+
+
+
+
+
+
+
+
+
Orders
+
+
+ +
+
+
+
+
+
+
+
+
+
Revenue
+
$
+
+ +
+
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/logs/index.php b/app/Views/admin/logs/index.php new file mode 100644 index 0000000..cdc267e --- /dev/null +++ b/app/Views/admin/logs/index.php @@ -0,0 +1,17 @@ + +

Cron Logs

+
+ + + + + + + + + + + + +
TimeJobStatusMessage
+
\ No newline at end of file diff --git a/app/Views/admin/orders/index.php b/app/Views/admin/orders/index.php new file mode 100644 index 0000000..e639f2b --- /dev/null +++ b/app/Views/admin/orders/index.php @@ -0,0 +1,33 @@ + +

Orders

+
+ + + + + + + + + + + + + + + + + + + + + + + +
IDUserServiceStatusPriceCreated
+ 'warning', 'processing' => 'info', 'completed' => 'success', 'partial' => 'secondary', 'cancelled' => 'dark', 'failed' => 'danger' + ]; ?> + + $
+
\ No newline at end of file diff --git a/app/Views/admin/reports/index.php b/app/Views/admin/reports/index.php new file mode 100644 index 0000000..a47e330 --- /dev/null +++ b/app/Views/admin/reports/index.php @@ -0,0 +1,32 @@ + +

Reports

+
+
+
Total Credits
$
+
+
+
Total Debits
$
+
+
+
Orders
+
+
+
+
+
Last 14 Days
+
+ + + + + + + + + + + +
DateCreditsDebits
$$
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/services/create.php b/app/Views/admin/services/create.php new file mode 100644 index 0000000..3b53597 --- /dev/null +++ b/app/Views/admin/services/create.php @@ -0,0 +1,19 @@ + +

Create Service

+
+
+ +
+
+
+ +
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/app/Views/admin/services/edit.php b/app/Views/admin/services/edit.php new file mode 100644 index 0000000..ab0139c --- /dev/null +++ b/app/Views/admin/services/edit.php @@ -0,0 +1,20 @@ + +

Edit Service

+
+
+ + +
+
+
+ +
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/app/Views/admin/services/index.php b/app/Views/admin/services/index.php new file mode 100644 index 0000000..f47efd7 --- /dev/null +++ b/app/Views/admin/services/index.php @@ -0,0 +1,42 @@ + +

Services

+
+
+ Categories + Create Service + Map API Services +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
IDCategoryNamePriceStatusActions
$Enabled' : 'Disabled' ?> + Edit +
+ + + +
+
+
\ No newline at end of file diff --git a/app/Views/admin/services/map.php b/app/Views/admin/services/map.php new file mode 100644 index 0000000..512e092 --- /dev/null +++ b/app/Views/admin/services/map.php @@ -0,0 +1,36 @@ + +

Map API Services

+
+
+ +
+ + + + + + + + + + + + +
APIRemote ServicePriceMap to Local Service
()$ + +
+
+
+ +
+
+ + +
\ No newline at end of file diff --git a/app/Views/admin/settings/index.php b/app/Views/admin/settings/index.php new file mode 100644 index 0000000..dafc80d --- /dev/null +++ b/app/Views/admin/settings/index.php @@ -0,0 +1,79 @@ + +

Settings

+
+
+
+
+ +
+ + +
+
Dhru-like API Paths
+
+ + +
+
+ + +
+
+ + +
Use {id} placeholder for order ID.
+
+
Dhru-like Keys
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
All Settings
+
+ + + + + + + + + + +
KeyValue
+
+
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/users/edit.php b/app/Views/admin/users/edit.php new file mode 100644 index 0000000..8562694 --- /dev/null +++ b/app/Views/admin/users/edit.php @@ -0,0 +1,34 @@ + +

Edit User

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/users/index.php b/app/Views/admin/users/index.php new file mode 100644 index 0000000..a97e069 --- /dev/null +++ b/app/Views/admin/users/index.php @@ -0,0 +1,51 @@ + +
+

Users

+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNameEmailRoleStatusWalletMarkup %SubscriptionCreated
+ + Active + + Inactive + + $Edit
+
+
+ +
\ No newline at end of file diff --git a/app/Views/admin/users/subscription.php b/app/Views/admin/users/subscription.php new file mode 100644 index 0000000..62695bf --- /dev/null +++ b/app/Views/admin/users/subscription.php @@ -0,0 +1,24 @@ + +

Update User Subscription

+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
\ No newline at end of file diff --git a/app/Views/admin/wallet/adjust.php b/app/Views/admin/wallet/adjust.php new file mode 100644 index 0000000..5b02597 --- /dev/null +++ b/app/Views/admin/wallet/adjust.php @@ -0,0 +1,20 @@ + +

Adjust Wallet

+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/wallet/index.php b/app/Views/admin/wallet/index.php new file mode 100644 index 0000000..5f359ca --- /dev/null +++ b/app/Views/admin/wallet/index.php @@ -0,0 +1,30 @@ + +

Wallet Transactions

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IDUserTypeMethodAmountReferenceDate
$
+
\ No newline at end of file diff --git a/app/Views/auth/forgot.php b/app/Views/auth/forgot.php new file mode 100644 index 0000000..ef6762b --- /dev/null +++ b/app/Views/auth/forgot.php @@ -0,0 +1,17 @@ + + +
+ + +
+ +
+ +
+ + +
+
+ +
+
\ No newline at end of file diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php new file mode 100644 index 0000000..abc404d --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,28 @@ + + +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Forgot Password? +
+
\ No newline at end of file diff --git a/app/Views/auth/reset.php b/app/Views/auth/reset.php new file mode 100644 index 0000000..f84b99c --- /dev/null +++ b/app/Views/auth/reset.php @@ -0,0 +1,15 @@ + + +
+ +
+ + +
+ + +
+
+ +
+
\ No newline at end of file diff --git a/app/Views/home/index.php b/app/Views/home/index.php new file mode 100644 index 0000000..382f0d0 --- /dev/null +++ b/app/Views/home/index.php @@ -0,0 +1,40 @@ +
+
+
+

+

All-in-one GSM services platform. Fast API integration, order management, and wallet system.

+ Login + Learn more +
+
+ GSM Services +
+
+
+
+
+
+
+
API Integrations
+

Connect multiple providers and sync services/prices.

+
+
+
+
+
+
+
Wallet & Payments
+

Top-up with Bkash, Nagad, Rocket, Binance Pay, PayPal.

+
+
+
+
+
+
+
Secure
+

CSRF, XSS, SQLi protection and optional 2FA.

+
+
+
+
+
\ No newline at end of file diff --git a/app/Views/layouts/admin.php b/app/Views/layouts/admin.php new file mode 100644 index 0000000..167ca1a --- /dev/null +++ b/app/Views/layouts/admin.php @@ -0,0 +1,55 @@ + + + + + + + <?= isset($title) ? App\Core\View::e($title) : 'Admin' ?> - <?= App\Core\View::e(SITE_NAME) ?> + + + + + + +
+
+ +
+ +
+
+
+ + + \ No newline at end of file diff --git a/app/Views/layouts/auth.php b/app/Views/layouts/auth.php new file mode 100644 index 0000000..c8a5ebf --- /dev/null +++ b/app/Views/layouts/auth.php @@ -0,0 +1,25 @@ + + + + + + + <?= isset($title) ? App\Core\View::e($title) : 'Auth' ?> - <?= App\Core\View::e(SITE_NAME) ?> + + + +
+
+
+
+
+
+ +
+
+
+
+
+ + + \ No newline at end of file diff --git a/app/Views/layouts/public.php b/app/Views/layouts/public.php new file mode 100644 index 0000000..51c3f30 --- /dev/null +++ b/app/Views/layouts/public.php @@ -0,0 +1,35 @@ + + + + + + + <?= isset($title) ? App\Core\View::e($title) : App\Core\View::e(SITE_NAME) ?> + + + + + +
+ +
+ + + + \ No newline at end of file diff --git a/app/Views/layouts/user.php b/app/Views/layouts/user.php new file mode 100644 index 0000000..db518a1 --- /dev/null +++ b/app/Views/layouts/user.php @@ -0,0 +1,39 @@ + + + + + + + <?= isset($title) ? App\Core\View::e($title) : 'User' ?> - <?= App\Core\View::e(SITE_NAME) ?> + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/app/Views/user/dashboard.php b/app/Views/user/dashboard.php new file mode 100644 index 0000000..6b569e4 --- /dev/null +++ b/app/Views/user/dashboard.php @@ -0,0 +1,34 @@ + +

Hello,

+
+
+
+
+
Wallet Balance
+
$
+
+
+
+
+
+
+
My Orders
+
+
+
+
+
+
+
+
Subscription
+
+ = date('Y-m-d')): ?> + Active until + + Expired + +
+
+
+
+
\ No newline at end of file diff --git a/app/Views/user/notifications/index.php b/app/Views/user/notifications/index.php new file mode 100644 index 0000000..4989774 --- /dev/null +++ b/app/Views/user/notifications/index.php @@ -0,0 +1,16 @@ + +

Notifications

+
+ +
+
+ + +
+
+
+ + +
No notifications
+ +
\ No newline at end of file diff --git a/app/Views/user/orders/index.php b/app/Views/user/orders/index.php new file mode 100644 index 0000000..abcf97c --- /dev/null +++ b/app/Views/user/orders/index.php @@ -0,0 +1,29 @@ + +

My Orders

+
+ + + + + + + + + + + + + + + + + + + + + +
IDServiceStatusPriceCreated
$
+
+
+ +
\ No newline at end of file diff --git a/app/Views/user/orders/place.php b/app/Views/user/orders/place.php new file mode 100644 index 0000000..73f5be3 --- /dev/null +++ b/app/Views/user/orders/place.php @@ -0,0 +1,24 @@ + +

Place Order

+
+
+
+
+ +
+ + +
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/app/Views/user/profile/index.php b/app/Views/user/profile/index.php new file mode 100644 index 0000000..637a21a --- /dev/null +++ b/app/Views/user/profile/index.php @@ -0,0 +1,17 @@ + +

Profile

+
+
+
+
+ + +
+
+ + +
+
+
Password changes can be performed via Forgot Password.
+
+
\ No newline at end of file diff --git a/app/Views/user/services/index.php b/app/Views/user/services/index.php new file mode 100644 index 0000000..fafa3a8 --- /dev/null +++ b/app/Views/user/services/index.php @@ -0,0 +1,19 @@ + +

Services

+
+ +
+
+
+
+
+

120 ? '…' : '' ?>

+
+
$
+ Order +
+
+
+
+ +
\ No newline at end of file diff --git a/app/Views/user/subscriptions/index.php b/app/Views/user/subscriptions/index.php new file mode 100644 index 0000000..6a15c36 --- /dev/null +++ b/app/Views/user/subscriptions/index.php @@ -0,0 +1,18 @@ + +

Subscription

+
+
+

+ = date('Y-m-d')): ?> + Active until + + Your subscription is expired. + +

+
+ + + +
+
+
\ No newline at end of file diff --git a/app/Views/user/support/index.php b/app/Views/user/support/index.php new file mode 100644 index 0000000..a32cdbb --- /dev/null +++ b/app/Views/user/support/index.php @@ -0,0 +1,21 @@ + +

Support

+ +
+ +
+
+
+ +
+ + +
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/app/Views/user/wallet/index.php b/app/Views/user/wallet/index.php new file mode 100644 index 0000000..4bd1e11 --- /dev/null +++ b/app/Views/user/wallet/index.php @@ -0,0 +1,56 @@ + +

Wallet

+
+
+
+
+
Current Balance
+
$
+
+
+
+
+
Add Funds
+
+ +
+ $ + + + +
+
+
+
+
+
+
+
+
Recent Transactions
+
+ + + + + + + + + + + + + + +
DateTypeMethodAmount
$
+
+
+
+
+
\ No newline at end of file diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..42b2b28 --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,69 @@ + DB_HOST, + 'database' => DB_NAME, + 'username' => DB_USER, + 'password' => DB_PASS, + 'charset' => DB_CHARSET ?? 'utf8mb4', +]); + +// Seed default admin if none exists +try { + $count = App\Core\DB::fetch('SELECT COUNT(*) AS c FROM users'); + if ($count && (int)$count['c'] === 0) { + $email = defined('DEFAULT_ADMIN_EMAIL') ? DEFAULT_ADMIN_EMAIL : 'admin@example.com'; + $pass = defined('DEFAULT_ADMIN_PASSWORD') ? DEFAULT_ADMIN_PASSWORD : 'Password@123'; + App\Core\DB::insert('INSERT INTO users (name, email, password_hash, role, status, wallet_balance, created_at, updated_at) VALUES (:n,:e,:p,\'super_admin\',1,0,NOW(),NOW())', [ + 'n' => 'Administrator', + 'e' => $email, + 'p' => password_hash($pass, PASSWORD_DEFAULT), + ]); + } +} catch (\Throwable $e) { + // ignore seeding errors +} \ No newline at end of file diff --git a/app/routes.php b/app/routes.php new file mode 100644 index 0000000..d2da145 --- /dev/null +++ b/app/routes.php @@ -0,0 +1,66 @@ +get('/', 'HomeController@index'); + +// Auth +$router->get('/login', 'AuthController@loginForm'); +$router->post('/login', 'AuthController@login'); +$router->get('/logout', 'AuthController@logout'); +$router->get('/forgot', 'AuthController@forgotForm'); +$router->post('/forgot', 'AuthController@sendReset'); +$router->get('/reset', 'AuthController@resetForm'); +$router->post('/reset', 'AuthController@resetPassword'); + +// Admin +$router->get('/admin', 'Admin\\DashboardController@index'); +$router->get('/admin/users', 'Admin\\UsersController@index'); +$router->get('/admin/users/edit', 'Admin\\UsersController@editForm'); +$router->post('/admin/users/update', 'Admin\\UsersController@update'); +$router->get('/admin/services', 'Admin\\ServicesController@index'); +$router->get('/admin/categories', 'Admin\\ServiceCategoriesController@index'); +$router->get('/admin/categories/create', 'Admin\\ServiceCategoriesController@createForm'); +$router->post('/admin/categories/create', 'Admin\\ServiceCategoriesController@create'); +$router->get('/admin/categories/edit', 'Admin\\ServiceCategoriesController@editForm'); +$router->post('/admin/categories/update', 'Admin\\ServiceCategoriesController@update'); +$router->post('/admin/categories/delete', 'Admin\\ServiceCategoriesController@delete'); +$router->get('/admin/services/create', 'Admin\\ServicesCrudController@createForm'); +$router->post('/admin/services/create', 'Admin\\ServicesCrudController@create'); +$router->get('/admin/services/edit', 'Admin\\ServicesCrudController@editForm'); +$router->post('/admin/services/update', 'Admin\\ServicesCrudController@update'); +$router->post('/admin/services/delete', 'Admin\\ServicesCrudController@delete'); +$router->get('/admin/apis', 'Admin\\ApisController@index'); +$router->get('/admin/apis/create', 'Admin\\ApisController@createForm'); +$router->post('/admin/apis/create', 'Admin\\ApisController@create'); +$router->get('/admin/orders', 'Admin\\OrdersController@index'); +$router->get('/admin/wallet', 'Admin\\WalletController@index'); +$router->get('/admin/reports', 'Admin\\ReportsController@index'); +$router->get('/admin/settings', 'Admin\\SettingsController@index'); +$router->post('/admin/settings/save', 'Admin\\SettingsController@save'); +$router->get('/admin/logs', 'Admin\\LogsController@index'); + +// User +$router->get('/dashboard', 'User\\DashboardController@index'); +$router->get('/orders', 'User\\OrdersController@index'); +$router->get('/wallet', 'User\\WalletController@index'); +$router->post('/wallet/add', 'User\\WalletController@addFunds'); +$router->get('/wallet/callback', 'User\\WalletController@callback'); +$router->get('/services', 'User\\ServicesController@index'); +$router->get('/place-order', 'User\\PlaceOrderController@form'); +$router->post('/place-order', 'User\\PlaceOrderController@submit'); +$router->get('/notifications', 'User\\NotificationsController@index'); +$router->get('/profile', 'User\\ProfileController@index'); +$router->get('/subscriptions', 'User\\SubscriptionsController@index'); +$router->get('/support', 'User\\SupportController@index'); +$router->post('/support/send', 'User\\SupportController@send'); + +// Admin extra actions +$router->get('/admin/wallet/adjust', 'Admin\\WalletController@adjustForm'); +$router->post('/admin/wallet/adjust', 'Admin\\WalletController@adjust'); +$router->get('/admin/users/subscription', 'Admin\\UsersController@subscriptionForm'); +$router->post('/admin/users/subscription', 'Admin\\UsersController@updateSubscription'); +$router->get('/admin/services/map', 'Admin\\ServicesController@mapForm'); +$router->post('/admin/services/map', 'Admin\\ServicesController@mapSave'); +$router->post('/admin/services/sync-prices', 'Admin\\ServicesController@syncPrices'); \ No newline at end of file diff --git a/config.php.sample b/config.php.sample new file mode 100644 index 0000000..fa88a52 --- /dev/null +++ b/config.php.sample @@ -0,0 +1,41 @@ += NOW() - INTERVAL 2 HOUR"); + if ($errors) { + $body = '

Cron Failures

'; + Mailer::send(DEFAULT_ADMIN_EMAIL, SITE_NAME . ' - Cron Failures', $body); + } + DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['failed-jobs-alert', 'ok', 'Alerts processed']); + echo "Failed jobs alert: done\n"; +} catch (Throwable $e) { + DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['failed-jobs-alert', 'error', $e->getMessage()]); + echo "Error: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/cron/subscriptions_check.php b/cron/subscriptions_check.php new file mode 100644 index 0000000..3725927 --- /dev/null +++ b/cron/subscriptions_check.php @@ -0,0 +1,21 @@ +getMessage()]); + echo "Error: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/cron/sync_apis.php b/cron/sync_apis.php new file mode 100644 index 0000000..e4cf37d --- /dev/null +++ b/cron/sync_apis.php @@ -0,0 +1,47 @@ +fetchServices($path); + $listKey = App\Core\Settings::get('dhru_services_list_key', 'data'); + if (isset($list[$listKey]) && is_array($list[$listKey])) { $list = $list[$listKey]; } + foreach ($list as $item) { + $remoteId = (string)($item['id'] ?? $item['service_id'] ?? ''); + $name = (string)($item['name'] ?? $item['service'] ?? ''); + $price = (float)($item['price'] ?? 0); + if (!$remoteId || !$name) { continue; } + $exists = DB::fetch('SELECT id FROM api_services WHERE api_id = :api AND remote_service_id = :rid LIMIT 1', ['api' => $api['id'], 'rid' => $remoteId]); + if ($exists) { + DB::query('UPDATE api_services SET name = :n, price = :p, updated_at = NOW() WHERE id = :id', ['n' => $name, 'p' => $price, 'id' => $exists['id']]); + } else { + DB::insert('INSERT INTO api_services (api_id, remote_service_id, name, price, status, created_at, updated_at) VALUES (:api,:rid,:n,:p,1,NOW(),NOW())', ['api' => $api['id'], 'rid' => $remoteId, 'n' => $name, 'p' => $price]); + } + $updated++; + } + DB::query('UPDATE apis SET last_sync_at = NOW() WHERE id = :id', ['id' => $api['id']]); + } catch (\Throwable $e) { + DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['sync-apis', 'error', 'API ' . $api['name'] . ': ' . $e->getMessage()]); + } + } + DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['sync-apis', 'ok', 'Services updated: ' . $updated]); + echo "Sync APIs: updated {$updated}\n"; +} catch (Throwable $e) { + DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['sync-apis', 'error', $e->getMessage()]); + echo "Error: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/cron/update_orders.php b/cron/update_orders.php new file mode 100644 index 0000000..7cb4d83 --- /dev/null +++ b/cron/update_orders.php @@ -0,0 +1,24 @@ +getMessage()]); + echo "Error: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..7ad664f --- /dev/null +++ b/database.sql @@ -0,0 +1,131 @@ +-- Database schema for GSM Theme Clone + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) DEFAULT NULL, + email VARCHAR(190) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role ENUM('super_admin','admin','reseller') NOT NULL DEFAULT 'reseller', + status TINYINT(1) NOT NULL DEFAULT 1, + wallet_balance DECIMAL(12,2) NOT NULL DEFAULT 0.00, + price_markup_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00, + subscription_expires_at DATE DEFAULT NULL, + two_factor_secret VARCHAR(32) DEFAULT NULL, + reset_token VARCHAR(64) DEFAULT NULL, + reset_expires_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX (role), + INDEX (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS service_categories ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(150) NOT NULL, + status TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS services ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + category_id INT UNSIGNED NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + price DECIMAL(12,2) NOT NULL DEFAULT 0.00, + api_service_id INT UNSIGNED DEFAULT NULL, + status TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES service_categories(id) ON DELETE CASCADE, + INDEX (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS apis ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(150) NOT NULL, + base_url VARCHAR(255) NOT NULL, + api_key VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + status TINYINT(1) NOT NULL DEFAULT 1, + last_sync_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS api_services ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + api_id INT UNSIGNED NOT NULL, + remote_service_id VARCHAR(100) NOT NULL, + name VARCHAR(200) NOT NULL, + price DECIMAL(12,2) NOT NULL DEFAULT 0.00, + data JSON NULL, + status TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (api_id) REFERENCES apis(id) ON DELETE CASCADE, + INDEX (api_id), + INDEX (remote_service_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS orders ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + service_id INT UNSIGNED NOT NULL, + api_id INT UNSIGNED DEFAULT NULL, + api_order_id VARCHAR(100) DEFAULT NULL, + status ENUM('pending','processing','completed','partial','cancelled','failed') NOT NULL DEFAULT 'pending', + input_data JSON NULL, + result_data JSON NULL, + price DECIMAL(12,2) NOT NULL DEFAULT 0.00, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE RESTRICT, + FOREIGN KEY (api_id) REFERENCES apis(id) ON DELETE SET NULL, + INDEX (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS wallet_transactions ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + type ENUM('credit','debit') NOT NULL, + method VARCHAR(50) DEFAULT NULL, + amount DECIMAL(12,2) NOT NULL, + reference VARCHAR(190) DEFAULT NULL, + meta JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX (type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS cron_logs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + job VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL, + message TEXT NULL, + ran_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS notifications ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED DEFAULT NULL, + title VARCHAR(200) NOT NULL, + message TEXT NOT NULL, + read_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS settings ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `key` VARCHAR(100) NOT NULL UNIQUE, + `value` TEXT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Seed optional admin if installer skipped +INSERT INTO users (name, email, password_hash, role, status, wallet_balance, created_at, updated_at) +SELECT 'Administrator', 'admin@example.com', '$2y$10$W.1kq6xwYH3r2xw7mJvG8O4H5S0X9A0w2Hk1j2t3u4v5w6x7y8z9e', 'super_admin', 1, 0, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'admin@example.com'); +-- Password hash above is a placeholder; installer will set real one when used. \ No newline at end of file diff --git a/install/index.php b/install/index.php new file mode 100644 index 0000000..d1d7d74 --- /dev/null +++ b/install/index.php @@ -0,0 +1,238 @@ +'; + echo 'Installer - ' . htmlspecialchars($title) . ''; + echo ''; + echo '
'; + echo '

Installation Wizard

Setup your application
'; +} + +function view_footer() +{ + echo '
'; +} + +function progress_bar(int $step, int $total = 8) +{ + $percent = (int) floor(($step - 1) / $total * 100); + echo '
Step ' . $step . ' / ' . $total . '
'; +} + +if (file_exists($basePath . '/config.php')) { + header('Location: /'); + exit; +} + +$step = isset($_GET['step']) ? (int) $_GET['step'] : 1; + +// Step 1: Environment check +if ($step === 1) { + view_header('Environment Check'); + progress_bar(1); + $requirements = [ + 'PHP >= 8.1' => version_compare(PHP_VERSION, '8.1.0', '>='), + 'mysqli' => extension_loaded('mysqli'), + 'pdo_mysql' => extension_loaded('pdo_mysql'), + 'curl' => extension_loaded('curl'), + 'openssl' => extension_loaded('openssl'), + 'json' => extension_loaded('json'), + 'mbstring' => extension_loaded('mbstring'), + 'zip' => extension_loaded('zip'), + 'gd' => extension_loaded('gd'), + 'fileinfo' => extension_loaded('fileinfo'), + 'allow_url_fopen' => (bool) ini_get('allow_url_fopen'), + 'write permissions (root)' => is_writable($basePath), + ]; + echo '
'; + echo ''; + echo '
'; + echo '
'; + if ($ok) { + echo 'Continue'; + } else { + echo '
Please resolve the missing requirements and reload this page.
'; + } + echo '
'; + view_footer(); + exit; +} + +// Step 2: License +if ($step === 2) { + view_header('License Agreement'); + progress_bar(2); + $license = @file_get_contents($basePath . '/license.txt'); + echo '
' . htmlspecialchars($license ?: 'No license.txt found.') . '
'; + echo '
'; + echo '
'; + echo '
'; + view_footer(); + exit; +} + +// Step 3: Database config +if ($step === 3) { + if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST['agree'])) { + header('Location: ?step=2'); + exit; + } + view_header('Database'); + progress_bar(3); + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + view_footer(); + exit; +} + +// Step 4: Site settings +if ($step === 4) { + $_SESSION['db'] = [ + 'host' => $_POST['db_host'] ?? '', + 'name' => $_POST['db_name'] ?? '', + 'user' => $_POST['db_user'] ?? '', + 'pass' => $_POST['db_pass'] ?? '', + ]; + view_header('Site Settings'); + progress_bar(4); + $url = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https://' : 'http://') . $_SERVER['HTTP_HOST']; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + view_footer(); + exit; +} + +// Step 5: Write config.php +if ($step === 5) { + $_SESSION['site'] = [ + 'name' => $_POST['site_name'] ?? 'GSM Theme', + 'url' => $_POST['site_url'] ?? '', + 'admin_email' => $_POST['admin_email'] ?? '', + 'admin_password' => $_POST['admin_password'] ?? '', + ]; + + $db = $_SESSION['db'] ?? []; + $site = $_SESSION['site'] ?? []; + + $config = " PDO::ERRMODE_EXCEPTION]); + $sql = file_get_contents($basePath . '/database.sql'); + $pdo->exec($sql); + echo '
Database imported successfully.
'; + echo '
Continue
'; + } catch (Throwable $e) { + echo '
Import failed: ' . htmlspecialchars($e->getMessage()) . '
'; + echo 'Retry'; + } + view_footer(); + exit; +} + +// Step 7: Create admin account +if ($step === 7) { + require $basePath . '/config.php'; + view_header('Create Admin'); + progress_bar(7); + try { + $pdo = new PDO('mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $email = DEFAULT_ADMIN_EMAIL; + $pass = DEFAULT_ADMIN_PASSWORD; + $exists = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1'); + $exists->execute([$email]); + if (!$exists->fetch()) { + $stmt = $pdo->prepare('INSERT INTO users (name, email, password_hash, role, status, wallet_balance, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 0, NOW(), NOW())'); + $stmt->execute(['Administrator', $email, password_hash($pass, PASSWORD_DEFAULT), 'super_admin']); + } + echo '
Admin account is ready.
'; + echo '
Finish
'; + } catch (Throwable $e) { + echo '
Failed to create admin: ' . htmlspecialchars($e->getMessage()) . '
'; + } + view_footer(); + exit; +} + +// Step 8: Cleanup installer +if ($step === 8) { + view_header('Finish'); + progress_bar(8); + $renamed = false; + $installDir = __DIR__; + $newName = dirname(__DIR__) . '/_install_completed_' . date('Ymd_His'); + if (@rename($installDir, $newName)) { + $renamed = true; + } + echo '
'; + echo '
Installation Successful
'; + echo '

Your application is ready.

'; + if ($renamed) { + echo '

Installer has been renamed for security.

'; + } else { + echo '

Please delete or rename the /install folder manually.

'; + } + echo '
'; + echo 'Go to Website'; + echo 'Go to Admin Panel'; + echo '
'; + echo '
'; + view_footer(); + exit; +} \ No newline at end of file diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..d58f72f --- /dev/null +++ b/license.txt @@ -0,0 +1,3 @@ +This software is provided "as is", without warranty of any kind. You are free to use, modify, and distribute this software for personal or commercial use, provided that you retain this notice. + +Third-party trademarks and brand names belong to their respective owners. \ No newline at end of file diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..f5e79dc --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,10 @@ +RewriteEngine On +Options -Indexes + +# Allow direct access to existing files and directories +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +# Redirect everything else to index.php +RewriteRule ^ index.php [L] \ No newline at end of file diff --git a/public/cron/failed_jobs_alert.php b/public/cron/failed_jobs_alert.php new file mode 100644 index 0000000..2a4b675 --- /dev/null +++ b/public/cron/failed_jobs_alert.php @@ -0,0 +1,2 @@ +run(); \ No newline at end of file diff --git a/public/webhook/api_order_callback.php b/public/webhook/api_order_callback.php new file mode 100644 index 0000000..6e0a447 --- /dev/null +++ b/public/webhook/api_order_callback.php @@ -0,0 +1,25 @@ + (string)$data['order_id']]); + if ($row) { $orderId = (int)$row['id']; } +} +if ($orderId) { + try { App\Services\OrderProcessor::refreshOrder($orderId); } catch (\Throwable $e) {} +} +DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['webhook', 'ok', substr($payload, 0, 1900)]); +echo 'OK'; \ No newline at end of file