Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions README.md

This file was deleted.

6 changes: 6 additions & 0 deletions public_html/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_URI} !^/(admin|api|bots|assets|includes|app|sql)/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([^/]+)/?$ post.php?slug=$1 [QSA,L]
34 changes: 34 additions & 0 deletions public_html/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Tin tức PHP cho shared hosting (dataonline.vn)

## Cấu trúc & upload
- Giữ nguyên thư mục `public_html/` và upload thẳng vào hosting dataonline.vn (đúng tên public_html).
- File cấu hình duy nhất: `app/config/config.php` (DB_HOST, DB_NAME, DB_USER, DB_PASS, BASE_URL, SESSION_SALT, CACHE_PATH, CACHE_TTL, TELEGRAM_BOT_TOKEN, TELEGRAM_ADMIN_CHAT_ID).
- Import `sql/database.sql` vào MySQL (có seed admin + bài mẫu).
- Đảm bảo `app/cache` có quyền ghi (777 nếu cần).
- Apache mod_rewrite bật sẵn: `.htaccess` map `/slug` → `post.php?slug=...` và bỏ qua `/admin|/api|/bots|/assets|/includes|/app|/sql`.

## Thiết lập nhanh
1) Import DB: `sql/database.sql`.
2) Sửa `app/config/config.php` với thông tin DB + BASE_URL + SESSION_SALT (tùy chọn đổi CACHE_TTL).
3) (Tuỳ chọn) Điền TELEGRAM_BOT_TOKEN + TELEGRAM_ADMIN_CHAT_ID để bật notify.
4) Kiểm tra quyền ghi `app/cache/` và thư mục `assets/img` (upload logo/banner).

## Đăng nhập admin
- URL: `BASE_URL/admin/login.php`
- Tài khoản mẫu: `admin / admin123` (đổi ngay bằng UPDATE DB hoặc `password_hash` PHP).

## Acceptance test checklist
1) Slug: mở `/tin-nong-hom-nay` đúng bài.
2) Quảng cáo Shopee (ads_enabled=1, ad_link có):
- Từ trang chủ hoặc truy cập trực tiếp `/slug`: overlay hiện ngay.
- Click overlay/nút X/anywhere: tab hiện tại chuyển NGAY đến ad_link, đồng thời mở tab mới `/slug?ad=0` không quảng cáo, không delay.
3) Ads tắt: ads_enabled=0 hoặc ad_link rỗng → không overlay.
4) Chống click ảo: spam `/api/track.php` trong 30s không tăng vô hạn (rate limit + hash IP/UA + token phiên).
5) Theme: đổi theme 1/2/3 trong admin/settings, frontend thay CSS tương ứng.
6) Telegram notify: publish bài public → nhận tin “Bài mới”; ad_click tích lũy → gửi gộp tối thiểu mỗi 5 phút.
7) Responsive: kiểm tra mobile/laptop, menu hamburger 3D, admin/login cân đối.

## Lưu ý hiệu năng
- Cache file: settings (5 phút), home (60s), bài (60s, key theo slug+updated_at).
- Có thể đặt cache-control dài cho assets tĩnh.
- Không dùng .env, không cần composer, thuần PHP 7.4+/PDO.
14 changes: 14 additions & 0 deletions public_html/admin/dashboard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
require_once __DIR__ . '/includes/auth_check.php';
require_once __DIR__ . '/../app/lib/db.php';
include __DIR__ . '/includes/header.php';
$stats = db()->query('SELECT event_type, COUNT(*) as c FROM events GROUP BY event_type')->fetchAll();
$byType = []; foreach ($stats as $s){$byType[$s['event_type']] = $s['c'];}
?>
<div class="card">
<h3>Tổng quan</h3>
<p>Page views: <?= $byType['page_view'] ?? 0 ?></p>
<p>Post views: <?= $byType['post_view'] ?? 0 ?></p>
<p>Ad clicks: <?= $byType['ad_click'] ?? 0 ?></p>
</div>
<?php include __DIR__ . '/includes/footer.php'; ?>
7 changes: 7 additions & 0 deletions public_html/admin/includes/auth_check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php
session_start();
require_once __DIR__ . '/../../app/config/config.php';
if (empty($_SESSION['admin_id'])) {
header('Location: ' . BASE_URL . '/admin/login.php');
exit;
}
3 changes: 3 additions & 0 deletions public_html/admin/includes/footer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
</main>
</body>
</html>
16 changes: 16 additions & 0 deletions public_html/admin/includes/header.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php require_once __DIR__ . '/../../app/config/config.php'; ?>
<!DOCTYPE html>
<html lang="vi">
<head><meta charset="UTF-8"><title>Admin</title><link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/admin.css"></head>
<body>
<header class="admin-header">
<div>Admin Panel</div>
<nav>
<a href="<?= BASE_URL ?>/admin/dashboard.php">Dashboard</a>
<a href="<?= BASE_URL ?>/admin/posts.php">Bài viết</a>
<a href="<?= BASE_URL ?>/admin/settings.php">Cài đặt</a>
<a href="<?= BASE_URL ?>/admin/stats.php">Thống kê</a>
<a href="<?= BASE_URL ?>/admin/logout.php">Logout</a>
</nav>
</header>
<main class="admin-main">
28 changes: 28 additions & 0 deletions public_html/admin/login.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
session_start();
require_once __DIR__ . '/../app/lib/auth.php';
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once __DIR__ . '/../app/lib/csrf.php';
csrf_verify();
if (admin_login($_POST['username'], $_POST['password'])) {
header('Location: ' . BASE_URL . '/admin/dashboard.php');
exit;
}
$message = 'Sai tài khoản hoặc mật khẩu';
}
?>
<!DOCTYPE html>
<html lang="vi">
<head><meta charset="UTF-8"><title>Đăng nhập</title><link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/admin.css"></head>
<body class="login-page">
<form method="post" class="login-form">
<h2>Admin Login</h2>
<?php if ($message): ?><div class="alert"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<?php if (function_exists('csrf_field')) echo csrf_field(); ?>
<input type="text" name="username" placeholder="Tên đăng nhập" required>
<input type="password" name="password" placeholder="Mật khẩu" required>
<button type="submit">Đăng nhập</button>
</form>
</body>
</html>
4 changes: 4 additions & 0 deletions public_html/admin/logout.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
admin_logout();
header('Location: ' . BASE_URL . '/admin/login.php');
Comment on lines +1 to +4
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Start session before destroying on logout

The logout handler calls admin_logout() (which only runs session_destroy()) without ever starting the session. Without session_start(), PHP ignores the destroy request and leaves the existing admin_id session cookie intact, so hitting /admin/logout.php does not actually log the user out—subsequent admin pages still see the old session. Start the session (and ideally clear session data) before destroying it to ensure logout works.

Useful? React with 👍 / 👎.

45 changes: 45 additions & 0 deletions public_html/admin/post_add.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
require_once __DIR__ . '/includes/auth_check.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/slugify.php';
require_once __DIR__ . '/../app/lib/csrf.php';
require_once __DIR__ . '/../app/lib/telegram.php';
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$slug = $_POST['slug'] ?: slugify($_POST['title']);
$stmt = db()->prepare('INSERT INTO articles (slug,title,excerpt,content,telegram_media,meta_title,meta_description,meta_keywords,status) VALUES (:slug,:title,:excerpt,:content,:media,:mt,:md,:mk,:status)');
$stmt->execute([
':slug'=>$slug,
':title'=>$_POST['title'],
':excerpt'=>$_POST['excerpt'],
':content'=>$_POST['content'],
':media'=>$_POST['telegram_media'],
':mt'=>$_POST['meta_title'],
':md'=>$_POST['meta_description'],
':mk'=>$_POST['meta_keywords'],
':status'=>$_POST['status']
]);
if ($_POST['status']==='public') {
sendTelegramMessage('Bài mới: '.$_POST['title'].' - '.BASE_URL.'/'.$slug);
}
header('Location: '.BASE_URL.'/admin/posts.php');
exit;
}
include __DIR__ . '/includes/header.php';
?>
<h3>Thêm bài</h3>
<form method="post">
<?= csrf_field() ?>
<label>Tiêu đề<input type="text" name="title" required></label>
<label>Slug<input type="text" name="slug"></label>
<label>Tóm tắt<textarea name="excerpt"></textarea></label>
<label>Nội dung<textarea name="content" rows="10"></textarea></label>
<label>Telegram media (mỗi dòng một link)<textarea name="telegram_media"></textarea></label>
<label>Meta title<input type="text" name="meta_title"></label>
<label>Meta description<input type="text" name="meta_description"></label>
<label>Meta keywords<input type="text" name="meta_keywords"></label>
<label>Trạng thái<select name="status"><option value="draft">Draft</option><option value="public">Public</option></select></label>
<button class="btn" type="submit">Lưu</button>
</form>
<?php include __DIR__ . '/includes/footer.php'; ?>
14 changes: 14 additions & 0 deletions public_html/admin/post_copy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
require_once __DIR__ . '/includes/auth_check.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/slugify.php';
$id = (int)($_GET['id'] ?? 0);
$stmt = db()->prepare('SELECT * FROM articles WHERE id=:id');
$stmt->execute([':id'=>$id]);
$a = $stmt->fetch();
if($a){
$newSlug = slugify($a['slug'].'-copy-'.rand(100,999));
$ins = db()->prepare('INSERT INTO articles (slug,title,excerpt,content,telegram_media,meta_title,meta_description,meta_keywords,status) VALUES (:slug,:title,:excerpt,:content,:media,:mt,:md,:mk,:status)');
$ins->execute([':slug'=>$newSlug,':title'=>$a['title'].' (copy)',':excerpt'=>$a['excerpt'],':content'=>$a['content'],':media'=>$a['telegram_media'],':mt'=>$a['meta_title'],':md'=>$a['meta_description'],':mk'=>$a['meta_keywords'],':status'=>'draft']);
}
header('Location: '.BASE_URL.'/admin/posts.php');
9 changes: 9 additions & 0 deletions public_html/admin/post_delete.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php
require_once __DIR__ . '/includes/auth_check.php';
require_once __DIR__ . '/../app/lib/db.php';
$id = (int)($_GET['id'] ?? 0);
if ($id) {
$stmt = db()->prepare('DELETE FROM articles WHERE id=:id');
$stmt->execute([':id'=>$id]);
}
Comment on lines +4 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Protect delete endpoint with CSRF

post_delete.php performs destructive deletion on a GET request with no CSRF validation. A logged-in admin who follows a crafted link can trigger an article delete without confirmation, bypassing the CSRF protections used elsewhere. This should be converted to a POST action guarded by csrf_verify() (and ideally removed from GET links) to prevent cross-site deletion.

Useful? React with 👍 / 👎.

header('Location: '.BASE_URL.'/admin/posts.php');
41 changes: 41 additions & 0 deletions public_html/admin/post_edit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
require_once __DIR__ . '/includes/auth_check.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/slugify.php';
require_once __DIR__ . '/../app/lib/csrf.php';
$id = (int)($_GET['id'] ?? 0);
$stmt = db()->prepare('SELECT * FROM articles WHERE id=:id');
$stmt->execute([':id'=>$id]);
$article = $stmt->fetch();
if(!$article){exit('Not found');}
if($_SERVER['REQUEST_METHOD']==='POST'){
csrf_verify();
$slug = $_POST['slug'] ?: slugify($_POST['title']);
$u = db()->prepare('UPDATE articles SET slug=:slug,title=:title,excerpt=:excerpt,content=:content,telegram_media=:media,meta_title=:mt,meta_description=:md,meta_keywords=:mk,status=:status WHERE id=:id');
$u->execute([
':slug'=>$slug,':title'=>$_POST['title'],':excerpt'=>$_POST['excerpt'],':content'=>$_POST['content'],':media'=>$_POST['telegram_media'],':mt'=>$_POST['meta_title'],':md'=>$_POST['meta_description'],':mk'=>$_POST['meta_keywords'],':status'=>$_POST['status'],':id'=>$id
]);
if ($article['status'] !== 'public' && $_POST['status'] === 'public') {
require_once __DIR__ . '/../app/lib/telegram.php';
sendTelegramMessage('Bài mới: '.$_POST['title'].' - '.BASE_URL.'/'.$slug);
}
header('Location: '.BASE_URL.'/admin/posts.php');
exit;
}
include __DIR__ . '/includes/header.php';
?>
<h3>Sửa bài</h3>
<form method="post">
<?= csrf_field() ?>
<label>Tiêu đề<input type="text" name="title" value="<?= htmlspecialchars($article['title']) ?>" required></label>
<label>Slug<input type="text" name="slug" value="<?= htmlspecialchars($article['slug']) ?>"></label>
<label>Tóm tắt<textarea name="excerpt"><?= htmlspecialchars($article['excerpt']) ?></textarea></label>
<label>Nội dung<textarea name="content" rows="10"><?= htmlspecialchars($article['content']) ?></textarea></label>
<label>Telegram media<textarea name="telegram_media"><?= htmlspecialchars($article['telegram_media']) ?></textarea></label>
<label>Meta title<input type="text" name="meta_title" value="<?= htmlspecialchars($article['meta_title']) ?>"></label>
<label>Meta description<input type="text" name="meta_description" value="<?= htmlspecialchars($article['meta_description']) ?>"></label>
<label>Meta keywords<input type="text" name="meta_keywords" value="<?= htmlspecialchars($article['meta_keywords']) ?>"></label>
<label>Trạng thái<select name="status"><option value="draft" <?= $article['status']==='draft'?'selected':'' ?>>Draft</option><option value="public" <?= $article['status']==='public'?'selected':'' ?>>Public</option></select></label>
<button class="btn" type="submit">Lưu</button>
</form>
<?php include __DIR__ . '/includes/footer.php'; ?>
23 changes: 23 additions & 0 deletions public_html/admin/posts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
require_once __DIR__ . '/includes/auth_check.php';
require_once __DIR__ . '/../app/lib/db.php';
include __DIR__ . '/includes/header.php';
$articles = db()->query('SELECT * FROM articles ORDER BY created_at DESC')->fetchAll();
?>
<div class="actions"><a class="btn" href="<?= BASE_URL ?>/admin/post_add.php">Thêm bài</a></div>
<table>
<tr><th>Tiêu đề</th><th>Slug</th><th>Trạng thái</th><th>Hành động</th></tr>
<?php foreach ($articles as $a): ?>
<tr>
<td><?= htmlspecialchars($a['title']) ?></td>
<td><?= htmlspecialchars($a['slug']) ?></td>
<td><?= $a['status'] ?></td>
<td>
<a href="<?= BASE_URL ?>/admin/post_edit.php?id=<?= $a['id'] ?>">Sửa</a> |
<a href="<?= BASE_URL ?>/admin/post_copy.php?id=<?= $a['id'] ?>">Copy</a> |
<a href="<?= BASE_URL ?>/admin/post_delete.php?id=<?= $a['id'] ?>" onclick="return confirm('Xóa?')">Xóa</a>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php include __DIR__ . '/includes/footer.php'; ?>
62 changes: 62 additions & 0 deletions public_html/admin/settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
require_once __DIR__ . '/includes/auth_check.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/csrf.php';
$settings = db()->query('SELECT * FROM site_settings LIMIT 1')->fetch();
if($_SERVER['REQUEST_METHOD']==='POST'){
csrf_verify();
$logoPath = $settings['logo_path'];
$bannerPath = $settings['banner_path'];
if (!empty($_FILES['logo']['name']) && $_FILES['logo']['error'] === UPLOAD_ERR_OK) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['logo']['tmp_name']);
finfo_close($finfo);
$allowed = ['image/png'=>'png','image/jpeg'=>'jpg','image/webp'=>'webp'];
if (isset($allowed[$mime]) && $_FILES['logo']['size'] <= 2*1024*1024) {
$ext = $allowed[$mime];
$name = 'assets/img/logo_'.time().'.'.$ext;
move_uploaded_file($_FILES['logo']['tmp_name'], __DIR__.'/../'.$name);
$logoPath = $name;
}
}
if (!empty($_FILES['banner']['name']) && $_FILES['banner']['error'] === UPLOAD_ERR_OK) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['banner']['tmp_name']);
finfo_close($finfo);
$allowed = ['image/png'=>'png','image/jpeg'=>'jpg','image/webp'=>'webp'];
if (isset($allowed[$mime]) && $_FILES['banner']['size'] <= 3*1024*1024) {
$ext = $allowed[$mime];
$name = 'assets/img/banner_'.time().'.'.$ext;
move_uploaded_file($_FILES['banner']['tmp_name'], __DIR__.'/../'.$name);
$bannerPath = $name;
}
}
$upd = db()->prepare('UPDATE site_settings SET site_name=:sn, site_description=:sd, logo_path=:logo, banner_path=:banner, theme=:theme, ads_enabled=:ads, ad_link=:link, ad_title=:title, ad_body=:body, updated_at=NOW() WHERE id=1');
$upd->execute([
':sn'=>$_POST['site_name'], ':sd'=>$_POST['site_description'], ':logo'=>$logoPath, ':banner'=>$bannerPath,
':theme'=>$_POST['theme'], ':ads'=>!empty($_POST['ads_enabled'])?1:0, ':link'=>$_POST['ad_link'], ':title'=>$_POST['ad_title'], ':body'=>$_POST['ad_body']
]);
header('Location: '.BASE_URL.'/admin/settings.php');
exit;
}
include __DIR__ . '/includes/header.php';
?>
<h3>Cài đặt</h3>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<label>Tên site<input type="text" name="site_name" value="<?= htmlspecialchars($settings['site_name']) ?>"></label>
<label>Mô tả<input type="text" name="site_description" value="<?= htmlspecialchars($settings['site_description']) ?>"></label>
<label>Logo<input type="file" name="logo"></label>
<label>Banner<input type="file" name="banner"></label>
<label>Theme<select name="theme">
<option value="1" <?= $settings['theme']==='1'?'selected':'' ?>>Theme 1</option>
<option value="2" <?= $settings['theme']==='2'?'selected':'' ?>>Theme 2</option>
<option value="3" <?= $settings['theme']==='3'?'selected':'' ?>>Theme 3</option>
</select></label>
<label><input type="checkbox" name="ads_enabled" value="1" <?= $settings['ads_enabled']?'checked':'' ?>> Bật quảng cáo</label>
<label>Link Shopee<input type="url" name="ad_link" value="<?= htmlspecialchars($settings['ad_link']) ?>"></label>
<label>Tiêu đề quảng cáo<input type="text" name="ad_title" value="<?= htmlspecialchars($settings['ad_title']) ?>"></label>
<label>Nội dung quảng cáo<textarea name="ad_body"><?= htmlspecialchars($settings['ad_body']) ?></textarea></label>
<button class="btn" type="submit">Lưu</button>
</form>
<?php include __DIR__ . '/includes/footer.php'; ?>
Loading