Cómo crear un sistema de noticias y eventos en el sitio del servidor de MU
Monta un sistema completo de noticias y agenda de eventos para el sitio de tu MU Online, con panel administrativo, base de datos, listado paginado y destaque de eventos activos.
Las noticias y una agenda de eventos son lo que mantiene vivo el sitio del servidor entre una temporada y otra. Es por ahí donde anuncias mantenimientos, divulgas la próxima invasión, comunicas bonos de fin de semana y cuentas las novedades del patch. En este tutorial vas a construir, desde cero y e
Las noticias y una agenda de eventos son lo que mantiene vivo el sitio del servidor entre una temporada y otra. Es por ahí donde anuncias mantenimientos, divulgas la próxima invasión, comunicas bonos de fin de semana y cuentas las novedades del patch. En este tutorial vas a construir, desde cero y en PHP puro, un sistema con panel administrativo protegido, base de datos bien modelada, listado público paginado y un bloque de eventos que destaca automáticamente lo que está activo ahora. Todos los valores concretos (horarios, categorías, formatos) aparecen como ejemplo, pues varían por versión y por servidor.
Requisitos previos
Este módulo asume que ya tienes el sitio del servidor publicado. Si aún estás montando la base, empieza por cómo crear un servidor de MU Online y luego integra este sistema a la estructura existente.
| Requisito | Detalle (ejemplo — varía por versión) |
|---|---|
| PHP | 7.4 o superior (8.1+ recomendado) |
| Base de datos | MySQL/MariaDB o SQL Server ya usado por el sitio |
| Sesiones | Soporte a session_start() para el login admin |
| Cuenta admin | Un usuario con contraseña en hash para el panel |
| Editor | Acceso para crear las carpetas noticias/ y admin/ |
Vale la pena separar la base de datos del sitio de la base de datos del juego. Las noticias y eventos no necesitan vivir en la base de MU. Muchos admins crean una base site_cms solo para contenido, evitando mezclar tablas de gestión con las tablas del servidor. En este tutorial, los ejemplos usan una base de sitio independiente.
Paso 1 — Modelar las tablas
Dos entidades principales: noticias y eventos. Las noticias son posts con título, cuerpo y fecha. Los eventos tienen inicio, fin y una categoría. Modela con campos nativos de fecha; nunca guardes la fecha como texto.
-- Base del sitio (ejemplo en MySQL/MariaDB)
CREATE TABLE noticias (
id INT AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(160) NOT NULL,
slug VARCHAR(180) NOT NULL UNIQUE,
resumo VARCHAR(300) NULL,
corpo MEDIUMTEXT NOT NULL,
categoria VARCHAR(40) NOT NULL DEFAULT 'geral',
autor VARCHAR(60) NOT NULL,
publicado TINYINT(1) NOT NULL DEFAULT 0,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado DATETIME NULL,
INDEX idx_pub (publicado, criado_em)
);
CREATE TABLE eventos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(120) NOT NULL,
descricao VARCHAR(400) NULL,
categoria VARCHAR(40) NOT NULL DEFAULT 'invasion',
inicio DATETIME NOT NULL,
fim DATETIME NOT NULL,
recorrente TINYINT(1) NOT NULL DEFAULT 0,
ativo TINYINT(1) NOT NULL DEFAULT 1,
INDEX idx_periodo (inicio, fim)
);
El campo publicado permite escribir borradores sin exhibirlos. El slug es el pedazo de URL amigable (/noticias/mantenimiento-miercoles). El índice idx_pub acelera el listado público, que siempre filtra por publicado = 1 y ordena por fecha.
Paso 2 — Capa de acceso a la base de datos
Centraliza la conexión en un archivo reaprovechable, usando PDO con prepared statements. Esto protege contra SQL injection por defecto:
<?php
// lib/db.php
function getSiteDB(): PDO {
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'mysql:host=127.0.0.1;dbname=site_cms;charset=utf8mb4';
$pdo = new PDO($dsn, 'usuario_site', 'senha_forte_aqui', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
Mantener ATTR_EMULATE_PREPARES => false garantiza prepared statements reales en el servidor, cerrando la puerta a la mayoría de los ataques de inyección.
Paso 3 — Listado público de noticias con paginación
La página que el jugador ve. Filtra solo noticias publicadas, ordena por la más reciente y pagina de 10 en 10:
<?php
// noticias/index.php
require_once __DIR__ . '/../lib/db.php';
$pdo = getSiteDB();
// Paginacion segura: fuerza entero y minimo 1
$pagina = max(1, (int)($_GET['p'] ?? 1));
$porPag = 10;
$offset = ($pagina - 1) * $porPag;
// Total para calcular numero de paginas
$total = (int) $pdo->query(
"SELECT COUNT(*) FROM noticias WHERE publicado = 1"
)->fetchColumn();
$totalPaginas = (int) ceil($total / $porPag);
// LIMIT/OFFSET con valores ya validados como enteros
$stmt = $pdo->prepare(
"SELECT id, titulo, slug, resumo, categoria, autor, criado_em
FROM noticias
WHERE publicado = 1
ORDER BY criado_em DESC
LIMIT :lim OFFSET :off"
);
$stmt->bindValue(':lim', $porPag, PDO::PARAM_INT);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
$stmt->execute();
$noticias = $stmt->fetchAll();
?>
<section class="noticias">
<h1>Noticias del Servidor</h1>
<?php foreach ($noticias as $n): ?>
<article class="noticia-card cat-<?= htmlspecialchars($n['categoria']) ?>">
<span class="tag"><?= htmlspecialchars($n['categoria']) ?></span>
<h2>
<a href="/noticias/<?= htmlspecialchars($n['slug']) ?>">
<?= htmlspecialchars($n['titulo']) ?>
</a>
</h2>
<p class="meta">
<?= htmlspecialchars($n['autor']) ?> ·
<?= date('d/m/Y', strtotime($n['criado_em'])) ?>
</p>
<p><?= htmlspecialchars($n['resumo'] ?? '') ?></p>
</article>
<?php endforeach; ?>
<nav class="paginacao">
<?php for ($i = 1; $i <= $totalPaginas; $i++): ?>
<a href="?p=<?= $i ?>" class="<?= $i === $pagina ? 'atual' : '' ?>"><?= $i ?></a>
<?php endfor; ?>
</nav>
</section>
Fíjate en que todo dato impreso pasa por htmlspecialchars. Esto es lo que impide que una noticia con HTML malicioso inyecte script en la página (XSS).
Paso 4 — Página individual de la noticia
La URL amigable /noticias/<slug> necesita un enrutador simple. Configura el servidor para dirigir esas URLs a un ler.php que busca por el slug:
<?php
// noticias/ler.php
require_once __DIR__ . '/../lib/db.php';
$slug = $_GET['slug'] ?? '';
$pdo = getSiteDB();
$stmt = $pdo->prepare(
"SELECT * FROM noticias WHERE slug = :slug AND publicado = 1 LIMIT 1"
);
$stmt->execute([':slug' => $slug]);
$noticia = $stmt->fetch();
if (!$noticia) {
http_response_code(404);
echo '<h1>Noticia no encontrada</h1>';
exit;
}
?>
<article class="noticia-full">
<span class="tag"><?= htmlspecialchars($noticia['categoria']) ?></span>
<h1><?= htmlspecialchars($noticia['titulo']) ?></h1>
<p class="meta">
Por <?= htmlspecialchars($noticia['autor']) ?> el
<?= date('d/m/Y H:i', strtotime($noticia['criado_em'])) ?>
</p>
<div class="corpo">
<!-- cuerpo ya sanitizado en la grabacion; ver Paso 6 -->
<?= $noticia['corpo'] ?>
</div>
</article>
Paso 5 — Bloque de eventos activos y próximos
Aquí está la parte que le da vida a la home. Queremos mostrar lo que está ocurriendo ahora y lo que viene a continuación. Una sola consulta resuelve ambos, comparando el horario actual con los campos de inicio y fin:
<?php
// eventos/bloco.php
require_once __DIR__ . '/../lib/db.php';
$pdo = getSiteDB();
// Eventos ocurriendo ahora
$ativos = $pdo->query(
"SELECT nome, categoria, inicio, fim
FROM eventos
WHERE ativo = 1 AND NOW() BETWEEN inicio AND fim
ORDER BY fim ASC"
)->fetchAll();
// Proximos eventos (empiezan en el futuro)
$proximos = $pdo->query(
"SELECT nome, categoria, inicio, fim
FROM eventos
WHERE ativo = 1 AND inicio > NOW()
ORDER BY inicio ASC
LIMIT 5"
)->fetchAll();
?>
<div class="eventos-bloco">
<?php if ($ativos): ?>
<h3>Ocurriendo ahora</h3>
<ul class="ev-ativos">
<?php foreach ($ativos as $e): ?>
<li>
<span class="pulse"></span>
<strong><?= htmlspecialchars($e['nome']) ?></strong>
— termina a las <?= date('H:i', strtotime($e['fim'])) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<h3>Proximos eventos</h3>
<ul class="ev-proximos">
<?php foreach ($proximos as $e): ?>
<li>
<span class="tag"><?= htmlspecialchars($e['categoria']) ?></span>
<?= htmlspecialchars($e['nome']) ?>
<em><?= date('d/m H:i', strtotime($e['inicio'])) ?></em>
</li>
<?php endforeach; ?>
</ul>
</div>
Para eventos recurrentes del juego —como Blood Castle, Devil Square o Chaos Castle, que suelen repetirse en horarios fijos— tienes dos estrategias. La simple es registrar cada ocurrencia futura. La elegante es guardar solo el patrón y calcular la próxima ocurrencia en PHP. Los horarios de abajo son ejemplo y varían por versión:
| Evento | Horarios (ejemplo — varía por versión) | Categoría |
|---|---|---|
| Blood Castle | cada 2h, minuto 00 | invasion |
| Devil Square | cada 2h, minuto 30 | invasion |
| Chaos Castle | 4x al día | pvp |
| Golden Invasion | 20:00 y 22:00 | invasion |
| Castle Siege | domingo, semanal | guild |
<?php
// Calcular la proxima ocurrencia de un evento cada N horas en el minuto M.
// AJUSTA a los horarios reales de tu servidor.
function proximaOcorrencia(int $intervaloHoras, int $minuto): DateTime {
$agora = new DateTime();
$prox = clone $agora;
$prox->setTime((int)$agora->format('H'), $minuto, 0);
// Si ya paso en este bloque, avanza al proximo intervalo
if ($prox <= $agora) {
$prox->modify("+{$intervaloHoras} hours");
}
return $prox;
}
$bc = proximaOcorrencia(2, 0); // Blood Castle cada 2h en el minuto 0 (ejemplo)
echo 'Proximo Blood Castle: ' . $bc->format('H:i');
Paso 6 — Panel administrativo protegido
Ningún sistema de noticias queda expuesto sin autenticación. El panel exige login, protege contra CSRF y sanitiza la entrada. Primero, el guardián de sesión:
<?php
// admin/guardiao.php
session_start();
function exigirAdmin(): void {
if (empty($_SESSION['admin_id'])) {
header('Location: /admin/login.php');
exit;
}
}
// Token CSRF por sesion
function csrfToken(): string {
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function validarCsrf(?string $token): bool {
return is_string($token)
&& !empty($_SESSION['csrf'])
&& hash_equals($_SESSION['csrf'], $token);
}
El login valida la contraseña contra un hash generado con password_hash. Nunca guardes la contraseña en texto plano:
<?php
// admin/login.php
require_once __DIR__ . '/../lib/db.php';
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo = getSiteDB();
$user = $_POST['usuario'] ?? '';
$senha = $_POST['senha'] ?? '';
$stmt = $pdo->prepare("SELECT id, senha_hash FROM admins WHERE usuario = :u LIMIT 1");
$stmt->execute([':u' => $user]);
$admin = $stmt->fetch();
// password_verify compara con el hash; hash_equals evita timing attack en el flujo
if ($admin && password_verify($senha, $admin['senha_hash'])) {
session_regenerate_id(true); // previene session fixation
$_SESSION['admin_id'] = $admin['id'];
header('Location: /admin/noticias.php');
exit;
}
$erro = 'Credenciales invalidas.';
}
?>
<form method="post" class="login-form">
<h1>Panel Admin</h1>
<?php if (!empty($erro)): ?><p class="erro"><?= htmlspecialchars($erro) ?></p><?php endif; ?>
<input name="usuario" placeholder="Usuario" required>
<input name="senha" type="password" placeholder="Contrasena" required>
<button type="submit">Entrar</button>
</form>
Paso 7 — Crear y editar noticias en el panel
El formulario de creación valida CSRF, genera el slug y sanitiza el contenido antes de grabar:
<?php
// admin/noticia-salvar.php
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/guardiao.php';
exigirAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !validarCsrf($_POST['csrf'] ?? null)) {
http_response_code(400);
exit('Solicitud invalida.');
}
// Genera slug a partir del titulo (minusculas, sin acento, guiones)
function gerarSlug(string $texto): string {
$texto = iconv('UTF-8', 'ASCII//TRANSLIT', $texto);
$texto = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $texto));
return trim($texto, '-');
}
$titulo = trim($_POST['titulo'] ?? '');
$corpo = $_POST['corpo'] ?? '';
$categoria = $_POST['categoria'] ?? 'geral';
$publicado = isset($_POST['publicado']) ? 1 : 0;
// Sanitizacion basica del cuerpo: permite solo tags seguras
$corpoLimpo = strip_tags($corpo, '<p><br><strong><em><ul><li><a><h2><h3>');
$pdo = getSiteDB();
$stmt = $pdo->prepare(
"INSERT INTO noticias (titulo, slug, resumo, corpo, categoria, autor, publicado, criado_em)
VALUES (:t, :s, :r, :c, :cat, :a, :pub, NOW())"
);
$stmt->execute([
':t' => $titulo,
':s' => gerarSlug($titulo) . '-' . substr(md5($titulo . time()), 0, 5),
':r' => mb_substr(strip_tags($corpo), 0, 280),
':c' => $corpoLimpo,
':cat' => $categoria,
':a' => 'Admin',
':pub' => $publicado,
]);
header('Location: /admin/noticias.php?ok=1');
Para una sanitización más robusta de HTML rico (que permita formato sin abrir XSS), considera una biblioteca dedicada de purificación de HTML en lugar de solo strip_tags. El strip_tags elimina tags, pero no neutraliza atributos peligrosos como onclick dentro de las permitidas.
Paso 8 — Caché ligera de la home
La home consulta noticias y eventos en cada visita. En servidores concurridos, guarda el HTML renderizado del bloque por algunos minutos:
<?php
// lib/cache.php
function render_cache(string $chave, callable $render, int $ttl = 180): string {
$arq = sys_get_temp_dir() . '/cache_' . md5($chave) . '.html';
if (is_file($arq) && (time() - filemtime($arq)) < $ttl) {
return file_get_contents($arq);
}
ob_start();
$render();
$html = ob_get_clean();
file_put_contents($arq, $html);
return $html;
}
// Uso: echo render_cache('home_noticias', function () {
// require __DIR__ . '/../noticias/index.php';
// }, 180);
Recuerda invalidar (borrar) la caché cuando el admin publica una noticia nueva, para no dejar contenido viejo publicado durante tres minutos.
Errores comunes y soluciones
| Síntoma | Causa probable | Solución |
|---|---|---|
| La noticia desaparece del listado | El campo publicado quedó en 0 | Márcala como publicada o revisa el filtro |
| El layout se rompe al abrir la noticia | HTML no sanitizado en el cuerpo | Usa strip_tags con whitelist o purificador |
Los acentos aparecen como é | Charset distinto entre PHP y base de datos | Usa utf8mb4 en la conexión y en las tablas |
| La paginación muestra páginas de más | Total contado sin el filtro publicado | Aplica el mismo WHERE en el conteo |
| El evento activo no aparece | Fecha guardada como texto | Migra a DATETIME y compara con NOW() |
| El panel accesible sin login | Faltó exigirAdmin() en la página | Incluye el guardián en la parte superior de toda página admin |
| El formulario siempre es rechazado | Token CSRF no enviado en el form | Agrega el hidden con csrfToken() |
Seguridad y buenas prácticas
- Siempre prepared statements. Toda query con dato del usuario usa parámetros vinculados, nunca concatenación de string.
- Escape en la salida.
htmlspecialcharsen todo lo que viene de la base de datos y se exhibe, excepto el cuerpo HTML que sanitizaste en la grabación. - Contraseñas con hash.
password_hashen la creación,password_verifyen el login. Jamás texto plano. - CSRF en todo POST del panel. Sin token válido, la solicitud es rechazada.
- Borradores por defecto. Crea las noticias como no publicadas y revísalas antes de hacerlas públicas.
- Separa las bases de datos. El contenido del sitio no necesita vivir en la base del juego.
Lista de verificación de lanzamiento
- Tablas
noticiasyeventoscreadas con camposDATETIME - Conexión PDO con
utf8mb4y prepared statements reales - Listado público filtrando
publicado = 1y paginado - Página individual respondiendo 404 para slug inexistente
- Bloque de eventos separando "ahora" y "próximos"
- Horarios de eventos recurrentes verificados con el GameServer
- Panel admin exigiendo login en todas las páginas
- Contraseñas del admin en hash con
password_hash - Token CSRF validado en cada formulario
- Contenido sanitizado en la grabación y escapado en la exhibición
- Caché de la home con invalidación al publicar
- Prueba real: publicar una noticia y verla en la home
- Prueba real: crear un evento activo y confirmar el destaque "ahora"
Con este sistema, el sitio deja de ser una página estática y se convierte en un canal vivo de comunicación con la comunidad. Los anuncios de mantenimiento, los bonos de fin de semana y la agenda de invasiones pasan a publicarse en minutos, directamente desde el panel, con la seguridad de un código que trata cada entrada como potencialmente hostil.
Preguntas frecuentes
¿Necesito un CMS ya hecho como WordPress para tener noticias?
No. Un CMS resuelve, pero suele ser pesado y difícil de integrar a la base de datos del juego. Para un servidor de MU, un módulo propio en PHP con una tabla de noticias es más ligero, más fácil de casar con el diseño del sitio y no abre una superficie enorme de plugins vulnerables.
¿Cómo hago para que eventos automáticos como Blood Castle aparezcan en la agenda?
Registras los horarios fijos en una tabla de eventos recurrentes y calculas en PHP la próxima ocurrencia a partir del horario actual. Los horarios exactos varían por versión y por tu configuración de eventos en el GameServer, así que trata los valores como ejemplo y ajústalos.
¿Es seguro dejar el panel de noticias accesible por internet?
Sí, siempre que exija login de administrador, use contraseñas con hash, proteja contra CSRF y escape toda la salida HTML. Nunca dejes el panel sin autenticación, y de preferencia restringe el acceso por IP o por una carpeta protegida.
¿Cómo evito que el HTML de la noticia rompa el layout o inyecte script?
Escapa el contenido con htmlspecialchars al mostrarlo, o usa una whitelist de tags con una biblioteca de sanitización. Nunca imprimas directamente en el HTML lo que vino del formulario sin tratarlo: eso abre XSS.
¿Debo guardar la fecha del evento como texto o como campo de fecha?
Siempre como campo de fecha/hora nativo (DATETIME). Guardarla como texto rompe el ordenamiento, los filtros por período y el cálculo de eventos activos. Formatéala para exhibición solo al momento de mostrarla al usuario.