Como criar um sistema de notícias e eventos no site do servidor de MU
Monte um sistema completo de notícias e agenda de eventos para o site do seu MU Online, com painel administrativo, banco de dados, listagem paginada e destaque de eventos ativos.
Notícias e uma agenda de eventos são o que mantém o site do servidor vivo entre uma temporada e outra. É por ali que você anuncia manutenções, divulga a próxima invasão, comunica bônus de fim de semana e conta as novidades do patch. Neste tutorial você vai construir, do zero e em PHP puro, um sistem
Notícias e uma agenda de eventos são o que mantém o site do servidor vivo entre uma temporada e outra. É por ali que você anuncia manutenções, divulga a próxima invasão, comunica bônus de fim de semana e conta as novidades do patch. Neste tutorial você vai construir, do zero e em PHP puro, um sistema com painel administrativo protegido, banco de dados bem modelado, listagem pública paginada e um bloco de eventos que destaca automaticamente o que está ativo agora. Todos os valores concretos (horários, categorias, formatos) aparecem como exemplo, pois variam por versão e por servidor.
Pré-requisitos
Este módulo assume que você já tem o site do servidor no ar. Se ainda está montando a base, comece por como criar um servidor de MU Online e depois integre este sistema à estrutura existente.
| Requisito | Detalhe (exemplo — varia por versão) |
|---|---|
| PHP | 7.4 ou superior (8.1+ recomendado) |
| Banco | MySQL/MariaDB ou SQL Server já usado pelo site |
| Sessões | Suporte a session_start() para o login admin |
| Conta admin | Um usuário com senha em hash para o painel |
| Editor | Acesso para criar as pastas noticias/ e admin/ |
Vale separar o banco do site do banco do jogo. Notícias e eventos não precisam morar na base do MU. Muitos admins criam uma base site_cms só para conteúdo, evitando misturar tabelas de gestão com as tabelas do servidor. Neste tutorial, os exemplos usam uma base de site independente.
Passo 1 — Modelar as tabelas
Duas entidades principais: notícias e eventos. As notícias são posts com título, corpo e data. Os eventos têm início, fim e uma categoria. Modele com campos nativos de data — nunca guarde data como texto.
-- Base do site (exemplo em 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 'invasao',
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)
);
O campo publicado permite escrever rascunhos sem exibi-los. O slug é o pedaço de URL amigável (/noticias/manutencao-quarta). O índice idx_pub acelera a listagem pública, que sempre filtra por publicado = 1 e ordena por data.
Passo 2 — Camada de acesso ao banco
Centralize a conexão em um arquivo reaproveitável, usando PDO com prepared statements. Isso protege contra SQL injection por padrão:
<?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;
}
Manter ATTR_EMULATE_PREPARES => false garante prepared statements reais no servidor, fechando a porta para a maioria dos ataques de injeção.
Passo 3 — Listagem pública de notícias com paginação
A página que o jogador vê. Ela filtra apenas notícias publicadas, ordena pela mais recente e pagina de 10 em 10:
<?php
// noticias/index.php
require_once __DIR__ . '/../lib/db.php';
$pdo = getSiteDB();
// Paginação segura: força inteiro e mínimo 1
$pagina = max(1, (int)($_GET['p'] ?? 1));
$porPag = 10;
$offset = ($pagina - 1) * $porPag;
// Total para calcular número de páginas
$total = (int) $pdo->query(
"SELECT COUNT(*) FROM noticias WHERE publicado = 1"
)->fetchColumn();
$totalPaginas = (int) ceil($total / $porPag);
// LIMIT/OFFSET com valores já validados como inteiros
$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>Notícias do 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>
Repare que todo dado impresso passa por htmlspecialchars. Isso é o que impede que uma notícia com HTML malicioso injete script na página (XSS).
Passo 4 — Página individual da notícia
A URL amigável /noticias/<slug> precisa de um roteador simples. Configure o servidor para direcionar essas URLs a um ler.php que busca pelo 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>Notícia não 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']) ?> em
<?= date('d/m/Y H:i', strtotime($noticia['criado_em'])) ?>
</p>
<div class="corpo">
<!-- corpo já sanitizado na gravação; ver Passo 6 -->
<?= $noticia['corpo'] ?>
</div>
</article>
Passo 5 — Bloco de eventos ativos e próximos
Aqui está a parte que dá vida à home. Queremos mostrar o que está acontecendo agora e o que vem em seguida. Uma única consulta resolve ambos, comparando o horário atual com os campos de início e fim:
<?php
// eventos/bloco.php
require_once __DIR__ . '/../lib/db.php';
$pdo = getSiteDB();
// Eventos acontecendo agora
$ativos = $pdo->query(
"SELECT nome, categoria, inicio, fim
FROM eventos
WHERE ativo = 1 AND NOW() BETWEEN inicio AND fim
ORDER BY fim ASC"
)->fetchAll();
// Próximos eventos (começam no 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>Acontecendo agora</h3>
<ul class="ev-ativos">
<?php foreach ($ativos as $e): ?>
<li>
<span class="pulse"></span>
<strong><?= htmlspecialchars($e['nome']) ?></strong>
— termina às <?= date('H:i', strtotime($e['fim'])) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<h3>Próximos 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 recorrentes do jogo — como Blood Castle, Devil Square ou Chaos Castle, que costumam repetir em horários fixos — você tem duas estratégias. A simples é cadastrar cada ocorrência futura. A elegante é guardar só o padrão e calcular a próxima ocorrência em PHP. Os horários abaixo são exemplo e variam por versão:
| Evento | Horários (exemplo — varia por versão) | Categoria |
|---|---|---|
| Blood Castle | a cada 2h, minuto 00 | invasao |
| Devil Square | a cada 2h, minuto 30 | invasao |
| Chaos Castle | 4x ao dia | pvp |
| Golden Invasion | 20:00 e 22:00 | invasao |
| Castle Siege | domingo, semanal | guild |
<?php
// Calcular a próxima ocorrência de um evento a cada N horas no minuto M.
// AJUSTE aos horários reais do seu servidor.
function proximaOcorrencia(int $intervaloHoras, int $minuto): DateTime {
$agora = new DateTime();
$prox = clone $agora;
$prox->setTime((int)$agora->format('H'), $minuto, 0);
// Se já passou neste bloco, avança para o próximo intervalo
if ($prox <= $agora) {
$prox->modify("+{$intervaloHoras} hours");
}
return $prox;
}
$bc = proximaOcorrencia(2, 0); // Blood Castle a cada 2h no minuto 0 (exemplo)
echo 'Próximo Blood Castle: ' . $bc->format('H:i');
Passo 6 — Painel administrativo protegido
Nenhum sistema de notícias fica exposto sem autenticação. O painel exige login, protege contra CSRF e sanitiza a entrada. Primeiro, o guardião de sessão:
<?php
// admin/guardiao.php
session_start();
function exigirAdmin(): void {
if (empty($_SESSION['admin_id'])) {
header('Location: /admin/login.php');
exit;
}
}
// Token CSRF por sessão
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);
}
O login valida a senha contra um hash gerado com password_hash. Nunca guarde senha em texto puro:
<?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 com o hash; hash_equals evita timing attack no fluxo
if ($admin && password_verify($senha, $admin['senha_hash'])) {
session_regenerate_id(true); // previne session fixation
$_SESSION['admin_id'] = $admin['id'];
header('Location: /admin/noticias.php');
exit;
}
$erro = 'Credenciais inválidas.';
}
?>
<form method="post" class="login-form">
<h1>Painel Admin</h1>
<?php if (!empty($erro)): ?><p class="erro"><?= htmlspecialchars($erro) ?></p><?php endif; ?>
<input name="usuario" placeholder="Usuário" required>
<input name="senha" type="password" placeholder="Senha" required>
<button type="submit">Entrar</button>
</form>
Passo 7 — Criar e editar notícias no painel
O formulário de criação valida CSRF, gera o slug e sanitiza o conteúdo antes de gravar:
<?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('Requisição inválida.');
}
// Gera slug a partir do título (minúsculas, sem acento, hifens)
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;
// Sanitização básica do corpo: permite só 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 uma sanitização mais robusta de HTML rico (permitindo formatação sem abrir XSS), considere uma biblioteca dedicada de purificação de HTML em vez de só strip_tags. O strip_tags remove tags, mas não neutraliza atributos perigosos como onclick dentro das permitidas.
Passo 8 — Cache leve da home
A home consulta notícias e eventos a cada visita. Em servidores movimentados, guarde o HTML renderizado do bloco por alguns 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);
Lembre-se de invalidar (apagar) o cache quando o admin publica uma notícia nova, para não deixar conteúdo velho no ar por três minutos.
Erros comuns e soluções
| Sintoma | Causa provável | Solução |
|---|---|---|
| Notícia some da listagem | Campo publicado ficou 0 | Marque como publicado ou revise o filtro |
| Layout quebra ao abrir notícia | HTML não sanitizado no corpo | Use strip_tags com whitelist ou purificador |
Acentos aparecem como é | Charset diferente entre PHP e banco | Use utf8mb4 na conexão e nas tabelas |
| Paginação mostra páginas a mais | Total contado sem o filtro publicado | Aplique o mesmo WHERE na contagem |
| Evento ativo não aparece | Data guardada como texto | Migre para DATETIME e compare com NOW() |
| Painel acessível sem login | Faltou exigirAdmin() na página | Inclua o guardião no topo de toda página admin |
| Formulário rejeitado sempre | Token CSRF não enviado no form | Adicione o hidden com csrfToken() |
Segurança e boas práticas
- Sempre prepared statements. Toda query com dado do usuário usa parâmetros ligados, nunca concatenação de string.
- Escape na saída.
htmlspecialcharsem tudo que vem do banco e é exibido, exceto o corpo HTML que você sanitizou na gravação. - Senhas com hash.
password_hashna criação,password_verifyno login. Jamais texto puro. - CSRF em todo POST do painel. Sem token válido, a requisição é recusada.
- Rascunhos por padrão. Crie notícias como não publicadas e revise antes de tornar públicas.
- Separe as bases. Conteúdo do site não precisa morar na base do jogo.
Checklist de lançamento
- Tabelas
noticiaseeventoscriadas com camposDATETIME - Conexão PDO com
utf8mb4e prepared statements reais - Listagem pública filtrando
publicado = 1e paginada - Página individual respondendo 404 para slug inexistente
- Bloco de eventos separando "agora" e "próximos"
- Horários de eventos recorrentes conferidos com o GameServer
- Painel admin exigindo login em todas as páginas
- Senhas do admin em hash com
password_hash - Token CSRF validado em cada formulário
- Conteúdo sanitizado na gravação e escapado na exibição
- Cache da home com invalidação ao publicar
- Teste real: publicar uma notícia e vê-la na home
- Teste real: criar um evento ativo e confirmar destaque "agora"
Com esse sistema, o site deixa de ser uma página estática e vira um canal vivo de comunicação com a comunidade. Anúncios de manutenção, bônus de fim de semana e a agenda de invasões passam a ser publicados em minutos, direto do painel, com a segurança de um código que trata cada entrada como potencialmente hostil.
Perguntas frequentes
Preciso de um CMS pronto como WordPress para ter notícias?
Não. Um CMS resolve, mas costuma ser pesado e difícil de integrar ao banco do jogo. Para um servidor de MU, um módulo próprio em PHP com uma tabela de notícias é mais leve, mais fácil de casar com o visual do site e não abre uma superfície enorme de plugins vulneráveis.
Como faço para eventos automáticos como Blood Castle aparecerem na agenda?
Você cadastra os horários fixos numa tabela de eventos recorrentes e calcula em PHP a próxima ocorrência a partir do horário atual. Os horários exatos variam por versão e pela sua configuração de eventos no GameServer, então trate os valores como exemplo e ajuste.
É seguro deixar o painel de notícias acessível pela internet?
Sim, desde que ele exija login de administrador, use senhas com hash, proteja contra CSRF e escape toda saída HTML. Nunca deixe o painel sem autenticação, e de preferência restrinja o acesso por IP ou por uma pasta protegida.
Como evito que HTML da notícia quebre o layout ou injete script?
Escape o conteúdo com htmlspecialchars ao exibir, ou use uma whitelist de tags com uma biblioteca de sanitização. Nunca imprima direto no HTML o que veio do formulário sem tratar — isso abre XSS.
Devo guardar a data do evento como texto ou como campo de data?
Sempre como campo de data/hora nativo (DATETIME). Guardar como texto quebra ordenação, filtros por período e o cálculo de eventos ativos. Formate para exibição só na hora de mostrar ao usuário.