diff --git a/public_html/.htaccess b/public_html/.htaccess new file mode 100644 index 0000000..28ee413 --- /dev/null +++ b/public_html/.htaccess @@ -0,0 +1,12 @@ +Options +FollowSymLinks +RewriteEngine On +RewriteBase / + +RewriteCond %{REQUEST_URI} ^/(admin|api|bots|assets|sql|app|includes|uploads)/ [NC] +RewriteRule ^ - [L] + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^([A-Za-z0-9-]+)/?$ post.php?slug=$1 [L,QSA] + +RewriteRule ^$ index.php [L] diff --git a/public_html/README.md b/public_html/README.md new file mode 100644 index 0000000..7ea9925 --- /dev/null +++ b/public_html/README.md @@ -0,0 +1,57 @@ +# Báo điện tử PHP thuần (shared hosting) + +## Công nghệ & Kiến trúc +- PHP 8.x + MySQL, HTML/CSS/JS thuần, PDO prepared statements. +- Cấu hình qua `app/config/config.php` (không .env), cache file tại `app/cache`. +- SEO slug rewrite bằng `.htaccess` (không ảnh hưởng `/admin`, `/api`, `/bots`, `/uploads`). +- 3 theme (A/B/C) đổi nhanh trong admin, mobile-first, hiệu ứng 3D nhẹ. + +## Cài đặt nhanh trên shared hosting (DataOnline.vn) +1. Upload toàn bộ `public_html` lên root hosting (Apache + mod_rewrite). +2. Tạo database MySQL, import `sql/database.sql`. +3. Tạo cấu hình: truy cập `https://your-domain/install.php` để nhập DB/BASE_URL (xong nên xóa file này). Hoặc sửa tay `app/config/config.php`. +4. Đảm bảo `app/cache` và `uploads` có quyền ghi (chmod 755/775 tùy hosting). +5. Đăng nhập admin: `https://your-domain/admin/login.php` (mặc định `admin / admin123`, hãy đổi mật khẩu trong DB), cấu hình tên site, logo/banner, theme, slug mode. + +## Chạy & URL +- Trang chủ: `/` +- Bài viết: `/slug` hoặc `/slug-id` (chọn trong Cài đặt > Slug mode). +- Admin: `/admin/` +- API tracking: `/api/track.php` +- Bot webhook: `/bots/telegram/webhook.php` + +## Tính năng frontend +- Trang chủ: banner, logo (ẩn nếu trống), tìm kiếm, lọc danh mục, phân trang. +- Trang bài: breadcrumbs, chia sẻ, meta/OG tiếng Việt, schema NewsArticle, media Telegram (ảnh/video link), bài liên quan. + +## Quảng cáo Shopee (interstitial hợp lệ) +- Cấu hình trong Admin > Cài đặt: bật/tắt, link Shopee, title/body, tần suất (một lần/phiên, mỗi N giờ, mỗi N bài). +- Overlay có 2 nút: “Đi tới Shopee (Ưu đãi)” mở tab mới Shopee (noopener) + tracking, “Vào đọc bài (Bỏ qua)” đóng overlay đọc ngay. Không hijack click toàn trang. + +## Tracking & chống click ảo +- Event: `article_view`, `ad_forced_redirect`, `ad_close_click`. +- IP+UA hash (SESSION_SALT) + rate limit cấu hình (mặc định 5 sự kiện/60s) + chặn UA bot regex. +- Gửi bằng `sendBeacon`/fetch keepalive nên không cản redirect. + +## Admin +- Đăng nhập bảo vệ CSRF + khóa tạm khi sai quá số lần (cấu hình). +- CRUD bài (title, slug, meta, tags, category, media Telegram, draft/publish, copy), ping Telegram khi publish. +- Cài đặt: theme A/B/C, slug mode, logo/banner upload an toàn (image mime), màu sắc, hero text, Shopee ad, rate limit, UA bot regex, Telegram token/chat id. +- Thống kê: view/click, CTR, reset (log), export CSV. + +## Telegram bot +- Thư mục `bots/telegram/` (bot.php, webhook.php, keyboard.php). +- Webhook: `https://api.telegram.org/bot/setWebhook?url=https://your-domain/bots/telegram/webhook.php` +- Lệnh: `/stats`, `/reset`, `/add title|excerpt|content|media`, `/ad link|title|body`. + +## Rewrite & bảo trì +- `.htaccess` đã chặn rewrite thư mục hệ thống (`admin|api|bots|assets|sql|app|includes|uploads`). +- Backup/restore: export DB, sao lưu `app/config/config.php`, `uploads/`, `app/cache` có thể xóa để rebuild. + +## Checklist kiểm thử nhanh +- Cài DB bằng install.php, xóa file sau khi hoàn tất. +- Slug rewrite hoạt động cho /slug và /slug-id. +- Quảng cáo: rỗng link => không hiển thị; có link => overlay với 2 nút, click “Shopee” mở tab mới + tracking. +- Tracking ghi `article_view` và `ad_forced_redirect`, CTR hiển thị ở dashboard. +- Admin CRUD + copy, publish/draft, đổi theme/slug mode, upload logo/banner. +- Bot Telegram nhận `/stats`, `/reset`, `/ad`, `/add` với admin ID whitelist. diff --git a/public_html/admin/dashboard.php b/public_html/admin/dashboard.php new file mode 100644 index 0000000..c5ab7c9 --- /dev/null +++ b/public_html/admin/dashboard.php @@ -0,0 +1,21 @@ + +
+

Tổng quan

+
+
Bài public
+
Lượt đọc
+
Redirect quảng cáo
+
%
CTR
+
+
+ diff --git a/public_html/admin/includes/auth_check.php b/public_html/admin/includes/auth_check.php new file mode 100644 index 0000000..edf8418 --- /dev/null +++ b/public_html/admin/includes/auth_check.php @@ -0,0 +1,3 @@ + + + + diff --git a/public_html/admin/includes/header.php b/public_html/admin/includes/header.php new file mode 100644 index 0000000..d980f9a --- /dev/null +++ b/public_html/admin/includes/header.php @@ -0,0 +1,27 @@ + + + + + + + Admin | <?php echo e($settings['site_name'] ?? 'News'); ?> + + + + +
+
Admin
+ +
+
diff --git a/public_html/admin/login.php b/public_html/admin/login.php new file mode 100644 index 0000000..7c05e9f --- /dev/null +++ b/public_html/admin/login.php @@ -0,0 +1,43 @@ + + + + + + + Đăng nhập + + + +
+

Đăng nhập quản trị

+

+
+ + + + + + +
+
+ + diff --git a/public_html/admin/logout.php b/public_html/admin/logout.php new file mode 100644 index 0000000..c650a85 --- /dev/null +++ b/public_html/admin/logout.php @@ -0,0 +1,5 @@ + '', 'slug' => '', 'excerpt' => '', 'content' => '', 'media_url' => '', 'is_public' => 1, 'published_at' => date('Y-m-d H:i:s'), 'category'=>'', 'tags'=>'', 'meta_title'=>'', 'meta_description'=>'', 'meta_keywords'=>'', 'og_image'=>'']; +if (!empty($_GET['copy'])) { + $copy = find_post_by_id((int)$_GET['copy']); + if ($copy) { + $post = $copy; + $post['slug'] = unique_slug($copy['slug'] . '-copy'); + $post['title'] .= ' (Copy)'; + } +} + +$error = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!verify_csrf($_POST['csrf'] ?? '')) { + $error = 'Token không hợp lệ'; + } else { + $title = sanitize_text($_POST['title'] ?? ''); + $slug = unique_slug($_POST['slug'] ?: $title); + $data = [ + 'title' => $title, + 'slug' => $slug, + 'excerpt' => sanitize_text($_POST['excerpt'] ?? ''), + 'content' => trim($_POST['content'] ?? ''), + 'media_url' => sanitize_text($_POST['media_url'] ?? ''), + 'is_public' => isset($_POST['is_public']) ? 1 : 0, + 'published_at' => sanitize_text($_POST['published_at'] ?? date('Y-m-d H:i:s')), + 'category' => sanitize_text($_POST['category'] ?? ''), + 'tags' => sanitize_text($_POST['tags'] ?? ''), + 'meta_title' => sanitize_text($_POST['meta_title'] ?? $title), + 'meta_description' => sanitize_text($_POST['meta_description'] ?? ($_POST['excerpt'] ?? '')), + 'meta_keywords' => sanitize_text($_POST['meta_keywords'] ?? ''), + 'og_image' => sanitize_text($_POST['og_image'] ?? ($_POST['media_url'] ?? '')), + ]; + $id = create_post($data); + admin_log('create_post'); + if ($data['is_public']) { + telegram_notify("Bài mới: " . $data['title'] . "\n" . base_url(ltrim(article_url(['slug'=>$data['slug'],'id'=>$id]), '/'))); + } + header('Location: /admin/posts.php'); + exit; + } +} +include __DIR__ . '/includes/header.php'; +?> +
+

Thêm bài viết

+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ diff --git a/public_html/admin/post_delete.php b/public_html/admin/post_delete.php new file mode 100644 index 0000000..68908dd --- /dev/null +++ b/public_html/admin/post_delete.php @@ -0,0 +1,14 @@ + $title, + 'slug' => $slug, + 'excerpt' => sanitize_text($_POST['excerpt'] ?? ''), + 'content' => trim($_POST['content'] ?? ''), + 'media_url' => sanitize_text($_POST['media_url'] ?? ''), + 'is_public' => isset($_POST['is_public']) ? 1 : 0, + 'published_at' => sanitize_text($_POST['published_at'] ?? date('Y-m-d H:i:s')), + 'category' => sanitize_text($_POST['category'] ?? ''), + 'tags' => sanitize_text($_POST['tags'] ?? ''), + 'meta_title' => sanitize_text($_POST['meta_title'] ?? $title), + 'meta_description' => sanitize_text($_POST['meta_description'] ?? ($_POST['excerpt'] ?? '')), + 'meta_keywords' => sanitize_text($_POST['meta_keywords'] ?? ''), + 'og_image' => sanitize_text($_POST['og_image'] ?? ($_POST['media_url'] ?? '')), + ]; + update_post($id, $data); + admin_log('update_post'); + header('Location: /admin/posts.php'); + exit; + } +} +include __DIR__ . '/includes/header.php'; +?> +
+

Sửa bài viết

+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ diff --git a/public_html/admin/posts.php b/public_html/admin/posts.php new file mode 100644 index 0000000..69861d2 --- /dev/null +++ b/public_html/admin/posts.php @@ -0,0 +1,35 @@ + +
+
+

Bài viết

+
+ Thêm bài +
+
+ + + + + + + + + + + + + + + +
IDTiêu đềDanh mụcSlugNgàyTTHành động
+ Sửa + Xoá + Copy +
+
+ diff --git a/public_html/admin/settings.php b/public_html/admin/settings.php new file mode 100644 index 0000000..02bab1c --- /dev/null +++ b/public_html/admin/settings.php @@ -0,0 +1,122 @@ + sanitize_text($_POST['site_name'] ?? ''), + 'logo_url' => $logoUpload ?: sanitize_text($_POST['logo_url'] ?? ''), + 'banner_url' => $bannerUpload ?: sanitize_text($_POST['banner_url'] ?? ''), + 'hero_text' => sanitize_text($_POST['hero_text'] ?? ''), + 'meta_description' => sanitize_text($_POST['meta_description'] ?? ''), + 'theme' => sanitize_text($_POST['theme'] ?? 'theme-a'), + 'slug_mode' => sanitize_text($_POST['slug_mode'] ?? 'slug'), + 'ad_enabled' => isset($_POST['ad_enabled']) ? 1 : 0, + 'ad_link' => sanitize_text($_POST['ad_link'] ?? ''), + 'ad_title' => sanitize_text($_POST['ad_title'] ?? ''), + 'ad_body' => sanitize_text($_POST['ad_body'] ?? ''), + 'ad_frequency' => sanitize_text($_POST['ad_frequency'] ?? 'once'), + 'ad_interval_hours' => (int)($_POST['ad_interval_hours'] ?? 4), + 'ad_every_posts' => (int)($_POST['ad_every_posts'] ?? 3), + 'rate_window_seconds' => (int)($_POST['rate_window_seconds'] ?? 60), + 'rate_max_events' => (int)($_POST['rate_max_events'] ?? 5), + 'bot_list' => trim($_POST['bot_list'] ?? 'bot|crawl|spider|curl'), + 'telegram_token' => sanitize_text($_POST['telegram_token'] ?? ''), + 'telegram_chat_id' => sanitize_text($_POST['telegram_chat_id'] ?? ''), + ]; + save_settings($data + $settings); + $settings = get_settings(); + $message = 'Đã lưu'; + admin_log('update_settings'); + } +} +include __DIR__ . '/includes/header.php'; +?> +
+

Cài đặt

+

+
+ + + + + + + + + + + + + + + + + +
+

Quảng cáo Shopee

+ + + + + + + + + + + + + +
+

Chống spam/UA bot

+ + + + + + +
+

Telegram

+ + + + + +
+
+ diff --git a/public_html/admin/stats.php b/public_html/admin/stats.php new file mode 100644 index 0000000..08ea46d --- /dev/null +++ b/public_html/admin/stats.php @@ -0,0 +1,42 @@ +query("SELECT slug, event_type, COUNT(*) as total FROM click_events GROUP BY slug, event_type")->fetchAll(); +if ($export) { + header('Content-Type: text/csv'); + header('Content-Disposition: attachment; filename=\"stats.csv\"'); + echo "slug,event_type,total\n"; + foreach ($statsByPost as $row) { + echo "{$row['slug']},{$row['event_type']},{$row['total']}\n"; + } + exit; +} +$message = ''; +if (isset($_POST['reset'])) { + reset_tracking(); + $message = 'Đã reset'; + admin_log('reset_stats'); +} +$stats = tracking_stats(); +$views = $stats['article_view'] ?? 0; +$clicks = $stats['ad_forced_redirect'] ?? 0; +$ctr = $views ? round($clicks / $views * 100, 2) : 0; +include __DIR__ . '/includes/header.php'; +?> +
+

Thống kê

+

+
    +
  • article_view:
  • +
  • ad_forced_redirect:
  • +
  • ad_close_click:
  • +
  • CTR: %
  • +
+

Export CSV

+
+ +
+
+ diff --git a/public_html/admin/theme.php b/public_html/admin/theme.php new file mode 100644 index 0000000..6fc9882 --- /dev/null +++ b/public_html/admin/theme.php @@ -0,0 +1,35 @@ + sanitize_text($_POST['primary_color'] ?? '#e63946'), + 'secondary_color' => sanitize_text($_POST['secondary_color'] ?? '#1d3557'), + ]; + save_settings($data + $settings); + $settings = get_settings(); + $message = 'Đã lưu'; + admin_log('update_theme'); + } +} +include __DIR__ . '/includes/header.php'; +?> +
+

Giao diện

+

+
+ + + + + + +
+
+ diff --git a/public_html/api/stats.php b/public_html/api/stats.php new file mode 100644 index 0000000..a11d681 --- /dev/null +++ b/public_html/api/stats.php @@ -0,0 +1,4 @@ + 'error']); + exit; +} + +$ok = track_event($event, $slug, $token); +header('Content-Type: application/json'); +echo json_encode(['status' => $ok ? 'ok' : 'ignored']); diff --git a/public_html/app/config/config.php b/public_html/app/config/config.php new file mode 100644 index 0000000..a23309b --- /dev/null +++ b/public_html/app/config/config.php @@ -0,0 +1,18 @@ + 'localhost', + 'DB_NAME' => 'decrypt_news', + 'DB_USER' => 'root', + 'DB_PASS' => '', + 'BASE_URL' => 'http://localhost', + 'SESSION_SALT' => 'change_me_to_random_salt', + 'TELEGRAM_BOT_TOKEN' => '', + 'TELEGRAM_CHAT_ID' => '', + 'TELEGRAM_ADMIN_IDS' => [123456789], + 'CACHE_PATH' => __DIR__ . '/../cache', + 'CACHE_TTL' => [ + 'settings' => 300, + 'home' => 45, + 'article' => 45, + ], +]; diff --git a/public_html/app/lib/auth.php b/public_html/app/lib/auth.php new file mode 100644 index 0000000..72669cf --- /dev/null +++ b/public_html/app/lib/auth.php @@ -0,0 +1,62 @@ +prepare('INSERT INTO admin_logs (admin_id, action) VALUES (:aid, :action)'); + $stmt->execute([':aid' => $_SESSION['admin_id'], ':action' => $action]); +} + +function is_logged_in(): bool +{ + return !empty($_SESSION['admin_id']); +} + +function require_login(): void +{ + if (!is_logged_in()) { + header('Location: /admin/login.php'); + exit; + } +} + +function login(string $username, string $password): bool +{ + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT id, password_hash, failed_attempts, locked_until FROM admins WHERE username = :u LIMIT 1'); + $stmt->execute([':u' => $username]); + $user = $stmt->fetch(); + if ($user && !empty($user['locked_until']) && strtotime($user['locked_until']) > time()) { + return false; + } + if ($user && password_verify($password, $user['password_hash'])) { + session_regenerate_id(true); + $_SESSION['admin_id'] = $user['id']; + admin_log('login'); + $pdo->prepare('UPDATE admins SET failed_attempts = 0, locked_until = NULL WHERE id = :id')->execute([':id' => $user['id']]); + return true; + } + if ($user) { + $attempts = (int)$user['failed_attempts'] + 1; + $lockMinutes = setting('login_lock_minutes', 10); + $update = $pdo->prepare('UPDATE admins SET failed_attempts = :a, locked_until = CASE WHEN :a >= :max THEN DATE_ADD(NOW(), INTERVAL :lockm MINUTE) ELSE NULL END WHERE id = :id'); + $update->execute([':a' => $attempts, ':max' => (int)setting('login_max_attempts', 5), ':lockm' => (int)$lockMinutes, ':id' => $user['id']]); + } + return false; +} + +function logout(): void +{ + admin_log('logout'); + $_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(); +} diff --git a/public_html/app/lib/cache.php b/public_html/app/lib/cache.php new file mode 100644 index 0000000..5afdcbb --- /dev/null +++ b/public_html/app/lib/cache.php @@ -0,0 +1,31 @@ + $ttl) { + return null; + } + $data = file_get_contents($file); + return $data === false ? null : unserialize($data); +} + +function cache_set(string $key, $value): void +{ + global $config; + if (!is_dir($config['CACHE_PATH'])) { + mkdir($config['CACHE_PATH'], 0777, true); + } + file_put_contents(cache_path($key), serialize($value), LOCK_EX); +} diff --git a/public_html/app/lib/csrf.php b/public_html/app/lib/csrf.php new file mode 100644 index 0000000..5262e9c --- /dev/null +++ b/public_html/app/lib/csrf.php @@ -0,0 +1,27 @@ + true, + 'samesite' => 'Lax', + 'secure' => $secure, + ]); + session_name('news_session'); + session_start(); +} + +if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(24)); +} + +function csrf_token(): string +{ + return $_SESSION['csrf_token'] ?? ''; +} + +function verify_csrf(string $token): bool +{ + return hash_equals($_SESSION['csrf_token'] ?? '', $token); +} diff --git a/public_html/app/lib/db.php b/public_html/app/lib/db.php new file mode 100644 index 0000000..8124f4b --- /dev/null +++ b/public_html/app/lib/db.php @@ -0,0 +1,21 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return self::$instance; + } +} diff --git a/public_html/app/lib/helpers.php b/public_html/app/lib/helpers.php new file mode 100644 index 0000000..6f05c2e --- /dev/null +++ b/public_html/app/lib/helpers.php @@ -0,0 +1,67 @@ + $chatId, 'text' => $message, 'parse_mode' => 'HTML']; + @file_get_contents($url, false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => json_encode($payload), + 'timeout' => 4, + ] + ])); +} diff --git a/public_html/app/lib/posts.php b/public_html/app/lib/posts.php new file mode 100644 index 0000000..cfa0ef2 --- /dev/null +++ b/public_html/app/lib/posts.php @@ -0,0 +1,170 @@ +prepare($sql); + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v); + } + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->bindValue(':off', $offset, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); +} + +function count_posts(bool $onlyPublic = true, string $search = '', string $category = ''): int +{ + $sql = 'SELECT COUNT(*) FROM articles '; + $conds = []; + $params = []; + if ($onlyPublic) { + $conds[] = 'is_public = 1'; + } + if ($search !== '') { + $conds[] = '(title LIKE :q OR slug LIKE :q OR tags LIKE :q)'; + $params[':q'] = '%' . $search . '%'; + } + if ($category !== '') { + $conds[] = 'category = :cat'; + $params[':cat'] = $category; + } + if ($conds) { + $sql .= 'WHERE ' . implode(' AND ', $conds); + } + $stmt = DB::conn()->prepare($sql); + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v); + } + $stmt->execute(); + return (int)$stmt->fetchColumn(); +} + +function find_post_by_slug(string $slug): ?array +{ + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM articles WHERE slug = :slug LIMIT 1'); + $stmt->execute([':slug' => $slug]); + $post = $stmt->fetch(); + return $post ?: null; +} + +function find_post_by_id(int $id): ?array +{ + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM articles WHERE id = :id'); + $stmt->execute([':id' => $id]); + $post = $stmt->fetch(); + return $post ?: null; +} + +function related_posts(string $category, int $currentId, int $limit = 3): array +{ + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT id, title, slug, published_at FROM articles WHERE is_public = 1 AND category = :cat AND id <> :id ORDER BY published_at DESC LIMIT :lim'); + $stmt->bindValue(':cat', $category); + $stmt->bindValue(':id', $currentId, PDO::PARAM_INT); + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); +} + +function create_post(array $data): int +{ + $pdo = DB::conn(); + $stmt = $pdo->prepare('INSERT INTO articles (title, slug, excerpt, content, media_url, is_public, published_at, category, tags, meta_title, meta_description, meta_keywords, og_image) VALUES (:title, :slug, :excerpt, :content, :media_url, :is_public, :published_at, :category, :tags, :meta_title, :meta_description, :meta_keywords, :og_image)'); + $stmt->execute([ + ':title' => $data['title'], + ':slug' => $data['slug'], + ':excerpt' => $data['excerpt'], + ':content' => $data['content'], + ':media_url' => $data['media_url'], + ':is_public' => (int)$data['is_public'], + ':published_at' => $data['published_at'] ?: date('Y-m-d H:i:s'), + ':category' => $data['category'] ?? '', + ':tags' => $data['tags'] ?? '', + ':meta_title' => $data['meta_title'] ?? $data['title'], + ':meta_description' => $data['meta_description'] ?? $data['excerpt'], + ':meta_keywords' => $data['meta_keywords'] ?? '', + ':og_image' => $data['og_image'] ?? $data['media_url'] ?? '', + ]); + return (int)$pdo->lastInsertId(); +} + +function update_post(int $id, array $data): void +{ + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE articles SET title = :title, slug = :slug, excerpt = :excerpt, content = :content, media_url = :media_url, is_public = :is_public, published_at = :published_at, category = :category, tags = :tags, meta_title = :meta_title, meta_description = :meta_description, meta_keywords = :meta_keywords, og_image = :og_image WHERE id = :id'); + $stmt->execute([ + ':title' => $data['title'], + ':slug' => $data['slug'], + ':excerpt' => $data['excerpt'], + ':content' => $data['content'], + ':media_url' => $data['media_url'], + ':is_public' => (int)$data['is_public'], + ':published_at' => $data['published_at'] ?: date('Y-m-d H:i:s'), + ':category' => $data['category'] ?? '', + ':tags' => $data['tags'] ?? '', + ':meta_title' => $data['meta_title'] ?? $data['title'], + ':meta_description' => $data['meta_description'] ?? $data['excerpt'], + ':meta_keywords' => $data['meta_keywords'] ?? '', + ':og_image' => $data['og_image'] ?? $data['media_url'] ?? '', + ':id' => $id, + ]); +} + +function delete_post(int $id): void +{ + $pdo = DB::conn(); + $stmt = $pdo->prepare('DELETE FROM articles WHERE id = :id'); + $stmt->execute([':id' => $id]); +} + +function copy_post(int $id): ?int +{ + $post = find_post_by_id($id); + if (!$post) { + return null; + } + $post['slug'] = unique_slug($post['slug'] . '-copy'); + $post['title'] .= ' (Copy)'; + return create_post($post); +} + +function unique_slug(string $title): string +{ + $base = slugify($title); + $slug = $base; + $i = 1; + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM articles WHERE slug = :slug'); + while (true) { + $stmt->execute([':slug' => $slug]); + if ((int)$stmt->fetchColumn() === 0) { + break; + } + $slug = $base . '-' . $i; + $i++; + } + return $slug; +} diff --git a/public_html/app/lib/settings.php b/public_html/app/lib/settings.php new file mode 100644 index 0000000..d70dc5f --- /dev/null +++ b/public_html/app/lib/settings.php @@ -0,0 +1,38 @@ +query('SELECT `key`, `value` FROM site_settings'); + $settings = []; + foreach ($stmt->fetchAll() as $row) { + $settings[$row['key']] = $row['value']; + } + cache_set($cacheKey, $settings); + return $settings; +} + +function save_settings(array $data): void +{ + global $config; + $pdo = DB::conn(); + $stmt = $pdo->prepare('INSERT INTO site_settings (`key`, `value`) VALUES (:k, :v) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'); + foreach ($data as $k => $v) { + $stmt->execute([':k' => $k, ':v' => $v]); + } + cache_set('site_settings', $data); +} + +function setting(string $key, $default = '') +{ + $settings = get_settings(); + return $settings[$key] ?? $default; +} diff --git a/public_html/app/lib/slugify.php b/public_html/app/lib/slugify.php new file mode 100644 index 0000000..36477e0 --- /dev/null +++ b/public_html/app/lib/slugify.php @@ -0,0 +1,9 @@ +prepare('SELECT COUNT(*) FROM click_events WHERE event_type = :e AND slug = :s AND ip_hash = :h AND created_at >= (NOW() - INTERVAL :win SECOND)'); + $stmt->bindValue(':e', $event); + $stmt->bindValue(':s', $slug); + $stmt->bindValue(':h', $hash); + $stmt->bindValue(':win', $windowSeconds, PDO::PARAM_INT); + $stmt->execute(); + if ((int)$stmt->fetchColumn() > $maxEvents) { + return false; + } + + $stmt = $pdo->prepare('INSERT INTO click_events (event_type, slug, ip_hash, session_token) VALUES (:e, :s, :h, :t)'); + $stmt->execute([ + ':e' => $event, + ':s' => $slug, + ':h' => $hash, + ':t' => $token, + ]); + return true; +} + +function tracking_stats(): array +{ + $pdo = DB::conn(); + $stmt = $pdo->query('SELECT event_type, COUNT(*) as total FROM click_events GROUP BY event_type'); + $out = []; + foreach ($stmt->fetchAll() as $row) { + $out[$row['event_type']] = (int)$row['total']; + } + return $out; +} + +function reset_tracking(): void +{ + DB::conn()->exec('TRUNCATE TABLE click_events'); +} diff --git a/public_html/assets/css/custom.css b/public_html/assets/css/custom.css new file mode 100644 index 0000000..c1f6e6c --- /dev/null +++ b/public_html/assets/css/custom.css @@ -0,0 +1 @@ +/* Custom overrides placeholder */ diff --git a/public_html/assets/css/style.css b/public_html/assets/css/style.css new file mode 100644 index 0000000..c978b08 --- /dev/null +++ b/public_html/assets/css/style.css @@ -0,0 +1,108 @@ +:root { + --primary: #e63946; + --secondary: #1d3557; + --bg: #0b1021; + --card-bg: #0f172a; + --text: #eaeaea; +} +* { box-sizing: border-box; } +body { margin:0; font-family: 'Inter', system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); } +a { color: inherit; text-decoration: none; } +img { max-width: 100%; display: block; } +.container { max-width: 1100px; margin: 0 auto; padding: 16px; } +.site-header { display: flex; justify-content: space-between; align-items: center; padding: 16px; background: linear-gradient(135deg, rgba(230,57,70,0.15), rgba(29,53,87,0.15)); border-bottom: 1px solid rgba(255,255,255,0.08); backdrop-filter: blur(8px); position: sticky; top:0; z-index:10; } +.logo-wrap { display: flex; align-items: center; gap: 12px; } +.logo { width: 64px; height: 64px; object-fit: contain; filter: drop-shadow(0 6px 18px rgba(0,0,0,0.4)); } +.site-name { font-size: 1.5rem; font-weight: 700; } +.tagline { color: #b6c2ff; font-size: 0.95rem; } +.banner img { max-height: 80px; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,0.35); } +.actions { display: flex; align-items: center; gap: 12px; } +.hamburger { width: 42px; height: 42px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); display: grid; place-items: center; cursor: pointer; transition: transform .2s; } +.hamburger span { display: block; width: 22px; height: 2px; background: #fff; margin: 3px 0; transform-origin: center; transition: transform .2s, opacity .2s; } +.hamburger.active span:nth-child(1) { transform: translateY(5px) rotate(45deg); } +.hamburger.active span:nth-child(2) { opacity: 0; } +.hamburger.active span:nth-child(3) { transform: translateY(-5px) rotate(-45deg); } +.nav-drawer { position: fixed; top: 0; right: -260px; width: 240px; height: 100vh; background: #0f172a; padding: 20px; box-shadow: -10px 0 40px rgba(0,0,0,0.35); display: flex; flex-direction: column; gap: 12px; transition: right .3s ease; transform: perspective(1000px) rotateY(-8deg); } +.nav-drawer.open { right: 0; transform: perspective(1000px) rotateY(0deg); } +.nav-drawer a { color: #e2e8f0; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.08); } +.search-form { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } +.search-form input, .search-form select { flex: 1; min-width: 120px; } +.hero { margin: 24px 0; padding: 18px; background: linear-gradient(135deg, rgba(230,57,70,0.15), rgba(69,123,157,0.15)); border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.25); transform: perspective(1000px) rotateX(2deg); } +.article-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } +.card { position: relative; overflow: hidden; background: var(--card-bg); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; transition: transform .2s ease, box-shadow .2s ease; box-shadow: 0 20px 60px rgba(0,0,0,0.25); } +.card::before { content:''; position: absolute; inset: -40%; background: radial-gradient(circle at 30% 20%, rgba(230,57,70,0.3), transparent 45%); opacity: 0; transition: opacity .3s; } +.card:hover { transform: translateY(-4px) scale(1.01); box-shadow: 0 26px 80px rgba(0,0,0,0.35); } +.card:hover::before { opacity: 1; } +.card-body { padding: 16px; } +.card h2 { margin: 0 0 8px; font-size: 1.2rem; color: #f8fafc; } +.card p { margin: 4px 0; color: #cbd5e1; } +.meta { font-size: 0.9rem; color: #94a3b8; } +.pagination { margin: 18px 0; display: flex; gap: 8px; flex-wrap: wrap; } +.pagination a { padding: 8px 12px; background: var(--card-bg); border-radius: 8px; border: 1px solid rgba(255,255,255,0.08); } +.pagination a.active { background: var(--primary); color: #fff; } +.article-detail { background: var(--card-bg); padding: 20px; border-radius: 14px; border:1px solid rgba(255,255,255,0.08); box-shadow: 0 20px 60px rgba(0,0,0,0.25); } +.article-detail h1 { margin-top: 0; } +.article-detail .excerpt { color: #cbd5e1; font-size: 1.05rem; } +.article-detail .media { margin: 16px 0; } +.article-detail video, .article-detail img { border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,0.35); } +.content { line-height: 1.7; color: #e2e8f0; white-space: pre-line; } +.footer { text-align: center; padding: 22px 12px; color: #94a3b8; } +.share a { margin-right: 12px; color: #93c5fd; } +.related h3 { margin-top: 0; } +.breadcrumbs { color: #94a3b8; margin-bottom: 8px; } + +.ad-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: grid; place-items: center; z-index: 9999; backdrop-filter: blur(8px); } +.ad-card { position: relative; width: min(420px, 90vw); background: #0f172a; border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 24px; box-shadow: 0 30px 90px rgba(0,0,0,0.5); transform: perspective(1000px) rotateX(3deg); animation: float 4s ease-in-out infinite; } +.ad-card h3 { margin: 0 0 8px; color: #f8fafc; } +.ad-card p { color: #cbd5e1; } +.ad-close { position: absolute; top: 8px; right: 8px; background: transparent; border: 1px solid rgba(255,255,255,0.2); color: #fff; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; } +.ad-note { font-size: 0.9rem; color: #94a3b8; margin-top: 10px; } + +.admin { background: #0b1021; } +.admin-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 12px 16px; background: #0f172a; border-bottom: 1px solid rgba(255,255,255,0.08); } +.admin-nav a { margin-left: 10px; color: #cbd5e1; } +.admin-container { max-width: 1100px; margin: 0 auto; padding: 16px; } +.admin-card { background: var(--card-bg); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 16px; box-shadow: 0 16px 50px rgba(0,0,0,0.35); } +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { border: 1px solid rgba(255,255,255,0.08); padding: 10px; text-align: left; } +.btn { display: inline-block; background: var(--primary); color: #fff; border: none; padding: 8px 12px; border-radius: 8px; cursor: pointer; text-decoration: none; transition: transform .15s ease, box-shadow .15s ease; box-shadow: 0 10px 30px rgba(230,57,70,0.35); } +.btn:hover { transform: translateY(-2px); box-shadow: 0 14px 40px rgba(230,57,70,0.45); } +.btn.danger { background: #ef4444; box-shadow: 0 10px 30px rgba(239,68,68,0.35); } +.btn.secondary { background: var(--secondary); box-shadow: 0 10px 30px rgba(69,123,157,0.35); } +input, textarea, select { width: 100%; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: #0b1329; color: #e2e8f0; margin-bottom: 10px; } +label { display: block; margin: 8px 0 4px; color: #cbd5e1; } +.error { color: #fca5a5; } +.success { color: #a7f3d0; } +.auth { display: grid; place-items: center; min-height: 100vh; } +.auth-card { background: var(--card-bg); padding: 24px; border-radius: 12px; width: min(420px, 90vw); box-shadow: 0 20px 60px rgba(0,0,0,0.35); } +.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; } +.stat { background: #0b1329; border: 1px solid rgba(255,255,255,0.08); padding: 12px; border-radius: 10px; text-align: center; } +.stat-number { font-size: 1.4rem; font-weight: 700; color: #f8fafc; } +.stat-label { color: #94a3b8; } +.flex-between { display: flex; align-items: center; justify-content: space-between; } +.ad-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } + +@keyframes float { + 0%, 100% { transform: perspective(1000px) rotateX(3deg) translateY(0); } + 50% { transform: perspective(1000px) rotateX(0deg) translateY(-6px); } +} + +@media (max-width: 768px) { + .site-header { flex-direction: column; align-items: flex-start; gap: 10px; } + .banner img { width: 100%; max-height: 120px; } + .article-grid { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } + .admin-nav a { display: inline-block; margin: 6px 6px 0 0; } +} + +@media (max-width: 480px) { + .site-header { position: static; } + .card-body { padding: 14px; } + .hero { padding: 14px; } + .article-detail { padding: 14px; } +} + +/* Themes */ +.theme-a { --bg: #0b1021; --card-bg: #0f172a; --text: #eaeaea; font-family: 'Inter', sans-serif; } +.theme-b { --bg: #111827; --card-bg: #1f2937; --text: #e5e7eb; font-family: 'Poppins', sans-serif; } +.theme-c { --bg: #f8fafc; --card-bg: #ffffff; --text: #111827; font-family: 'Roboto', sans-serif; color: #111827; } +.theme-c body, .theme-c .card, .theme-c .article-detail { color: #111827; } diff --git a/public_html/assets/js/app.js b/public_html/assets/js/app.js new file mode 100644 index 0000000..466826a --- /dev/null +++ b/public_html/assets/js/app.js @@ -0,0 +1,99 @@ +(function(){ + const overlay = document.getElementById('ad-overlay'); + if (!overlay) return; + const adTitle = document.getElementById('ad-title'); + const adBody = document.getElementById('ad-body'); + const closeBtn = document.getElementById('ad-skip'); + const goBtn = document.getElementById('ad-go'); + const ad = window.__AD__ || {}; + const adLink = ad.link || ''; + + function fillAd(){ + if (adTitle) adTitle.textContent = ad.title || ''; + if (adBody) adBody.textContent = ad.body || ''; + } + + function track(event, slug){ + if (!event || !slug) return; + const payload = new URLSearchParams({event, slug, token: ad.token || ''}); + if (!navigator.sendBeacon('/api/track.php', payload)) { + fetch('/api/track.php', {method:'POST', body: payload, keepalive: true}); + } + } + + function shouldShowFromClient(){ + if (!ad.enabled) return false; + const freq = ad.frequency || 'once'; + if (freq === 'once') { + return !sessionStorage.getItem('ad_seen_once'); + } + if (freq === 'hourly') { + const last = parseInt(localStorage.getItem('ad_last_ts') || '0', 10); + const hours = parseInt(ad.hours || '4', 10); + return (Date.now() - last) > hours * 3600 * 1000; + } + if (freq === 'per_n_posts') { + const n = parseInt(ad.everyPosts || '3', 10); + const c = parseInt(sessionStorage.getItem('ad_counter') || '0', 10) + 1; + sessionStorage.setItem('ad_counter', c.toString()); + if (c >= n) { + sessionStorage.setItem('ad_counter', '0'); + return true; + } + return false; + } + return false; + } + + function markShown(){ + sessionStorage.setItem('ad_seen_once', '1'); + localStorage.setItem('ad_last_ts', Date.now().toString()); + } + + function openOverlay(slug, goToArticle){ + if (!adLink || !shouldShowFromClient()) { + if (goToArticle) window.location.href = goToArticle; + return; + } + overlay.hidden = false; + fillAd(); + const proceedArticle = () => { + overlay.hidden = true; + if (goToArticle) window.location.href = goToArticle; + }; + if (closeBtn) closeBtn.onclick = proceedArticle; + if (goBtn) goBtn.onclick = () => { + track('ad_forced_redirect', slug); + window.open(adLink, '_blank', 'noopener'); + markShown(); + proceedArticle(); + }; + if (closeBtn) closeBtn.addEventListener('click', () => track('ad_close_click', slug)); + } + + document.addEventListener('DOMContentLoaded', function(){ + const navToggle = document.getElementById('nav-toggle'); + const navDrawer = document.getElementById('nav-drawer'); + if (navToggle && navDrawer) { + navToggle.addEventListener('click', () => { + navToggle.classList.toggle('active'); + navDrawer.classList.toggle('open'); + }); + } + + // Home page interception + document.querySelectorAll('a.post-link').forEach(a => { + a.addEventListener('click', function(e){ + const slug = this.dataset.slug; + if (!slug) return; + e.preventDefault(); + openOverlay(slug, this.href); + }); + }); + + // Article direct access + if (ad.show) { + openOverlay(ad.slug, null); + } + }); +})(); diff --git a/public_html/bots/telegram/bot.php b/public_html/bots/telegram/bot.php new file mode 100644 index 0000000..3e5ecdc --- /dev/null +++ b/public_html/bots/telegram/bot.php @@ -0,0 +1,99 @@ + [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => json_encode($params), + 'timeout' => 5, + ]]; + $context = stream_context_create($opts); + return file_get_contents($url, false, $context); +} + +function tg_send($chatId, $text, $keyboard = null) { + $payload = ['chat_id' => $chatId, 'text' => $text, 'parse_mode' => 'HTML']; + if ($keyboard) { + $payload['reply_markup'] = $keyboard; + } + tg_request('sendMessage', $payload); +} + +function is_admin_id($id): bool +{ + global $config; + return in_array((int)$id, $config['TELEGRAM_ADMIN_IDS']); +} + +function handle_update(array $update): void +{ + if (isset($update['message'])) { + $msg = $update['message']; + $chatId = $msg['chat']['id']; + $userId = $msg['from']['id'] ?? 0; + if (!is_admin_id($userId)) { + tg_send($chatId, 'Bạn không có quyền.'); + return; + } + $text = trim($msg['text'] ?? ''); + route_command($chatId, $text); + } +} + +function route_command(int $chatId, string $text): void +{ + if (strpos($text, '/stats') === 0) { + $s = tracking_stats(); + tg_send($chatId, "Views: " . ($s['article_view'] ?? 0) . "\nAd redirect: " . ($s['ad_forced_redirect'] ?? 0)); + return; + } + if (strpos($text, '/reset') === 0) { + reset_tracking(); + tg_send($chatId, 'Đã reset thống kê'); + return; + } + if (strpos($text, '/add ') === 0) { + $parts = explode('|', substr($text, 5)); + if (count($parts) >= 3) { + $title = sanitize_text($parts[0]); + $slug = unique_slug($title); + $id = create_post([ + 'title' => $title, + 'slug' => $slug, + 'excerpt' => sanitize_text($parts[1]), + 'content' => trim($parts[2]), + 'media_url' => $parts[3] ?? '', + 'category' => '', + 'tags' => '', + 'meta_title' => $title, + 'meta_description' => sanitize_text($parts[1]), + 'meta_keywords' => '', + 'og_image' => $parts[3] ?? '', + 'is_public' => 1, + 'published_at' => date('Y-m-d H:i:s'), + ]); + tg_send($chatId, 'Đã tạo bài #' . $id . ' slug: ' . $slug); + return; + } + } + if (strpos($text, '/ad ') === 0) { + $parts = explode('|', substr($text, 4)); + save_settings([ + 'ad_link' => sanitize_text($parts[0] ?? ''), + 'ad_title' => sanitize_text($parts[1] ?? ''), + 'ad_body' => sanitize_text($parts[2] ?? ''), + 'ad_enabled' => 1, + ] + get_settings()); + tg_send($chatId, 'Đã cập nhật quảng cáo'); + return; + } + tg_send($chatId, "Lệnh:\n/stats\n/reset\n/add title|excerpt|content|media\n/ad link|title|body"); +} diff --git a/public_html/bots/telegram/keyboard.php b/public_html/bots/telegram/keyboard.php new file mode 100644 index 0000000..d0a1b5f --- /dev/null +++ b/public_html/bots/telegram/keyboard.php @@ -0,0 +1,13 @@ + [ + [ + ['text' => 'Thống kê', 'callback_data' => 'stats'], + ['text' => 'Reset', 'callback_data' => 'reset'], + ], + [ + ['text' => 'Thêm bài', 'callback_data' => 'add'], + ['text' => 'Quảng cáo', 'callback_data' => 'ad'], + ] + ] +]); diff --git a/public_html/bots/telegram/webhook.php b/public_html/bots/telegram/webhook.php new file mode 100644 index 0000000..908a1b6 --- /dev/null +++ b/public_html/bots/telegram/webhook.php @@ -0,0 +1,6 @@ + +
+

© - Tin tức nhanh, chuẩn xác.

+
+ + diff --git a/public_html/includes/header.php b/public_html/includes/header.php new file mode 100644 index 0000000..85aed02 --- /dev/null +++ b/public_html/includes/header.php @@ -0,0 +1,73 @@ + + + + + + + <?php echo e($pageTitle ?? $siteName); ?> + + + + + + + + + + + + +
+ diff --git a/public_html/index.php b/public_html/index.php new file mode 100644 index 0000000..6c1e968 --- /dev/null +++ b/public_html/index.php @@ -0,0 +1,81 @@ + +
+
+

+

+
+
+ + + +
+
+
+
+ + + +
+ 1): ?> + + +
+ + diff --git a/public_html/install.php b/public_html/install.php new file mode 100644 index 0000000..467d8d9 --- /dev/null +++ b/public_html/install.php @@ -0,0 +1,58 @@ + '" . addslashes($dbHost) . "',\n 'DB_NAME' => '" . addslashes($dbName) . "',\n 'DB_USER' => '" . addslashes($dbUser) . "',\n 'DB_PASS' => '" . addslashes($dbPass) . "',\n 'BASE_URL' => '" . addslashes($baseUrl) . "',\n 'SESSION_SALT' => '" . $salt . "',\n 'TELEGRAM_BOT_TOKEN' => '',\n 'TELEGRAM_CHAT_ID' => '',\n 'TELEGRAM_ADMIN_IDS' => [],\n 'CACHE_PATH' => __DIR__ . '/../cache',\n 'CACHE_TTL' => [\n 'settings' => 300,\n 'home' => 45,\n 'article' => 45,\n ],\n];\n"; + file_put_contents($configPath, $configContent); + + try { + $pdo = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4", $dbUser, $dbPass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $sql = file_get_contents($schemaPath); + $pdo->exec($sql); + $message = 'Đã tạo cấu hình và import database thành công. Xóa file install.php để bảo mật.'; + } catch (Throwable $e) { + $message = 'Lỗi: ' . $e->getMessage(); + } +} +?> + + + + + + Cài đặt + + + +
+

Cài đặt hệ thống

+

+
+ + + + + + + + + + + +
+
+ + diff --git a/public_html/post.php b/public_html/post.php new file mode 100644 index 0000000..c890cdc --- /dev/null +++ b/public_html/post.php @@ -0,0 +1,144 @@ + ($hours * 3600); + } elseif ($freq === 'per_n_posts') { + $n = max(1, (int)($settings['ad_every_posts'] ?? 3)); + $_SESSION['ad_show_count']++; + if ($_SESSION['ad_show_count'] >= $n) { + $showAd = true; + $_SESSION['ad_show_count'] = 0; + } + } + if ($showAd) { + $_SESSION['ad_last_shown'] = time(); + } +} + +$related = related_posts($post['category'] ?? '', (int)$post['id']); +include __DIR__ . '/includes/header.php'; +?> + + + + + + + + diff --git a/public_html/sql/database.sql b/public_html/sql/database.sql new file mode 100644 index 0000000..46e6308 --- /dev/null +++ b/public_html/sql/database.sql @@ -0,0 +1,81 @@ +-- Database schema for news portal +CREATE TABLE admins ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + failed_attempts INT DEFAULT 0, + locked_until DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE articles ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + excerpt TEXT NOT NULL, + content MEDIUMTEXT NOT NULL, + media_url VARCHAR(255) DEFAULT NULL, + category VARCHAR(120) DEFAULT '', + tags VARCHAR(255) DEFAULT '', + meta_title VARCHAR(255) DEFAULT '', + meta_description VARCHAR(255) DEFAULT '', + meta_keywords VARCHAR(255) DEFAULT '', + og_image VARCHAR(255) DEFAULT '', + is_public TINYINT(1) DEFAULT 0, + published_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_public (is_public), + INDEX idx_published (published_at) +); + +CREATE TABLE site_settings ( + `key` VARCHAR(100) PRIMARY KEY, + `value` TEXT +); + +CREATE TABLE click_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + event_type VARCHAR(50) NOT NULL, + slug VARCHAR(255) NOT NULL, + ip_hash CHAR(64) NOT NULL, + session_token VARCHAR(64) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_event (event_type), + INDEX idx_slug (slug), + INDEX idx_created (created_at) +); + +CREATE TABLE admin_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + admin_id INT NOT NULL, + action VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_admin (admin_id) +); + +INSERT INTO admins (username, password_hash) VALUES +('admin', '$2y$10$Hgd6Nq35p3xNmPR9U1FVLemyYI7DiIP9N6byN1Nsx3Rp3XIanFkOu'); -- password: admin123 + +INSERT INTO site_settings (`key`, `value`) VALUES +('site_name', 'Tin Nóng 24H'), +('logo_url', 'https://telegram.org/img/t_logo.png'), +('banner_url', 'https://telegram.org/img/t_logo.png'), +('hero_text', 'Tin nhanh, chính xác, luôn cập nhật'), +('meta_description', 'Báo điện tử tối ưu cho hosting yếu'), +('primary_color', '#e63946'), +('secondary_color', '#1d3557'), +('theme', 'theme-a'), +('slug_mode', 'slug'), +('ad_enabled', '0'), +('ad_link', ''), +('ad_title', 'Deal hot Shopee'), +('ad_body', 'Mua sắm tiết kiệm nhất hôm nay!'), +('ad_frequency', 'once'), +('ad_interval_hours', '4'), +('ad_every_posts', '3'), +('rate_window_seconds', '60'), +('rate_max_events', '5'), +('bot_list', 'bot|crawl|spider|curl'); + +INSERT INTO articles (title, slug, excerpt, content, media_url, is_public, published_at) VALUES +('Chào mừng đến với bản demo', 'chao-mung-den-voi-ban-demo', 'Báo điện tử tối ưu cho hosting yếu.', 'Nội dung mẫu cho bài viết đầu tiên.', 'https://telegram.org/img/t_logo.png', 1, NOW()), +('Tin nóng hôm nay', 'tin-nong-hom-nay', 'Cập nhật liên tục các sự kiện nổi bật.', 'Nội dung chi tiết của tin nóng.', 'https://telegram.org/img/t_logo.png', 1, NOW());