From c35a6ccc6829ef346a9ebffc6b9e9b1575f4563b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 17:34:06 +0000 Subject: [PATCH 01/10] Initial commit: GSM Theme Clone with MVC structure, admin/user panels Co-authored-by: ayanayat18 --- README.md | 68 +++++ app/Controllers/Admin/ApisController.php | 18 ++ app/Controllers/Admin/DashboardController.php | 25 ++ app/Controllers/Admin/LogsController.php | 18 ++ app/Controllers/Admin/OrdersController.php | 18 ++ app/Controllers/Admin/ReportsController.php | 24 ++ app/Controllers/Admin/ServicesController.php | 20 ++ app/Controllers/Admin/SettingsController.php | 18 ++ app/Controllers/Admin/UsersController.php | 18 ++ app/Controllers/Admin/WalletController.php | 18 ++ app/Controllers/AuthController.php | 111 ++++++++ app/Controllers/HomeController.php | 14 ++ app/Controllers/User/DashboardController.php | 25 ++ app/Controllers/User/OrdersController.php | 20 ++ app/Controllers/User/ProfileController.php | 20 ++ app/Controllers/User/ServicesController.php | 20 ++ .../User/SubscriptionsController.php | 19 ++ app/Controllers/User/SupportController.php | 34 +++ app/Controllers/User/WalletController.php | 40 +++ app/Core/App.php | 27 ++ app/Core/Auth.php | 46 ++++ app/Core/CSRF.php | 23 ++ app/Core/Controller.php | 35 +++ app/Core/DB.php | 61 +++++ app/Core/Helpers.php | 23 ++ app/Core/Mailer.php | 46 ++++ app/Core/Router.php | 65 +++++ app/Core/TOTP.php | 58 +++++ app/Core/Telegram.php | 30 +++ app/Core/View.php | 29 +++ app/Interfaces/PaymentGatewayInterface.php | 11 + app/Payments/PaypalGateway.php | 32 +++ app/Views/admin/apis/index.php | 28 +++ app/Views/admin/dashboard.php | 43 ++++ app/Views/admin/logs/index.php | 17 ++ app/Views/admin/orders/index.php | 33 +++ app/Views/admin/reports/index.php | 32 +++ app/Views/admin/services/index.php | 26 ++ app/Views/admin/settings/index.php | 15 ++ app/Views/admin/users/index.php | 40 +++ app/Views/admin/wallet/index.php | 30 +++ app/Views/auth/forgot.php | 17 ++ app/Views/auth/login.php | 24 ++ app/Views/auth/reset.php | 15 ++ app/Views/home/index.php | 40 +++ app/Views/layouts/admin.php | 55 ++++ app/Views/layouts/auth.php | 25 ++ app/Views/layouts/public.php | 35 +++ app/Views/layouts/user.php | 39 +++ app/Views/user/dashboard.php | 34 +++ app/Views/user/orders/index.php | 26 ++ app/Views/user/profile/index.php | 17 ++ app/Views/user/services/index.php | 19 ++ app/Views/user/subscriptions/index.php | 18 ++ app/Views/user/support/index.php | 21 ++ app/Views/user/wallet/index.php | 49 ++++ app/bootstrap.php | 69 +++++ app/routes.php | 37 +++ config.php.sample | 41 +++ cron/failed_jobs_alert.php | 29 +++ cron/subscriptions_check.php | 21 ++ cron/sync_apis.php | 20 ++ cron/update_orders.php | 20 ++ database.sql | 130 ++++++++++ install/index.php | 238 ++++++++++++++++++ license.txt | 3 + public/.htaccess | 10 + public/cron/failed_jobs_alert.php | 2 + public/cron/subscriptions_check.php | 2 + public/cron/sync_apis.php | 2 + public/cron/update_orders.php | 2 + public/index.php | 14 ++ public/webhook/api_order_callback.php | 15 ++ 73 files changed, 2387 insertions(+) create mode 100644 README.md create mode 100644 app/Controllers/Admin/ApisController.php create mode 100644 app/Controllers/Admin/DashboardController.php create mode 100644 app/Controllers/Admin/LogsController.php create mode 100644 app/Controllers/Admin/OrdersController.php create mode 100644 app/Controllers/Admin/ReportsController.php create mode 100644 app/Controllers/Admin/ServicesController.php create mode 100644 app/Controllers/Admin/SettingsController.php create mode 100644 app/Controllers/Admin/UsersController.php create mode 100644 app/Controllers/Admin/WalletController.php create mode 100644 app/Controllers/AuthController.php create mode 100644 app/Controllers/HomeController.php create mode 100644 app/Controllers/User/DashboardController.php create mode 100644 app/Controllers/User/OrdersController.php create mode 100644 app/Controllers/User/ProfileController.php create mode 100644 app/Controllers/User/ServicesController.php create mode 100644 app/Controllers/User/SubscriptionsController.php create mode 100644 app/Controllers/User/SupportController.php create mode 100644 app/Controllers/User/WalletController.php create mode 100644 app/Core/App.php create mode 100644 app/Core/Auth.php create mode 100644 app/Core/CSRF.php create mode 100644 app/Core/Controller.php create mode 100644 app/Core/DB.php create mode 100644 app/Core/Helpers.php create mode 100644 app/Core/Mailer.php create mode 100644 app/Core/Router.php create mode 100644 app/Core/TOTP.php create mode 100644 app/Core/Telegram.php create mode 100644 app/Core/View.php create mode 100644 app/Interfaces/PaymentGatewayInterface.php create mode 100644 app/Payments/PaypalGateway.php create mode 100644 app/Views/admin/apis/index.php create mode 100644 app/Views/admin/dashboard.php create mode 100644 app/Views/admin/logs/index.php create mode 100644 app/Views/admin/orders/index.php create mode 100644 app/Views/admin/reports/index.php create mode 100644 app/Views/admin/services/index.php create mode 100644 app/Views/admin/settings/index.php create mode 100644 app/Views/admin/users/index.php create mode 100644 app/Views/admin/wallet/index.php create mode 100644 app/Views/auth/forgot.php create mode 100644 app/Views/auth/login.php create mode 100644 app/Views/auth/reset.php create mode 100644 app/Views/home/index.php create mode 100644 app/Views/layouts/admin.php create mode 100644 app/Views/layouts/auth.php create mode 100644 app/Views/layouts/public.php create mode 100644 app/Views/layouts/user.php create mode 100644 app/Views/user/dashboard.php create mode 100644 app/Views/user/orders/index.php create mode 100644 app/Views/user/profile/index.php create mode 100644 app/Views/user/services/index.php create mode 100644 app/Views/user/subscriptions/index.php create mode 100644 app/Views/user/support/index.php create mode 100644 app/Views/user/wallet/index.php create mode 100644 app/bootstrap.php create mode 100644 app/routes.php create mode 100644 config.php.sample create mode 100644 cron/failed_jobs_alert.php create mode 100644 cron/subscriptions_check.php create mode 100644 cron/sync_apis.php create mode 100644 cron/update_orders.php create mode 100644 database.sql create mode 100644 install/index.php create mode 100644 license.txt create mode 100644 public/.htaccess create mode 100644 public/cron/failed_jobs_alert.php create mode 100644 public/cron/subscriptions_check.php create mode 100644 public/cron/sync_apis.php create mode 100644 public/cron/update_orders.php create mode 100644 public/index.php create mode 100644 public/webhook/api_order_callback.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd2d622 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# 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 +``` +- 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..3805d1a --- /dev/null +++ b/app/Controllers/Admin/ApisController.php @@ -0,0 +1,18 @@ +requireRole(['admin', 'super_admin']); + $apis = DB::fetchAll('SELECT * FROM apis ORDER BY name'); + $this->render('admin/apis/index', [ + 'title' => 'APIs', + 'apis' => $apis, + ], 'admin'); + } +} \ 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/ServicesController.php b/app/Controllers/Admin/ServicesController.php new file mode 100644 index 0000000..af15cb2 --- /dev/null +++ b/app/Controllers/Admin/ServicesController.php @@ -0,0 +1,20 @@ +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'); + } +} \ 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..d4fb4c0 --- /dev/null +++ b/app/Controllers/Admin/SettingsController.php @@ -0,0 +1,18 @@ +requireRole(['admin', 'super_admin']); + $settings = DB::fetchAll('SELECT `key`,`value` FROM settings ORDER BY `key`'); + $this->render('admin/settings/index', [ + 'title' => 'Settings', + 'settings' => $settings, + ], 'admin'); + } +} \ 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..54e4eb0 --- /dev/null +++ b/app/Controllers/Admin/UsersController.php @@ -0,0 +1,18 @@ +requireRole(['admin', 'super_admin']); + $users = DB::fetchAll('SELECT id, name, email, role, status, wallet_balance, subscription_expires_at, created_at FROM users ORDER BY id DESC'); + $this->render('admin/users/index', [ + 'title' => 'Users', + 'users' => $users, + ], 'admin'); + } +} \ 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..d46d31b --- /dev/null +++ b/app/Controllers/Admin/WalletController.php @@ -0,0 +1,18 @@ +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'); + } +} \ No newline at end of file diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..feae372 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,111 @@ +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'] ?? ''); + + $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; + } + + 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/OrdersController.php b/app/Controllers/User/OrdersController.php new file mode 100644 index 0000000..6c28c8e --- /dev/null +++ b/app/Controllers/User/OrdersController.php @@ -0,0 +1,20 @@ +redirect('/login'); } + $userId = Auth::id(); + $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', ['uid' => $userId]); + $this->render('user/orders/index', [ + 'title' => 'My Orders', + 'orders' => $orders, + ], 'user'); + } +} \ 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..80501b5 --- /dev/null +++ b/app/Controllers/User/WalletController.php @@ -0,0 +1,40 @@ +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); + if ($amount <= 0) { $this->redirect('/wallet'); } + $gateway = new PaypalGateway(); + $res = $gateway->createPayment(Auth::id(), $amount); + if (($res['status'] ?? '') === 'redirect') { + header('Location: ' . $res['redirect_url']); + exit; + } + $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/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/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' => '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/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/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/index.php b/app/Views/admin/services/index.php new file mode 100644 index 0000000..af41a4d --- /dev/null +++ b/app/Views/admin/services/index.php @@ -0,0 +1,26 @@ + +

Services

+
+ + + + + + + + + + + + + + + + + + + + + +
IDCategoryNamePriceStatus
$Enabled' : 'Disabled' ?>
+
\ 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..b615157 --- /dev/null +++ b/app/Views/admin/settings/index.php @@ -0,0 +1,15 @@ + +

Settings

+
+ + + + + + + + + + +
KeyValue
+
\ 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..94b9066 --- /dev/null +++ b/app/Views/admin/users/index.php @@ -0,0 +1,40 @@ + +
+

Users

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNameEmailRoleStatusWalletSubscriptionCreated
+ + Active + + Inactive + + $
+
\ 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..bfe4b68 --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,24 @@ + + +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ +
+ +
\ 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) ?> + + + + + +
+ +
+
+
+ © . All rights reserved. +
+
+ + + \ 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/orders/index.php b/app/Views/user/orders/index.php new file mode 100644 index 0000000..99a59d6 --- /dev/null +++ b/app/Views/user/orders/index.php @@ -0,0 +1,26 @@ + +

My Orders

+
+ + + + + + + + + + + + + + + + + + + + + +
IDServiceStatusPriceCreated
$
+
\ 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..1e5c9f3 --- /dev/null +++ b/app/Views/user/wallet/index.php @@ -0,0 +1,49 @@ + +

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..93aa945 --- /dev/null +++ b/app/routes.php @@ -0,0 +1,37 @@ +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/services', 'Admin\\ServicesController@index'); +$router->get('/admin/apis', 'Admin\\ApisController@index'); +$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->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('/services', 'User\\ServicesController@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'); \ 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..0835eec --- /dev/null +++ b/cron/sync_apis.php @@ -0,0 +1,20 @@ +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..9555959 --- /dev/null +++ b/cron/update_orders.php @@ -0,0 +1,20 @@ +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..193b60c --- /dev/null +++ b/database.sql @@ -0,0 +1,130 @@ +-- 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, + 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 '
    '; + $ok = true; + foreach ($requirements as $name => $passed) { + $ok = $ok && $passed; + echo '
  • ' . htmlspecialchars($name) . '' . ($passed ? 'OK' : 'Missing') . '
  • '; + } + 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 ''; + } 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 ''; + } 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..1b5d48f --- /dev/null +++ b/public/webhook/api_order_callback.php @@ -0,0 +1,15 @@ + Date: Tue, 12 Aug 2025 17:36:39 +0000 Subject: [PATCH 02/10] Add API management, 2FA login, and API sync functionality Co-authored-by: ayanayat18 --- app/Controllers/Admin/ApisController.php | 23 +++++++++ app/Controllers/AuthController.php | 8 +++ app/Services/ApiClient.php | 65 ++++++++++++++++++++++++ app/Views/admin/apis/create.php | 28 ++++++++++ app/Views/auth/login.php | 4 ++ app/routes.php | 2 + cron/sync_apis.php | 31 +++++++++-- database.sql | 1 + 8 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 app/Services/ApiClient.php create mode 100644 app/Views/admin/apis/create.php diff --git a/app/Controllers/Admin/ApisController.php b/app/Controllers/Admin/ApisController.php index 3805d1a..e2ca103 100644 --- a/app/Controllers/Admin/ApisController.php +++ b/app/Controllers/Admin/ApisController.php @@ -3,6 +3,7 @@ use App\Core\Controller; use App\Core\DB; +use App\Core\CSRF; class ApisController extends Controller { @@ -15,4 +16,26 @@ public function index(): void '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/AuthController.php b/app/Controllers/AuthController.php index feae372..b93af09 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -7,6 +7,7 @@ use App\Core\CSRF; use App\Core\View; use App\Core\Mailer; +use App\Core\TOTP; class AuthController extends Controller { @@ -28,6 +29,7 @@ public function login(): void $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'])) { @@ -38,6 +40,12 @@ public function login(): void $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)) { diff --git a/app/Services/ApiClient.php b/app/Services/ApiClient.php new file mode 100644 index 0000000..152b19b --- /dev/null +++ b/app/Services/ApiClient.php @@ -0,0 +1,65 @@ +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(): array + { + // Placeholder: provider-specific endpoint mapping + return $this->get('/services'); + } +} \ 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/auth/login.php b/app/Views/auth/login.php index bfe4b68..abc404d 100644 --- a/app/Views/auth/login.php +++ b/app/Views/auth/login.php @@ -15,6 +15,10 @@ +
+ + +
diff --git a/app/routes.php b/app/routes.php index 93aa945..d3ad80a 100644 --- a/app/routes.php +++ b/app/routes.php @@ -19,6 +19,8 @@ $router->get('/admin/users', 'Admin\\UsersController@index'); $router->get('/admin/services', 'Admin\\ServicesController@index'); $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'); diff --git a/cron/sync_apis.php b/cron/sync_apis.php index 0835eec..d46cc96 100644 --- a/cron/sync_apis.php +++ b/cron/sync_apis.php @@ -11,9 +11,34 @@ } try { - // TODO: iterate configured APIs and sync services/prices - DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['sync-apis', 'ok', 'Sync completed']); - echo "Sync APIs: done\n"; + // Iterate configured APIs and upsert api_services + $apis = DB::fetchAll('SELECT * FROM apis WHERE status = 1'); + $updated = 0; + foreach ($apis as $api) { + $client = new App\Services\ApiClient($api['base_url'], $api['api_key']); + try { + $list = $client->fetchServices(); + if (isset($list['data']) && is_array($list['data'])) { $list = $list['data']; } + 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"; diff --git a/database.sql b/database.sql index 193b60c..7ad664f 100644 --- a/database.sql +++ b/database.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS users ( 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, From ff40a9b72c9076f5f05819bd3ddb4414a78fb91a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 17:45:48 +0000 Subject: [PATCH 03/10] Add admin features for services, users, wallet, and new user routes Co-authored-by: ayanayat18 --- app/Controllers/Admin/ServicesController.php | 41 ++++++++++++ app/Controllers/Admin/UsersController.php | 21 ++++++- app/Controllers/Admin/WalletController.php | 23 +++++++ .../User/NotificationsController.php | 19 ++++++ app/Controllers/User/PlaceOrderController.php | 62 +++++++++++++++++++ app/Views/admin/services/map.php | 36 +++++++++++ app/Views/admin/users/subscription.php | 24 +++++++ app/Views/admin/wallet/adjust.php | 20 ++++++ app/Views/user/notifications/index.php | 16 +++++ app/Views/user/orders/place.php | 24 +++++++ app/routes.php | 14 ++++- 11 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 app/Controllers/User/NotificationsController.php create mode 100644 app/Controllers/User/PlaceOrderController.php create mode 100644 app/Views/admin/services/map.php create mode 100644 app/Views/admin/users/subscription.php create mode 100644 app/Views/admin/wallet/adjust.php create mode 100644 app/Views/user/notifications/index.php create mode 100644 app/Views/user/orders/place.php diff --git a/app/Controllers/Admin/ServicesController.php b/app/Controllers/Admin/ServicesController.php index af15cb2..29098fd 100644 --- a/app/Controllers/Admin/ServicesController.php +++ b/app/Controllers/Admin/ServicesController.php @@ -3,6 +3,7 @@ use App\Core\Controller; use App\Core\DB; +use App\Core\CSRF; class ServicesController extends Controller { @@ -17,4 +18,44 @@ public function index(): void '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/UsersController.php b/app/Controllers/Admin/UsersController.php index 54e4eb0..5b60c6c 100644 --- a/app/Controllers/Admin/UsersController.php +++ b/app/Controllers/Admin/UsersController.php @@ -3,16 +3,35 @@ use App\Core\Controller; use App\Core\DB; +use App\Core\CSRF; class UsersController extends Controller { public function index(): void { $this->requireRole(['admin', 'super_admin']); - $users = DB::fetchAll('SELECT id, name, email, role, status, wallet_balance, subscription_expires_at, created_at FROM users ORDER BY id DESC'); + $users = DB::fetchAll('SELECT id, name, email, role, status, wallet_balance, price_markup_percent, subscription_expires_at, created_at FROM users ORDER BY id DESC'); $this->render('admin/users/index', [ 'title' => 'Users', 'users' => $users, ], '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'); + } } \ No newline at end of file diff --git a/app/Controllers/Admin/WalletController.php b/app/Controllers/Admin/WalletController.php index d46d31b..7065d78 100644 --- a/app/Controllers/Admin/WalletController.php +++ b/app/Controllers/Admin/WalletController.php @@ -3,6 +3,7 @@ use App\Core\Controller; use App\Core\DB; +use App\Core\CSRF; class WalletController extends Controller { @@ -15,4 +16,26 @@ public function index(): void '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/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/PlaceOrderController.php b/app/Controllers/User/PlaceOrderController.php new file mode 100644 index 0000000..1e894cb --- /dev/null +++ b/app/Controllers/User/PlaceOrderController.php @@ -0,0 +1,62 @@ +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); + $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, + ]); + $pdo->commit(); + $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/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/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/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/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/routes.php b/app/routes.php index d3ad80a..d8d34ea 100644 --- a/app/routes.php +++ b/app/routes.php @@ -33,7 +33,19 @@ $router->get('/wallet', 'User\\WalletController@index'); $router->post('/wallet/add', 'User\\WalletController@addFunds'); $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'); \ No newline at end of file +$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 From adadb716e202d3f44930851208bf5701e002f5d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 17:49:05 +0000 Subject: [PATCH 04/10] Implement order processing, API integration, and payment gateway support Co-authored-by: ayanayat18 --- app/Controllers/User/PlaceOrderController.php | 2 + app/Payments/BinancePayGateway.php | 23 ++++++++ app/Payments/BkashGateway.php | 23 ++++++++ app/Payments/NagadGateway.php | 23 ++++++++ app/Payments/RocketGateway.php | 23 ++++++++ app/Services/OrderProcessor.php | 58 +++++++++++++++++++ cron/update_orders.php | 10 +++- public/webhook/api_order_callback.php | 10 ++++ 8 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 app/Payments/BinancePayGateway.php create mode 100644 app/Payments/BkashGateway.php create mode 100644 app/Payments/NagadGateway.php create mode 100644 app/Payments/RocketGateway.php create mode 100644 app/Services/OrderProcessor.php diff --git a/app/Controllers/User/PlaceOrderController.php b/app/Controllers/User/PlaceOrderController.php index 1e894cb..75e08a0 100644 --- a/app/Controllers/User/PlaceOrderController.php +++ b/app/Controllers/User/PlaceOrderController.php @@ -51,6 +51,8 @@ public function submit(): void $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(); $this->redirect('/orders'); } catch (\Throwable $e) { diff --git a/app/Payments/BinancePayGateway.php b/app/Payments/BinancePayGateway.php new file mode 100644 index 0000000..49a920d --- /dev/null +++ b/app/Payments/BinancePayGateway.php @@ -0,0 +1,23 @@ + '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/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/OrderProcessor.php b/app/Services/OrderProcessor.php new file mode 100644 index 0000000..2f069db --- /dev/null +++ b/app/Services/OrderProcessor.php @@ -0,0 +1,58 @@ + $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) ?: []; + $payload = [ + 'service_id' => $order['remote_service_id'] ?? '', + 'input' => $inputData['input'] ?? '', + ]; + $res = $client->post('/orders', $payload); + $remoteId = (string)($res['id'] ?? $res['order_id'] ?? ''); + $remoteStatus = (string)($res['status'] ?? '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']); + $res = $client->get('/orders/' . urlencode((string)$order['api_order_id'])); + $remoteStatus = (string)($res['status'] ?? 'processing'); + $result = $res['result'] ?? 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/cron/update_orders.php b/cron/update_orders.php index 9555959..7cb4d83 100644 --- a/cron/update_orders.php +++ b/cron/update_orders.php @@ -11,9 +11,13 @@ } try { - // TODO: poll provider APIs and update local order statuses - DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['update-orders', 'ok', 'Update completed']); - echo "Update Orders: done\n"; + $orders = DB::fetchAll("SELECT id FROM orders WHERE api_id IS NOT NULL AND api_order_id IS NOT NULL AND status IN ('pending','processing','partial') ORDER BY id DESC LIMIT 200"); + $count = 0; + foreach ($orders as $o) { + try { App\Services\OrderProcessor::refreshOrder((int)$o['id']); $count++; } catch (\Throwable $e) {} + } + DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['update-orders', 'ok', 'Updated: ' . $count]); + echo "Update Orders: {$count}\n"; } catch (Throwable $e) { DB::insert('INSERT INTO cron_logs (job, status, message, ran_at) VALUES (?,?,?,NOW())', ['update-orders', 'error', $e->getMessage()]); echo "Error: " . $e->getMessage() . "\n"; diff --git a/public/webhook/api_order_callback.php b/public/webhook/api_order_callback.php index 1b5d48f..6e0a447 100644 --- a/public/webhook/api_order_callback.php +++ b/public/webhook/api_order_callback.php @@ -11,5 +11,15 @@ } $payload = file_get_contents('php://input') ?: json_encode($_REQUEST); +$data = json_decode((string)$payload, true) ?: []; +// Try to map callback to an order +$orderId = null; +if (!empty($data['order_id'])) { + $row = DB::fetch('SELECT id FROM orders WHERE api_order_id = :rid LIMIT 1', ['rid' => (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 From b5f65f9083fa671423c824c43fc5281c84e7213c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 17:51:36 +0000 Subject: [PATCH 05/10] Add multi-gateway wallet funding and user notifications Co-authored-by: ayanayat18 --- app/Controllers/User/PlaceOrderController.php | 1 + app/Controllers/User/WalletController.php | 32 +++++++++++++++++-- app/Core/Notifier.php | 22 +++++++++++++ app/Views/user/wallet/index.php | 9 +++++- app/routes.php | 1 + 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 app/Core/Notifier.php diff --git a/app/Controllers/User/PlaceOrderController.php b/app/Controllers/User/PlaceOrderController.php index 75e08a0..ec5e533 100644 --- a/app/Controllers/User/PlaceOrderController.php +++ b/app/Controllers/User/PlaceOrderController.php @@ -54,6 +54,7 @@ public function submit(): void // 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(); } diff --git a/app/Controllers/User/WalletController.php b/app/Controllers/User/WalletController.php index 80501b5..e37ee1c 100644 --- a/app/Controllers/User/WalletController.php +++ b/app/Controllers/User/WalletController.php @@ -5,7 +5,12 @@ use App\Core\Auth; use App\Core\DB; use App\Payments\PaypalGateway; +use App\Payments\BkashGateway; +use App\Payments\NagadGateway; +use App\Payments\RocketGateway; +use App\Payments\BinancePayGateway; use App\Core\CSRF; +use App\Core\Notifier; class WalletController extends Controller { @@ -28,13 +33,36 @@ public function addFunds(): void 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 = new PaypalGateway(); - $res = $gateway->createPayment(Auth::id(), $amount); + $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/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/Views/user/wallet/index.php b/app/Views/user/wallet/index.php index 1e5c9f3..4bd1e11 100644 --- a/app/Views/user/wallet/index.php +++ b/app/Views/user/wallet/index.php @@ -16,7 +16,14 @@
$ - + +
diff --git a/app/routes.php b/app/routes.php index d8d34ea..08beb11 100644 --- a/app/routes.php +++ b/app/routes.php @@ -32,6 +32,7 @@ $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'); From a1b7afbf3391a30d7bd455320fd423d03b16cbae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 17:55:53 +0000 Subject: [PATCH 06/10] Add CRUD for service categories and improve services management Co-authored-by: ayanayat18 --- .../Admin/ServiceCategoriesController.php | 72 +++++++++++++++++++ .../Admin/ServicesCrudController.php | 71 ++++++++++++++++++ app/Views/admin/categories/create.php | 13 ++++ app/Views/admin/categories/edit.php | 14 ++++ app/Views/admin/categories/index.php | 28 ++++++++ app/Views/admin/services/create.php | 19 +++++ app/Views/admin/services/edit.php | 20 ++++++ app/Views/admin/services/index.php | 16 +++++ app/routes.php | 11 +++ 9 files changed, 264 insertions(+) create mode 100644 app/Controllers/Admin/ServiceCategoriesController.php create mode 100644 app/Controllers/Admin/ServicesCrudController.php create mode 100644 app/Views/admin/categories/create.php create mode 100644 app/Views/admin/categories/edit.php create mode 100644 app/Views/admin/categories/index.php create mode 100644 app/Views/admin/services/create.php create mode 100644 app/Views/admin/services/edit.php 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/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/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/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 index af41a4d..f47efd7 100644 --- a/app/Views/admin/services/index.php +++ b/app/Views/admin/services/index.php @@ -1,5 +1,12 @@

Services

+
@@ -9,6 +16,7 @@ + @@ -19,6 +27,14 @@ + diff --git a/app/routes.php b/app/routes.php index 08beb11..a69a0e3 100644 --- a/app/routes.php +++ b/app/routes.php @@ -18,6 +18,17 @@ $router->get('/admin', 'Admin\\DashboardController@index'); $router->get('/admin/users', 'Admin\\UsersController@index'); $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'); From 69602b400a0761738a4b62c87a672480119df94f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 17:58:32 +0000 Subject: [PATCH 07/10] Add configurable API paths and minimum balance setting Co-authored-by: ayanayat18 --- app/Controllers/Admin/SettingsController.php | 17 ++++++ app/Controllers/User/PlaceOrderController.php | 5 ++ app/Core/Settings.php | 31 ++++++++++ app/Services/ApiClient.php | 18 +++++- app/Services/OrderProcessor.php | 11 ++-- app/Views/admin/settings/index.php | 61 +++++++++++++++---- app/routes.php | 1 + cron/sync_apis.php | 3 +- 8 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 app/Core/Settings.php diff --git a/app/Controllers/Admin/SettingsController.php b/app/Controllers/Admin/SettingsController.php index d4fb4c0..cef844d 100644 --- a/app/Controllers/Admin/SettingsController.php +++ b/app/Controllers/Admin/SettingsController.php @@ -3,6 +3,8 @@ use App\Core\Controller; use App\Core\DB; +use App\Core\CSRF; +use App\Core\Settings; class SettingsController extends Controller { @@ -13,6 +15,21 @@ public function index(): void $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}'), ], '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}'))); + $this->redirect('/admin/settings'); + } } \ No newline at end of file diff --git a/app/Controllers/User/PlaceOrderController.php b/app/Controllers/User/PlaceOrderController.php index ec5e533..5afb73c 100644 --- a/app/Controllers/User/PlaceOrderController.php +++ b/app/Controllers/User/PlaceOrderController.php @@ -35,6 +35,11 @@ public function submit(): void $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 { 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/Services/ApiClient.php b/app/Services/ApiClient.php index 152b19b..79d5580 100644 --- a/app/Services/ApiClient.php +++ b/app/Services/ApiClient.php @@ -57,9 +57,21 @@ private function request(string $method, string $url, array $data = []): array return is_array($json) ? $json : []; } - public function fetchServices(): array + public function fetchServices(string $path = '/services'): array { - // Placeholder: provider-specific endpoint mapping - return $this->get('/services'); + 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 index 2f069db..0c04b20 100644 --- a/app/Services/OrderProcessor.php +++ b/app/Services/OrderProcessor.php @@ -13,11 +13,8 @@ public static function submitToApi(int $orderId): void } $client = new ApiClient($order['base_url'], $order['api_key']); $inputData = json_decode((string)$order['input_data'], true) ?: []; - $payload = [ - 'service_id' => $order['remote_service_id'] ?? '', - 'input' => $inputData['input'] ?? '', - ]; - $res = $client->post('/orders', $payload); + $placePath = \App\Core\Settings::get('dhru_place_order_path', '/orders'); + $res = $client->placeOrder($placePath, (string)($order['remote_service_id'] ?? ''), (string)($inputData['input'] ?? '')); $remoteId = (string)($res['id'] ?? $res['order_id'] ?? ''); $remoteStatus = (string)($res['status'] ?? 'processing'); if ($remoteId) { @@ -32,7 +29,9 @@ 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']); - $res = $client->get('/orders/' . urlencode((string)$order['api_order_id'])); + $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); $remoteStatus = (string)($res['status'] ?? 'processing'); $result = $res['result'] ?? null; DB::query('UPDATE orders SET status = :st, result_data = :res, updated_at = NOW() WHERE id = :id', [ diff --git a/app/Views/admin/settings/index.php b/app/Views/admin/settings/index.php index b615157..643567a 100644 --- a/app/Views/admin/settings/index.php +++ b/app/Views/admin/settings/index.php @@ -1,15 +1,50 @@ - +

Settings

-
-
Name Price StatusActions
$ Enabled' : 'Disabled' ?> + Edit +
+ + + +
+
- - - - - - - - - -
KeyValue
+
+
+
+
+ +
+ + +
+
Dhru-like API Paths
+
+ + +
+
+ + +
+
+ + +
Use {id} placeholder for order ID.
+
+
+ +
+
+
+
+
+
All Settings
+
+ + + + + + + + + + +
KeyValue
+
+
+
+
\ No newline at end of file diff --git a/app/routes.php b/app/routes.php index a69a0e3..d81861c 100644 --- a/app/routes.php +++ b/app/routes.php @@ -36,6 +36,7 @@ $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 diff --git a/cron/sync_apis.php b/cron/sync_apis.php index d46cc96..cb64f88 100644 --- a/cron/sync_apis.php +++ b/cron/sync_apis.php @@ -17,7 +17,8 @@ foreach ($apis as $api) { $client = new App\Services\ApiClient($api['base_url'], $api['api_key']); try { - $list = $client->fetchServices(); + $path = App\Core\Settings::get('dhru_services_path', '/services'); + $list = $client->fetchServices($path); if (isset($list['data']) && is_array($list['data'])) { $list = $list['data']; } foreach ($list as $item) { $remoteId = (string)($item['id'] ?? $item['service_id'] ?? ''); From 81a4bca59cfb26bfe67728de2db673c6b2741ac3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 18:02:02 +0000 Subject: [PATCH 08/10] Add configurable API keys for Dhru-like service integrations Co-authored-by: ayanayat18 --- README.md | 2 ++ app/Controllers/Admin/SettingsController.php | 12 ++++++++ app/Services/OrderProcessor.php | 21 ++++++++++---- app/Views/admin/settings/index.php | 29 ++++++++++++++++++++ cron/sync_apis.php | 3 +- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dd2d622..774e515 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Use the following examples, adjusting paths and PHP binary as needed. ``` /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 diff --git a/app/Controllers/Admin/SettingsController.php b/app/Controllers/Admin/SettingsController.php index cef844d..d0f901f 100644 --- a/app/Controllers/Admin/SettingsController.php +++ b/app/Controllers/Admin/SettingsController.php @@ -19,6 +19,12 @@ public function index(): void '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'); } @@ -30,6 +36,12 @@ public function save(): void 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/Services/OrderProcessor.php b/app/Services/OrderProcessor.php index 0c04b20..d4fd0a2 100644 --- a/app/Services/OrderProcessor.php +++ b/app/Services/OrderProcessor.php @@ -14,9 +14,18 @@ public static function submitToApi(int $orderId): void $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'); - $res = $client->placeOrder($placePath, (string)($order['remote_service_id'] ?? ''), (string)($inputData['input'] ?? '')); - $remoteId = (string)($res['id'] ?? $res['order_id'] ?? ''); - $remoteStatus = (string)($res['status'] ?? 'processing'); + $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, @@ -32,8 +41,10 @@ public static function refreshOrder(int $orderId): void $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); - $remoteStatus = (string)($res['status'] ?? 'processing'); - $result = $res['result'] ?? null; + $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, diff --git a/app/Views/admin/settings/index.php b/app/Views/admin/settings/index.php index 643567a..dafc80d 100644 --- a/app/Views/admin/settings/index.php +++ b/app/Views/admin/settings/index.php @@ -23,6 +23,35 @@
Use {id} placeholder for order ID.
+
Dhru-like Keys
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/cron/sync_apis.php b/cron/sync_apis.php index cb64f88..e4cf37d 100644 --- a/cron/sync_apis.php +++ b/cron/sync_apis.php @@ -19,7 +19,8 @@ try { $path = App\Core\Settings::get('dhru_services_path', '/services'); $list = $client->fetchServices($path); - if (isset($list['data']) && is_array($list['data'])) { $list = $list['data']; } + $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'] ?? ''); From 0fda357dfbec938bceafb8c8eaceab044ac98f56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 18:04:57 +0000 Subject: [PATCH 09/10] Add pagination support for users and orders with search functionality Co-authored-by: ayanayat18 --- app/Controllers/Admin/UsersController.php | 15 ++++++++- app/Controllers/User/OrdersController.php | 7 ++++- app/Core/Pagination.php | 38 +++++++++++++++++++++++ app/Views/admin/users/index.php | 7 +++++ app/Views/user/orders/index.php | 3 ++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 app/Core/Pagination.php diff --git a/app/Controllers/Admin/UsersController.php b/app/Controllers/Admin/UsersController.php index 5b60c6c..38d311e 100644 --- a/app/Controllers/Admin/UsersController.php +++ b/app/Controllers/Admin/UsersController.php @@ -4,16 +4,29 @@ use App\Core\Controller; use App\Core\DB; use App\Core\CSRF; +use App\Core\Pagination; class UsersController extends Controller { public function index(): void { $this->requireRole(['admin', 'super_admin']); - $users = DB::fetchAll('SELECT id, name, email, role, status, wallet_balance, price_markup_percent, subscription_expires_at, created_at FROM users ORDER BY id DESC'); + $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'); } diff --git a/app/Controllers/User/OrdersController.php b/app/Controllers/User/OrdersController.php index 6c28c8e..7734cdd 100644 --- a/app/Controllers/User/OrdersController.php +++ b/app/Controllers/User/OrdersController.php @@ -4,6 +4,7 @@ use App\Core\Controller; use App\Core\Auth; use App\Core\DB; +use App\Core\Pagination; class OrdersController extends Controller { @@ -11,10 +12,14 @@ public function index(): void { if (!Auth::check()) { $this->redirect('/login'); } $userId = Auth::id(); - $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', ['uid' => $userId]); + $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/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/Views/admin/users/index.php b/app/Views/admin/users/index.php index 94b9066..7bd6658 100644 --- a/app/Views/admin/users/index.php +++ b/app/Views/admin/users/index.php @@ -1,6 +1,10 @@

Users

+
+ + +
@@ -37,4 +41,7 @@
+
+
+
\ No newline at end of file diff --git a/app/Views/user/orders/index.php b/app/Views/user/orders/index.php index 99a59d6..abcf97c 100644 --- a/app/Views/user/orders/index.php +++ b/app/Views/user/orders/index.php @@ -23,4 +23,7 @@ + +
+
\ No newline at end of file From 12be89c6d7e4d51c30969144e98a0a9682c54f4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 18:07:39 +0000 Subject: [PATCH 10/10] Add user edit functionality in admin users management Co-authored-by: ayanayat18 --- app/Controllers/Admin/UsersController.php | 21 ++++++++++++++ app/Views/admin/users/edit.php | 34 +++++++++++++++++++++++ app/Views/admin/users/index.php | 4 +++ app/routes.php | 2 ++ 4 files changed, 61 insertions(+) create mode 100644 app/Views/admin/users/edit.php diff --git a/app/Controllers/Admin/UsersController.php b/app/Controllers/Admin/UsersController.php index 38d311e..f1ef6bd 100644 --- a/app/Controllers/Admin/UsersController.php +++ b/app/Controllers/Admin/UsersController.php @@ -47,4 +47,25 @@ public function updateSubscription(): void } $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/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 index 7bd6658..a97e069 100644 --- a/app/Views/admin/users/index.php +++ b/app/Views/admin/users/index.php @@ -16,8 +16,10 @@ Role Status Wallet + Markup % Subscription Created + @@ -35,8 +37,10 @@ $ + + Edit diff --git a/app/routes.php b/app/routes.php index d81861c..d2da145 100644 --- a/app/routes.php +++ b/app/routes.php @@ -17,6 +17,8 @@ // 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');