O maior portal de MU Online do Brasil — desde 2003
Tutorial Intermediário Website

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.

BR Bruno · Atualizado em 12 jul 2025 · ⏱ 24 min de leitura
Resposta rápida

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.

RequisitoDetalhe (exemplo — varia por versão)
PHP7.4 ou superior (8.1+ recomendado)
BancoMySQL/MariaDB ou SQL Server já usado pelo site
SessõesSuporte a session_start() para o login admin
Conta adminUm usuário com senha em hash para o painel
EditorAcesso 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:

EventoHorários (exemplo — varia por versão)Categoria
Blood Castlea cada 2h, minuto 00invasao
Devil Squarea cada 2h, minuto 30invasao
Chaos Castle4x ao diapvp
Golden Invasion20:00 e 22:00invasao
Castle Siegedomingo, semanalguild
<?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

SintomaCausa provávelSolução
Notícia some da listagemCampo publicado ficou 0Marque como publicado ou revise o filtro
Layout quebra ao abrir notíciaHTML não sanitizado no corpoUse strip_tags com whitelist ou purificador
Acentos aparecem como éCharset diferente entre PHP e bancoUse utf8mb4 na conexão e nas tabelas
Paginação mostra páginas a maisTotal contado sem o filtro publicadoAplique o mesmo WHERE na contagem
Evento ativo não apareceData guardada como textoMigre para DATETIME e compare com NOW()
Painel acessível sem loginFaltou exigirAdmin() na páginaInclua o guardião no topo de toda página admin
Formulário rejeitado sempreToken CSRF não enviado no formAdicione 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. htmlspecialchars em tudo que vem do banco e é exibido, exceto o corpo HTML que você sanitizou na gravação.
  • Senhas com hash. password_hash na criação, password_verify no 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 noticias e eventos criadas com campos DATETIME
  • Conexão PDO com utf8mb4 e prepared statements reais
  • Listagem pública filtrando publicado = 1 e 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.

BR
Editor de eventos, mapas e itens

Bruno é especialista em eventos, mapas, bosses e economia de itens do MU Online. Documenta cada detalhe com base em jogo real.

Continue lendo

Artigos relacionados