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:
'; + 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 @@ + +
| ID | +Name | +Base URL | +Type | +Status | +Last Sync | +
|---|---|---|---|---|---|
| = (int)$api['id'] ?> | += View::e($api['name']) ?> | +Link | += View::e($api['type']) ?> | += (int)$api['status'] === 1 ? 'Active' : 'Disabled' ?> | += View::e($api['last_sync_at'] ?: '-') ?> | +
| ID | Name | Order | Status | Actions |
|---|---|---|---|---|
| = (int)$c['id'] ?> | += View::e($c['name']) ?> | += (int)$c['sort_order'] ?> | += (int)$c['status'] === 1 ? 'Active' : 'Disabled' ?> | ++ Edit + + | +
| Time | Job | Status | Message |
|---|---|---|---|
| = View::e($log['ran_at']) ?> | += View::e($log['job']) ?> | += View::e($log['status']) ?> | += View::e($log['message'] ?? '') ?> | +
| ID | +User | +Service | +Status | +Price | +Created | +
|---|---|---|---|---|---|
| = (int)$o['id'] ?> | += View::e($o['email']) ?> | += View::e($o['service_name']) ?> | ++ 'warning', 'processing' => 'info', 'completed' => 'success', 'partial' => 'secondary', 'cancelled' => 'dark', 'failed' => 'danger' + ]; ?> + = View::e($status) ?> + | +$= number_format((float)$o['price'], 2) ?> | += View::e($o['created_at']) ?> | +
| Date | Credits | Debits |
|---|---|---|
| = View::e($r['d']) ?> | +$= number_format((float)$r['credits'], 2) ?> | +$= number_format((float)$r['debits'], 2) ?> | +
| ID | +Category | +Name | +Price | +Status | +Actions | +
|---|---|---|---|---|---|
| = (int)$s['id'] ?> | += View::e($s['category_name']) ?> | += View::e($s['name']) ?> | +$= number_format((float)$s['price'], 2) ?> | += (int)$s['status'] === 1 ? 'Enabled' : 'Disabled' ?> | ++ Edit + + | +
| Key | Value |
|---|---|
| = View::e($s['key']) ?> | += View::e($s['value']) ?> |
+
| ID | +Name | +Role | +Status | +Wallet | +Markup % | +Subscription | +Created | ++ | |
|---|---|---|---|---|---|---|---|---|---|
| = (int)$u['id'] ?> | += View::e($u['name'] ?: '-') ?> | += View::e($u['email']) ?> | += View::e($u['role']) ?> | ++ + Active + + Inactive + + | +$= number_format((float)$u['wallet_balance'], 2) ?> | += number_format((float)$u['price_markup_percent'], 2) ?> | += View::e($u['subscription_expires_at'] ?: '-') ?> | += View::e($u['created_at']) ?> | +Edit | +
| ID | +User | +Type | +Method | +Amount | +Reference | +Date | +
|---|---|---|---|---|---|---|
| = (int)$t['id'] ?> | += View::e($t['email']) ?> | += View::e($t['type']) ?> | += View::e($t['method'] ?: '-') ?> | +$= number_format((float)$t['amount'], 2) ?> | += View::e($t['reference'] ?: '-') ?> | += View::e($t['created_at']) ?> | +
All-in-one GSM services platform. Fast API integration, order management, and wallet system.
+ Login + Learn more +Connect multiple providers and sync services/prices.
+Top-up with Bkash, Nagad, Rocket, Binance Pay, PayPal.
+CSRF, XSS, SQLi protection and optional 2FA.
+| ID | +Service | +Status | +Price | +Created | +
|---|---|---|---|---|
| = (int)$o['id'] ?> | += View::e($o['service_name']) ?> | += View::e($o['status']) ?> | +$= number_format((float)$o['price'], 2) ?> | += View::e($o['created_at']) ?> | +
= View::e(substr((string)$s['description'], 0, 120)) ?>= strlen((string)$s['description']) > 120 ? '…' : '' ?>
++ = date('Y-m-d')): ?> + Active until = View::e($expires) ?> + + Your subscription is expired. + +
+| Date | Type | Method | Amount |
|---|---|---|---|
| = View::e($t['created_at']) ?> | += View::e($t['type']) ?> | += View::e($t['method'] ?: '-') ?> | +$= number_format((float)$t['amount'], 2) ?> | +
Your application is ready.
'; + if ($renamed) { + echo 'Installer has been renamed for security.
'; + } else { + echo 'Please delete or rename the /install folder manually.
'; + } + echo '