Commit 724a5054607aaad3ad427fc2ea7ba6e17a367f0e

Authored by Willen Borges Coelho
0 parents

Initial commit: sistema de gestão de alunos no AD

Substitui o painel embutido no WordPress por um sistema independente
com controle de acesso gerenciado pelo próprio sistema (tabela admins
no banco em vez de lista fixa de SIAPE no código), escaping de filtros
LDAP, sessão segura e log de auditoria.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.gitignore 0 → 100644
  1 +++ a/.gitignore
... ... @@ -0,0 +1,3 @@
  1 +config/local.php
  2 +storage/*.sqlite
  3 +storage/*.log
... ...
README.md 0 → 100644
  1 +++ a/README.md
... ... @@ -0,0 +1,63 @@
  1 +# Gestão de Alunos (AD) - LabInfo
  2 +
  3 +Sistema independente (sem WordPress) para habilitar/desabilitar contas de aluno no AD
  4 +e resetar senha, com controle de acesso gerenciado pela própria aplicação.
  5 +
  6 +## Deploy no pandora
  7 +
  8 +1. No pandora, clonar o repositório (é privado — use uma chave SSH já autorizada
  9 + no GitLab, ou um deploy token) direto em `/home/labinfo/`, de forma que
  10 + `public_html/` deste projeto substitua (ou vire) o `/home/labinfo/public_html` do site,
  11 + e `src/`, `config/`, `storage/`, `sql/` fiquem um nível acima dele (fora da árvore
  12 + servida publicamente):
  13 + ```bash
  14 + git clone git@git.cachoeiro.ifes.edu.br:1652445/lab-info.git /home/labinfo/app
  15 + ```
  16 + Depois ajuste os links/symlinks ou mova `public_html` conforme a estrutura real
  17 + do docroot do site.
  18 +
  19 +2. Criar as tabelas no banco `labinfo`:
  20 + ```bash
  21 + mysql -h <host> -u 'labinfo@pandora%.cefetes.br' -p labinfo < sql/schema.sql
  22 + ```
  23 +
  24 +3. Criar `config/local.php` a partir de `config/config.example.php` preenchendo:
  25 + - `db.pass` com a senha gerada pelo Puppet (me repassa por fora do catálogo).
  26 + - `ldap.service_user` / `ldap.service_pass` com uma conta de serviço (peça delegação
  27 + restrita à OU/grupo de alunos — não domain admin).
  28 + - `app.session_secret` com `bin2hex(random_bytes(32))`.
  29 +
  30 +4. Garantir que `storage/` tem permissão de escrita pelo usuário do PHP-FPM do site
  31 + (para `audit.log`), e que `config/local.php` está fora do alcance público
  32 + (confirmar que `.php` nunca é servido como texto puro — padrão do PHP já cobre isso).
  33 +
  34 +5. Cadastrar o primeiro administrador direto no banco (seed), já que a tela de
  35 + administradores exige estar logado como admin:
  36 + ```sql
  37 + INSERT INTO admins (login, papel, criado_por) VALUES ('willen', 'admin', 'seed');
  38 + ```
  39 +
  40 +6. Acessar `https://labinfo.cachoeiro.ifes.edu.br/`, logar com usuário/senha do AD
  41 + e confirmar que o dashboard carrega os alunos do grupo configurado.
  42 +
  43 +## Estrutura
  44 +
  45 +- `public_html/` — document root (único diretório exposto na web).
  46 +- `src/` — classes PHP (LDAP, Auth, repositórios, log).
  47 +- `config/` — config.example.php versionado; `local.php` (real) nunca versionado.
  48 +- `sql/schema.sql` — schema do banco (admins, settings, audit_log, login_attempts).
  49 +- `storage/` — log de auditoria em arquivo (complementar ao banco).
  50 +
  51 +## Papéis
  52 +
  53 +- **admin**: tudo que operador pode, mais gerenciar `admins.php` e `settings.php`.
  54 +- **operador**: só habilita/desabilita aluno e reseta senha no `dashboard.php`.
  55 +
  56 +A autenticação de quem usa o sistema é sempre feita com login e senha do próprio AD
  57 +(bind LDAP) — o banco **não armazena nenhuma senha**. A tabela `admins` só guarda
  58 +quem está autorizado a entrar e com qual papel (login, nome, papel), funcionando como
  59 +uma lista de permissão: se o login/senha do AD estiverem corretos mas o login não
  60 +estiver cadastrado (e ativo) em `admins`, o acesso ao sistema é negado mesmo assim.
  61 +
  62 +Não existe mais lista de SIAPE fixa no código — todo acesso é via tabela `admins`,
  63 +editável pela tela (exceto o primeiro admin, que precisa de uma inserção manual inicial).
... ...
config/config.example.php 0 → 100644
  1 +++ a/config/config.example.php
... ... @@ -0,0 +1,35 @@
  1 +<?php
  2 +/**
  3 + * Copie este arquivo para config/local.php e preencha os valores reais.
  4 + * config/local.php NUNCA deve ser versionado (está no .gitignore).
  5 + */
  6 +
  7 +return [
  8 + 'ldap' => [
  9 + 'server' => 'ldap://ad.example.org',
  10 + 'base_dn' => 'OU=Example OU,DC=example,DC=org',
  11 + 'domain_suffix' => '@example.org', // usado para montar o login de bind: <usuario>@example.org
  12 + 'group_prof' => 'CN=grp-example-prof,OU=Groups,OU=Example OU,DC=example,DC=org',
  13 + 'group_aluno' => 'cn=grp-example-aluno',
  14 + 'filter_prof' => 'cn=grp-example-prof',
  15 + // Conta de serviço usada para buscas e para as alterações (habilitar/desabilitar,
  16 + // resetar senha) na OU de alunos. Peça ao pessoal de AD uma conta com delegação
  17 + // restrita só a essa OU/grupo — nunca uma conta de domain admin.
  18 + 'service_user' => 'CHANGE_ME',
  19 + 'service_pass' => 'CHANGE_ME',
  20 + ],
  21 +
  22 + 'db' => [
  23 + 'host' => 'CHANGE_ME',
  24 + 'name' => 'CHANGE_ME',
  25 + 'user' => 'CHANGE_ME',
  26 + 'pass' => 'CHANGE_ME',
  27 + ],
  28 +
  29 + 'app' => [
  30 + // Gere com: bin2hex(random_bytes(32))
  31 + 'session_name' => 'app_session',
  32 + 'session_secret' => 'CHANGE_ME',
  33 + 'log_file' => __DIR__ . '/../storage/audit.log',
  34 + ],
  35 +];
... ...
public_html/action_status.php 0 → 100644
  1 +++ a/public_html/action_status.php
... ... @@ -0,0 +1,47 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +require __DIR__ . '/../src/bootstrap.php';
  6 +Auth::requireLogin();
  7 +
  8 +if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  9 + header('Location: dashboard.php');
  10 + exit;
  11 +}
  12 +
  13 +$user = trim((string) ($_POST['user'] ?? ''));
  14 +$enable = ($_POST['enable'] ?? '') === '1';
  15 +
  16 +if ($user === '') {
  17 + header('Location: dashboard.php?erro=1&msg=' . urlencode('Usuário inválido.'));
  18 + exit;
  19 +}
  20 +
  21 +try {
  22 + $ldap = new Ldap();
  23 + $ldap->bindService();
  24 +
  25 + $entry = $ldap->findUserByCn($user);
  26 + if ($entry === null) {
  27 + throw new RuntimeException('Aluno não encontrado no AD.');
  28 + }
  29 +
  30 + $dn = $ldap->dnOf($entry);
  31 + $accountControl = (int) ($entry['useraccountcontrol'][0] ?? 0);
  32 + $new = $enable ? ($accountControl & ~2) : ($accountControl | 2);
  33 +
  34 + $quemFez = Auth::currentLogin() ?? 'desconhecido';
  35 + $ldap->modify($dn, [
  36 + 'useraccountcontrol' => $new,
  37 + 'description' => $quemFez,
  38 + ]);
  39 +
  40 + AuditLog::record($quemFez, $enable ? 'habilitou_aluno' : 'desabilitou_aluno', $user);
  41 +
  42 + header('Location: dashboard.php?ok=1');
  43 + exit;
  44 +} catch (Throwable $e) {
  45 + header('Location: dashboard.php?erro=1&msg=' . urlencode($e->getMessage()));
  46 + exit;
  47 +}
... ...
public_html/admins.php 0 → 100644
  1 +++ a/public_html/admins.php
... ... @@ -0,0 +1,122 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +require __DIR__ . '/../src/bootstrap.php';
  6 +Auth::requireAdmin();
  7 +
  8 +$erro = null;
  9 +$ok = null;
  10 +$quemFez = Auth::currentLogin() ?? 'desconhecido';
  11 +
  12 +if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  13 + $acao = $_POST['acao'] ?? '';
  14 +
  15 + if ($acao === 'adicionar') {
  16 + $login = trim((string) ($_POST['login'] ?? ''));
  17 + $papel = ($_POST['papel'] ?? 'operador') === 'admin' ? 'admin' : 'operador';
  18 +
  19 + if ($login === '') {
  20 + $erro = 'Informe o login.';
  21 + } else {
  22 + try {
  23 + $ldapObj = new Ldap();
  24 + $ldapObj->bindService();
  25 + $entry = $ldapObj->findUserByLogin($login);
  26 + $nome = $entry['displayname'][0] ?? null;
  27 +
  28 + if ($entry === null) {
  29 + $erro = 'Login não encontrado no AD. Confira e tente novamente.';
  30 + } else {
  31 + AdminRepository::add($login, $nome, $papel, $quemFez);
  32 + AuditLog::record($quemFez, 'adicionou_admin', $login, "papel={$papel}");
  33 + $ok = 'Administrador adicionado com sucesso.';
  34 + }
  35 + } catch (Throwable $e) {
  36 + $erro = 'Erro ao consultar o AD: ' . $e->getMessage();
  37 + }
  38 + }
  39 + } elseif ($acao === 'remover') {
  40 + $id = (int) ($_POST['id'] ?? 0);
  41 + $admin = AdminRepository::find($id);
  42 +
  43 + if (!$admin) {
  44 + $erro = 'Administrador não encontrado.';
  45 + } elseif ($admin['papel'] === 'admin' && AdminRepository::countActiveAdmins($id) === 0) {
  46 + $erro = 'Não é possível remover o último administrador ativo do sistema.';
  47 + } else {
  48 + AdminRepository::setActive($id, false);
  49 + AuditLog::record($quemFez, 'removeu_admin', $admin['login']);
  50 + $ok = 'Administrador removido (desativado) com sucesso.';
  51 + }
  52 + } elseif ($acao === 'reativar') {
  53 + $id = (int) ($_POST['id'] ?? 0);
  54 + $admin = AdminRepository::find($id);
  55 + if ($admin) {
  56 + AdminRepository::setActive($id, true);
  57 + AuditLog::record($quemFez, 'reativou_admin', $admin['login']);
  58 + $ok = 'Administrador reativado.';
  59 + }
  60 + }
  61 +}
  62 +
  63 +$admins = AdminRepository::all();
  64 +
  65 +View::header('Administradores');
  66 +?>
  67 +<div class="card">
  68 + <h1>Administradores do sistema</h1>
  69 +
  70 + <?php if ($erro): ?><div class="alert error"><?= View::escape($erro) ?></div><?php endif; ?>
  71 + <?php if ($ok): ?><div class="alert success"><?= View::escape($ok) ?></div><?php endif; ?>
  72 +
  73 + <form method="post" action="admins.php">
  74 + <input type="hidden" name="acao" value="adicionar">
  75 + <label for="login">Login do AD (sem @cefetes.br)</label>
  76 + <input type="text" id="login" name="login" required>
  77 + <label for="papel">Papel</label>
  78 + <select id="papel" name="papel">
  79 + <option value="operador">Operador (só habilita/desabilita e reseta senha)</option>
  80 + <option value="admin">Administrador (também gerencia quem tem acesso)</option>
  81 + </select>
  82 + <button type="submit">Adicionar</button>
  83 + </form>
  84 +
  85 + <table>
  86 + <tr>
  87 + <th>Login</th>
  88 + <th>Nome</th>
  89 + <th>Papel</th>
  90 + <th>Status</th>
  91 + <th>Ações</th>
  92 + </tr>
  93 + <?php foreach ($admins as $a): ?>
  94 + <tr>
  95 + <td><?= View::escape($a['login']) ?></td>
  96 + <td><?= View::escape($a['nome']) ?></td>
  97 + <td><?= View::escape($a['papel']) ?></td>
  98 + <td>
  99 + <?php if ($a['ativo']): ?>
  100 + <span class="badge ok">Ativo</span>
  101 + <?php else: ?>
  102 + <span class="badge off">Inativo</span>
  103 + <?php endif; ?>
  104 + </td>
  105 + <td>
  106 + <form method="post" action="admins.php" style="display:inline">
  107 + <input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
  108 + <?php if ($a['ativo']): ?>
  109 + <input type="hidden" name="acao" value="remover">
  110 + <button type="submit" class="danger">Remover</button>
  111 + <?php else: ?>
  112 + <input type="hidden" name="acao" value="reativar">
  113 + <button type="submit">Reativar</button>
  114 + <?php endif; ?>
  115 + </form>
  116 + </td>
  117 + </tr>
  118 + <?php endforeach; ?>
  119 + </table>
  120 +</div>
  121 +<?php
  122 +View::footer();
... ...
public_html/assets/style.css 0 → 100644
  1 +++ a/public_html/assets/style.css
... ... @@ -0,0 +1,101 @@
  1 +:root {
  2 + color-scheme: light;
  3 + --bg: #f4f5f7;
  4 + --card: #ffffff;
  5 + --text: #1f2430;
  6 + --muted: #6b7280;
  7 + --border: #e2e4e9;
  8 + --accent: #2563eb;
  9 + --accent-contrast: #ffffff;
  10 + --danger: #dc2626;
  11 + --success: #16a34a;
  12 +}
  13 +
  14 +* { box-sizing: border-box; }
  15 +
  16 +body {
  17 + margin: 0;
  18 + font-family: -apple-system, Segoe UI, Roboto, Arial, sans-serif;
  19 + background: var(--bg);
  20 + color: var(--text);
  21 +}
  22 +
  23 +.topbar {
  24 + display: flex;
  25 + justify-content: space-between;
  26 + align-items: center;
  27 + padding: 14px 24px;
  28 + background: var(--card);
  29 + border-bottom: 1px solid var(--border);
  30 +}
  31 +
  32 +.topbar nav a {
  33 + margin-left: 16px;
  34 + color: var(--text);
  35 + text-decoration: none;
  36 + font-size: 14px;
  37 +}
  38 +
  39 +.topbar nav a:hover { color: var(--accent); }
  40 +
  41 +.container {
  42 + max-width: 960px;
  43 + margin: 32px auto;
  44 + padding: 0 16px;
  45 +}
  46 +
  47 +.card {
  48 + background: var(--card);
  49 + border: 1px solid var(--border);
  50 + border-radius: 10px;
  51 + padding: 24px;
  52 +}
  53 +
  54 +.login-wrap {
  55 + display: flex;
  56 + min-height: 100vh;
  57 + align-items: center;
  58 + justify-content: center;
  59 +}
  60 +
  61 +.login-wrap .card { width: 100%; max-width: 360px; }
  62 +
  63 +h1 { font-size: 20px; margin: 0 0 16px; }
  64 +
  65 +label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
  66 +
  67 +input[type=text], input[type=password], select {
  68 + width: 100%;
  69 + padding: 8px 10px;
  70 + margin-bottom: 14px;
  71 + border: 1px solid var(--border);
  72 + border-radius: 6px;
  73 + font-size: 14px;
  74 +}
  75 +
  76 +button, .btn {
  77 + display: inline-block;
  78 + background: var(--accent);
  79 + color: var(--accent-contrast);
  80 + border: none;
  81 + padding: 8px 14px;
  82 + border-radius: 6px;
  83 + font-size: 14px;
  84 + cursor: pointer;
  85 + text-decoration: none;
  86 +}
  87 +
  88 +button.danger, .btn.danger { background: var(--danger); }
  89 +button.secondary, .btn.secondary { background: var(--muted); }
  90 +
  91 +table { width: 100%; border-collapse: collapse; margin-top: 12px; }
  92 +th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); font-size: 14px; }
  93 +th { color: var(--muted); font-weight: 600; }
  94 +
  95 +.badge { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
  96 +.badge.ok { background: #dcfce7; color: var(--success); }
  97 +.badge.off { background: #fee2e2; color: var(--danger); }
  98 +
  99 +.alert { padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 14px; }
  100 +.alert.error { background: #fee2e2; color: var(--danger); }
  101 +.alert.success { background: #dcfce7; color: var(--success); }
... ...
public_html/dashboard.php 0 → 100644
  1 +++ a/public_html/dashboard.php
... ... @@ -0,0 +1,91 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +require __DIR__ . '/../src/bootstrap.php';
  6 +Auth::requireLogin();
  7 +
  8 +$erro = null;
  9 +$ok = null;
  10 +
  11 +try {
  12 + $ldap = new Ldap();
  13 + $ldap->bindService();
  14 +
  15 + $groupFilter = SettingsRepository::get('group_aluno_filter', 'cn=grpCI-Aluno-Info');
  16 + $logins = $ldap->groupMembers($groupFilter);
  17 +
  18 + $alunos = [];
  19 + foreach ($logins as $cn) {
  20 + $entry = $ldap->findUserByCn($cn);
  21 + if ($entry === null) {
  22 + continue;
  23 + }
  24 + $accountControl = (int) ($entry['useraccountcontrol'][0] ?? 0);
  25 + $alunos[] = [
  26 + 'cn' => $cn,
  27 + 'nome' => $entry['sn'][0] ?? $cn,
  28 + 'habilitado' => ($accountControl & 2) !== 2,
  29 + 'descricao' => $entry['description'][0] ?? '',
  30 + ];
  31 + }
  32 +} catch (Throwable $e) {
  33 + $erro = 'Não foi possível consultar o AD agora: ' . $e->getMessage();
  34 + $alunos = [];
  35 +}
  36 +
  37 +if (($_GET['ok'] ?? '') === '1') {
  38 + $ok = 'Ação realizada com sucesso.';
  39 +} elseif (($_GET['erro'] ?? '') === '1') {
  40 + $erro = $_GET['msg'] ?? 'Ocorreu um erro ao processar a ação.';
  41 +}
  42 +
  43 +View::header('Alunos');
  44 +?>
  45 +<div class="card">
  46 + <h1>Alunos do grupo</h1>
  47 +
  48 + <?php if ($erro): ?>
  49 + <div class="alert error"><?= View::escape($erro) ?></div>
  50 + <?php endif; ?>
  51 + <?php if ($ok): ?>
  52 + <div class="alert success"><?= View::escape($ok) ?></div>
  53 + <?php endif; ?>
  54 +
  55 + <table>
  56 + <tr>
  57 + <th>Aluno</th>
  58 + <th>Status</th>
  59 + <th>Última alteração por</th>
  60 + <th>Ações</th>
  61 + </tr>
  62 + <?php foreach ($alunos as $aluno): ?>
  63 + <tr>
  64 + <td><?= View::escape($aluno['nome']) ?></td>
  65 + <td>
  66 + <?php if ($aluno['habilitado']): ?>
  67 + <span class="badge ok">Habilitado</span>
  68 + <?php else: ?>
  69 + <span class="badge off">Desabilitado</span>
  70 + <?php endif; ?>
  71 + </td>
  72 + <td><?= View::escape($aluno['descricao']) ?></td>
  73 + <td>
  74 + <form method="post" action="action_status.php" style="display:inline">
  75 + <input type="hidden" name="user" value="<?= View::escape($aluno['cn']) ?>">
  76 + <input type="hidden" name="enable" value="<?= $aluno['habilitado'] ? '0' : '1' ?>">
  77 + <button type="submit" class="<?= $aluno['habilitado'] ? 'danger' : '' ?>">
  78 + <?= $aluno['habilitado'] ? 'Desabilitar' : 'Habilitar' ?>
  79 + </button>
  80 + </form>
  81 + <a class="btn secondary" href="newpasswd.php?user=<?= urlencode($aluno['cn']) ?>">Nova senha</a>
  82 + </td>
  83 + </tr>
  84 + <?php endforeach; ?>
  85 + <?php if (!$alunos && !$erro): ?>
  86 + <tr><td colspan="4">Nenhum aluno encontrado no grupo.</td></tr>
  87 + <?php endif; ?>
  88 + </table>
  89 +</div>
  90 +<?php
  91 +View::footer();
... ...
public_html/index.php 0 → 100644
  1 +++ a/public_html/index.php
... ... @@ -0,0 +1,58 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +require __DIR__ . '/../src/bootstrap.php';
  6 +
  7 +if (!empty($_SESSION['login'])) {
  8 + header('Location: dashboard.php');
  9 + exit;
  10 +}
  11 +
  12 +$erro = null;
  13 +
  14 +if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  15 + $login = trim((string) ($_POST['login'] ?? ''));
  16 + $senha = (string) ($_POST['senha'] ?? '');
  17 +
  18 + if ($login === '' || $senha === '') {
  19 + $erro = 'Preencha login e senha.';
  20 + } else {
  21 + $result = Auth::attempt($login, $senha);
  22 + if ($result['ok']) {
  23 + header('Location: dashboard.php');
  24 + exit;
  25 + }
  26 + $erro = $result['error'];
  27 + }
  28 +} elseif (($_GET['erro'] ?? '') === 'sessao_expirada') {
  29 + $erro = 'Sessão expirada. Faça login novamente.';
  30 +}
  31 +?>
  32 +<!doctype html>
  33 +<html lang="pt-br">
  34 +<head>
  35 + <meta charset="utf-8">
  36 + <title>Gestão de Alunos - LabInfo</title>
  37 + <link rel="stylesheet" href="assets/style.css">
  38 +</head>
  39 +<body>
  40 + <div class="login-wrap">
  41 + <div class="card">
  42 + <h1>Gestão de Alunos (AD)</h1>
  43 + <?php if ($erro): ?>
  44 + <div class="alert error"><?= htmlspecialchars($erro, ENT_QUOTES) ?></div>
  45 + <?php endif; ?>
  46 + <form method="post" action="index.php">
  47 + <label for="login">Login</label>
  48 + <input type="text" id="login" name="login" autocomplete="username" required autofocus>
  49 +
  50 + <label for="senha">Senha</label>
  51 + <input type="password" id="senha" name="senha" autocomplete="current-password" required>
  52 +
  53 + <button type="submit">Entrar</button>
  54 + </form>
  55 + </div>
  56 + </div>
  57 +</body>
  58 +</html>
... ...
public_html/logout.php 0 → 100644
  1 +++ a/public_html/logout.php
... ... @@ -0,0 +1,9 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +require __DIR__ . '/../src/bootstrap.php';
  6 +
  7 +Auth::logout();
  8 +header('Location: index.php');
  9 +exit;
... ...
public_html/newpasswd.php 0 → 100644
  1 +++ a/public_html/newpasswd.php
... ... @@ -0,0 +1,68 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +require __DIR__ . '/../src/bootstrap.php';
  6 +Auth::requireLogin();
  7 +
  8 +$user = trim((string) ($_GET['user'] ?? $_POST['user'] ?? ''));
  9 +$erro = null;
  10 +$ok = null;
  11 +
  12 +if ($user === '') {
  13 + $erro = 'Usuário inválido.';
  14 +} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
  15 + $pass1 = (string) ($_POST['passwd1'] ?? '');
  16 + $pass2 = (string) ($_POST['passwd2'] ?? '');
  17 +
  18 + if ($pass1 === '' || $pass1 !== $pass2) {
  19 + $erro = 'As senhas não coincidem ou estão em branco.';
  20 + } elseif (strlen($pass1) < 8) {
  21 + $erro = 'A senha precisa ter pelo menos 8 caracteres.';
  22 + } else {
  23 + try {
  24 + $ldap = new Ldap();
  25 + $ldap->bindService();
  26 +
  27 + $entry = $ldap->findUserByCn($user);
  28 + if ($entry === null) {
  29 + throw new RuntimeException('Aluno não encontrado no AD.');
  30 + }
  31 + $dn = $ldap->dnOf($entry);
  32 + $ldap->modify($dn, [
  33 + 'unicodePwd' => Ldap::encodePassword($pass1),
  34 + ]);
  35 +
  36 + $quemFez = Auth::currentLogin() ?? 'desconhecido';
  37 + AuditLog::record($quemFez, 'alterou_senha_aluno', $user);
  38 +
  39 + $ok = 'Senha alterada com sucesso!';
  40 + } catch (Throwable $e) {
  41 + $erro = 'Erro ao alterar a senha: ' . $e->getMessage();
  42 + }
  43 + }
  44 +}
  45 +
  46 +View::header('Nova senha');
  47 +?>
  48 +<div class="card">
  49 + <h1>Nova senha para <?= View::escape($user) ?></h1>
  50 +
  51 + <?php if ($erro): ?><div class="alert error"><?= View::escape($erro) ?></div><?php endif; ?>
  52 + <?php if ($ok): ?><div class="alert success"><?= View::escape($ok) ?></div><?php endif; ?>
  53 +
  54 + <?php if (!$ok): ?>
  55 + <form method="post" action="newpasswd.php">
  56 + <input type="hidden" name="user" value="<?= View::escape($user) ?>">
  57 + <label for="passwd1">Nova senha</label>
  58 + <input type="password" id="passwd1" name="passwd1" required minlength="8">
  59 + <label for="passwd2">Confirmar senha</label>
  60 + <input type="password" id="passwd2" name="passwd2" required minlength="8">
  61 + <button type="submit">Alterar</button>
  62 + </form>
  63 + <?php endif; ?>
  64 +
  65 + <p><a href="dashboard.php">&larr; Voltar</a></p>
  66 +</div>
  67 +<?php
  68 +View::footer();
... ...
public_html/settings.php 0 → 100644
  1 +++ a/public_html/settings.php
... ... @@ -0,0 +1,45 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +require __DIR__ . '/../src/bootstrap.php';
  6 +Auth::requireAdmin();
  7 +
  8 +$erro = null;
  9 +$ok = null;
  10 +
  11 +if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  12 + $groupFilter = trim((string) ($_POST['group_aluno_filter'] ?? ''));
  13 + if ($groupFilter === '') {
  14 + $erro = 'Informe o filtro/CN do grupo de alunos.';
  15 + } else {
  16 + SettingsRepository::set('group_aluno_filter', $groupFilter, Auth::currentLogin() ?? 'desconhecido');
  17 + AuditLog::record(Auth::currentLogin() ?? 'desconhecido', 'atualizou_settings', 'group_aluno_filter', $groupFilter);
  18 + $ok = 'Configurações salvas.';
  19 + }
  20 +}
  21 +
  22 +$settings = SettingsRepository::all();
  23 +
  24 +View::header('Configurações');
  25 +?>
  26 +<div class="card">
  27 + <h1>Configurações</h1>
  28 +
  29 + <?php if ($erro): ?><div class="alert error"><?= View::escape($erro) ?></div><?php endif; ?>
  30 + <?php if ($ok): ?><div class="alert success"><?= View::escape($ok) ?></div><?php endif; ?>
  31 +
  32 + <form method="post" action="settings.php">
  33 + <label for="group_aluno_filter">Grupo (CN) de alunos monitorado no dashboard</label>
  34 + <input type="text" id="group_aluno_filter" name="group_aluno_filter"
  35 + value="<?= View::escape($settings['group_aluno_filter'] ?? '') ?>" required>
  36 + <button type="submit">Salvar</button>
  37 + </form>
  38 +
  39 + <p style="color:#6b7280;font-size:13px">
  40 + As demais configurações de conexão (servidor LDAP, conta de serviço, credenciais de banco)
  41 + ficam em <code>config/local.php</code>, fora do versionamento, por segurança.
  42 + </p>
  43 +</div>
  44 +<?php
  45 +View::footer();
... ...
sql/schema.sql 0 → 100644
  1 +++ a/sql/schema.sql
... ... @@ -0,0 +1,36 @@
  1 +CREATE TABLE IF NOT EXISTS admins (
  2 + id INT AUTO_INCREMENT PRIMARY KEY,
  3 + login VARCHAR(64) NOT NULL UNIQUE, -- login do AD, sem domínio (ex: willen)
  4 + nome VARCHAR(190) NULL, -- cache do displayName, atualizado no login
  5 + papel ENUM('admin','operador') NOT NULL DEFAULT 'operador',
  6 + ativo TINYINT(1) NOT NULL DEFAULT 1,
  7 + criado_por VARCHAR(64) NULL,
  8 + criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  9 + atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  10 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  11 +
  12 +CREATE TABLE IF NOT EXISTS settings (
  13 + chave VARCHAR(64) PRIMARY KEY,
  14 + valor TEXT NOT NULL,
  15 + atualizado_por VARCHAR(64) NULL,
  16 + atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  17 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  18 +
  19 +CREATE TABLE IF NOT EXISTS audit_log (
  20 + id INT AUTO_INCREMENT PRIMARY KEY,
  21 + usuario VARCHAR(64) NOT NULL,
  22 + acao VARCHAR(64) NOT NULL,
  23 + alvo VARCHAR(190) NULL,
  24 + detalhe VARCHAR(255) NULL,
  25 + ip VARCHAR(45) NULL,
  26 + criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  27 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  28 +
  29 +CREATE TABLE IF NOT EXISTS login_attempts (
  30 + id INT AUTO_INCREMENT PRIMARY KEY,
  31 + login VARCHAR(64) NOT NULL,
  32 + ip VARCHAR(45) NOT NULL,
  33 + sucesso TINYINT(1) NOT NULL,
  34 + criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  35 + INDEX idx_login_ip_time (login, ip, criado_em)
  36 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
... ...
src/AdminRepository.php 0 → 100644
  1 +++ a/src/AdminRepository.php
... ... @@ -0,0 +1,66 @@
  1 +<?php
  2 +
  3 +final class AdminRepository
  4 +{
  5 + public static function findActiveByLogin(string $login): ?array
  6 + {
  7 + $stmt = Database::pdo()->prepare('SELECT * FROM admins WHERE login = :login AND ativo = 1');
  8 + $stmt->execute(['login' => $login]);
  9 + $row = $stmt->fetch();
  10 + return $row ?: null;
  11 + }
  12 +
  13 + public static function all(): array
  14 + {
  15 + return Database::pdo()
  16 + ->query('SELECT * FROM admins ORDER BY ativo DESC, login ASC')
  17 + ->fetchAll();
  18 + }
  19 +
  20 + public static function countActiveAdmins(?int $excludeId = null): int
  21 + {
  22 + $sql = "SELECT COUNT(*) FROM admins WHERE ativo = 1 AND papel = 'admin'";
  23 + $params = [];
  24 + if ($excludeId !== null) {
  25 + $sql .= ' AND id != :id';
  26 + $params['id'] = $excludeId;
  27 + }
  28 + $stmt = Database::pdo()->prepare($sql);
  29 + $stmt->execute($params);
  30 + return (int) $stmt->fetchColumn();
  31 + }
  32 +
  33 + public static function add(string $login, ?string $nome, string $papel, string $criadoPor): void
  34 + {
  35 + $stmt = Database::pdo()->prepare(
  36 + 'INSERT INTO admins (login, nome, papel, criado_por) VALUES (:login, :nome, :papel, :criado_por)
  37 + ON DUPLICATE KEY UPDATE ativo = 1, papel = VALUES(papel), nome = VALUES(nome)'
  38 + );
  39 + $stmt->execute([
  40 + 'login' => $login,
  41 + 'nome' => $nome,
  42 + 'papel' => $papel,
  43 + 'criado_por' => $criadoPor,
  44 + ]);
  45 + }
  46 +
  47 + public static function setActive(int $id, bool $active): void
  48 + {
  49 + $stmt = Database::pdo()->prepare('UPDATE admins SET ativo = :ativo WHERE id = :id');
  50 + $stmt->execute(['ativo' => $active ? 1 : 0, 'id' => $id]);
  51 + }
  52 +
  53 + public static function find(int $id): ?array
  54 + {
  55 + $stmt = Database::pdo()->prepare('SELECT * FROM admins WHERE id = :id');
  56 + $stmt->execute(['id' => $id]);
  57 + $row = $stmt->fetch();
  58 + return $row ?: null;
  59 + }
  60 +
  61 + public static function touchLoginCache(string $login, ?string $nome): void
  62 + {
  63 + $stmt = Database::pdo()->prepare('UPDATE admins SET nome = :nome WHERE login = :login');
  64 + $stmt->execute(['nome' => $nome, 'login' => $login]);
  65 + }
  66 +}
... ...
src/AuditLog.php 0 → 100644
  1 +++ a/src/AuditLog.php
... ... @@ -0,0 +1,39 @@
  1 +<?php
  2 +
  3 +final class AuditLog
  4 +{
  5 + public static function record(string $usuario, string $acao, ?string $alvo = null, ?string $detalhe = null): void
  6 + {
  7 + $ip = $_SERVER['REMOTE_ADDR'] ?? null;
  8 +
  9 + try {
  10 + $stmt = Database::pdo()->prepare(
  11 + 'INSERT INTO audit_log (usuario, acao, alvo, detalhe, ip) VALUES (:usuario, :acao, :alvo, :detalhe, :ip)'
  12 + );
  13 + $stmt->execute([
  14 + 'usuario' => $usuario,
  15 + 'acao' => $acao,
  16 + 'alvo' => $alvo,
  17 + 'detalhe' => $detalhe,
  18 + 'ip' => $ip,
  19 + ]);
  20 + } catch (Throwable $e) {
  21 + // Nunca deixa uma falha de log derrubar a ação principal.
  22 + error_log('AuditLog falhou: ' . $e->getMessage());
  23 + }
  24 +
  25 + $logFile = Config::get('app.log_file');
  26 + if ($logFile) {
  27 + $line = sprintf(
  28 + "%s|%s|%s|%s|%s|%s\n",
  29 + date('Y-m-d H:i:s'),
  30 + $ip,
  31 + $usuario,
  32 + $acao,
  33 + $alvo ?? '-',
  34 + $detalhe ?? '-'
  35 + );
  36 + @file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
  37 + }
  38 + }
  39 +}
... ...
src/Auth.php 0 → 100644
  1 +++ a/src/Auth.php
... ... @@ -0,0 +1,134 @@
  1 +<?php
  2 +
  3 +final class Auth
  4 +{
  5 + private const MAX_ATTEMPTS = 5;
  6 + private const WINDOW_MINUTES = 15;
  7 +
  8 + public static function start(): void
  9 + {
  10 + if (session_status() === PHP_SESSION_NONE) {
  11 + session_name(Config::get('app.session_name', 'ad_session'));
  12 + session_set_cookie_params([
  13 + 'lifetime' => 0,
  14 + 'path' => '/',
  15 + 'secure' => (!empty($_SERVER['HTTPS'])),
  16 + 'httponly' => true,
  17 + 'samesite' => 'Lax',
  18 + ]);
  19 + session_start();
  20 + }
  21 + }
  22 +
  23 + public static function attempt(string $login, string $password): array
  24 + {
  25 + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
  26 +
  27 + if (self::isRateLimited($login, $ip)) {
  28 + return ['ok' => false, 'error' => 'Muitas tentativas. Aguarde alguns minutos e tente novamente.'];
  29 + }
  30 +
  31 + $ldap = new Ldap();
  32 + $bindOk = $ldap->tryUserBind($login, $password);
  33 +
  34 + self::logAttempt($login, $ip, $bindOk);
  35 +
  36 + if (!$bindOk) {
  37 + return ['ok' => false, 'error' => 'Usuário ou senha incorretos.'];
  38 + }
  39 +
  40 + $admin = AdminRepository::findActiveByLogin($login);
  41 + if (!$admin) {
  42 + AuditLog::record($login, 'login_negado', null, 'usuário autenticado no AD mas sem permissão no sistema');
  43 + return ['ok' => false, 'error' => 'Seu usuário não tem permissão para acessar este sistema.'];
  44 + }
  45 +
  46 + // Atualiza o nome em cache a partir do AD, sem depender da conta de serviço falhar.
  47 + try {
  48 + $ldap->bindService();
  49 + $entry = $ldap->findUserByLogin($login);
  50 + $nome = $entry['displayname'][0] ?? null;
  51 + if ($nome) {
  52 + AdminRepository::touchLoginCache($login, $nome);
  53 + $admin['nome'] = $nome;
  54 + }
  55 + } catch (Throwable $e) {
  56 + // Não bloqueia o login por causa da conta de serviço.
  57 + }
  58 +
  59 + self::start();
  60 + session_regenerate_id(true);
  61 + $_SESSION['login'] = $login;
  62 + $_SESSION['nome'] = $admin['nome'] ?? $login;
  63 + $_SESSION['papel'] = $admin['papel'];
  64 +
  65 + AuditLog::record($login, 'login', null, 'login efetuado');
  66 +
  67 + return ['ok' => true];
  68 + }
  69 +
  70 + public static function logout(): void
  71 + {
  72 + self::start();
  73 + $login = $_SESSION['login'] ?? 'desconhecido';
  74 + AuditLog::record($login, 'logout');
  75 + $_SESSION = [];
  76 + session_destroy();
  77 + }
  78 +
  79 + public static function requireLogin(): void
  80 + {
  81 + self::start();
  82 + if (empty($_SESSION['login'])) {
  83 + header('Location: index.php?erro=sessao_expirada');
  84 + exit;
  85 + }
  86 + }
  87 +
  88 + public static function requireAdmin(): void
  89 + {
  90 + self::requireLogin();
  91 + if (($_SESSION['papel'] ?? null) !== 'admin') {
  92 + http_response_code(403);
  93 + echo 'Acesso restrito a administradores.';
  94 + exit;
  95 + }
  96 + }
  97 +
  98 + public static function currentLogin(): ?string
  99 + {
  100 + return $_SESSION['login'] ?? null;
  101 + }
  102 +
  103 + public static function currentName(): ?string
  104 + {
  105 + return $_SESSION['nome'] ?? null;
  106 + }
  107 +
  108 + public static function isAdmin(): bool
  109 + {
  110 + return ($_SESSION['papel'] ?? null) === 'admin';
  111 + }
  112 +
  113 + private static function isRateLimited(string $login, string $ip): bool
  114 + {
  115 + $stmt = Database::pdo()->prepare(
  116 + 'SELECT COUNT(*) FROM login_attempts
  117 + WHERE login = :login AND ip = :ip AND sucesso = 0
  118 + AND criado_em >= (NOW() - INTERVAL :minutes MINUTE)'
  119 + );
  120 + $stmt->bindValue('login', $login);
  121 + $stmt->bindValue('ip', $ip);
  122 + $stmt->bindValue('minutes', self::WINDOW_MINUTES, PDO::PARAM_INT);
  123 + $stmt->execute();
  124 + return (int) $stmt->fetchColumn() >= self::MAX_ATTEMPTS;
  125 + }
  126 +
  127 + private static function logAttempt(string $login, string $ip, bool $success): void
  128 + {
  129 + $stmt = Database::pdo()->prepare(
  130 + 'INSERT INTO login_attempts (login, ip, sucesso) VALUES (:login, :ip, :sucesso)'
  131 + );
  132 + $stmt->execute(['login' => $login, 'ip' => $ip, 'sucesso' => $success ? 1 : 0]);
  133 + }
  134 +}
... ...
src/Config.php 0 → 100644
  1 +++ a/src/Config.php
... ... @@ -0,0 +1,40 @@
  1 +<?php
  2 +
  3 +final class Config
  4 +{
  5 + private static ?array $data = null;
  6 +
  7 + public static function get(string $path, $default = null)
  8 + {
  9 + if (self::$data === null) {
  10 + self::$data = self::load();
  11 + }
  12 +
  13 + $value = self::$data;
  14 + foreach (explode('.', $path) as $segment) {
  15 + if (!is_array($value) || !array_key_exists($segment, $value)) {
  16 + return $default;
  17 + }
  18 + $value = $value[$segment];
  19 + }
  20 +
  21 + return $value;
  22 + }
  23 +
  24 + private static function load(): array
  25 + {
  26 + $localFile = __DIR__ . '/../config/local.php';
  27 + if (!is_file($localFile)) {
  28 + throw new RuntimeException(
  29 + 'config/local.php não encontrado. Copie config/config.example.php para config/local.php e preencha os valores.'
  30 + );
  31 + }
  32 +
  33 + $data = require $localFile;
  34 + if (!is_array($data)) {
  35 + throw new RuntimeException('config/local.php deve retornar um array.');
  36 + }
  37 +
  38 + return $data;
  39 + }
  40 +}
... ...
src/Database.php 0 → 100644
  1 +++ a/src/Database.php
... ... @@ -0,0 +1,25 @@
  1 +<?php
  2 +
  3 +final class Database
  4 +{
  5 + private static ?PDO $pdo = null;
  6 +
  7 + public static function pdo(): PDO
  8 + {
  9 + if (self::$pdo === null) {
  10 + $host = Config::get('db.host');
  11 + $name = Config::get('db.name');
  12 + $user = Config::get('db.user');
  13 + $pass = Config::get('db.pass');
  14 +
  15 + $dsn = "mysql:host={$host};dbname={$name};charset=utf8mb4";
  16 + self::$pdo = new PDO($dsn, $user, $pass, [
  17 + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  18 + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  19 + PDO::ATTR_EMULATE_PREPARES => false,
  20 + ]);
  21 + }
  22 +
  23 + return self::$pdo;
  24 + }
  25 +}
... ...
src/Ldap.php 0 → 100644
  1 +++ a/src/Ldap.php
... ... @@ -0,0 +1,125 @@
  1 +<?php
  2 +
  3 +final class LdapException extends RuntimeException
  4 +{
  5 +}
  6 +
  7 +final class Ldap
  8 +{
  9 + /** @var resource|\LDAP\Connection */
  10 + private $connect;
  11 +
  12 + public function __construct()
  13 + {
  14 + $server = Config::get('ldap.server');
  15 + $this->connect = ldap_connect($server);
  16 + if (!$this->connect) {
  17 + throw new LdapException('Não foi possível conectar ao servidor LDAP.');
  18 + }
  19 + ldap_set_option($this->connect, LDAP_OPT_PROTOCOL_VERSION, 3);
  20 + ldap_set_option($this->connect, LDAP_OPT_REFERRALS, 0);
  21 + ldap_set_option($this->connect, LDAP_OPT_NETWORK_TIMEOUT, 5);
  22 + }
  23 +
  24 + /**
  25 + * Tenta autenticar o usuário com a própria senha dele (bind direto).
  26 + * Retorna true/false, nunca lança por credencial errada.
  27 + */
  28 + public function tryUserBind(string $login, string $password): bool
  29 + {
  30 + if ($login === '' || $password === '') {
  31 + return false;
  32 + }
  33 + $userPrincipal = $login . Config::get('ldap.domain_suffix', '@cefetes.br');
  34 + set_error_handler(static function () {
  35 + return true;
  36 + }); // silencia warning nativo do ldap_bind em credencial inválida, sem usar @
  37 + try {
  38 + return (bool) ldap_bind($this->connect, $userPrincipal, $password);
  39 + } finally {
  40 + restore_error_handler();
  41 + }
  42 + }
  43 +
  44 + /**
  45 + * Faz bind com a conta de serviço (somente leitura), usada para todas as buscas.
  46 + */
  47 + public function bindService(): void
  48 + {
  49 + $user = Config::get('ldap.service_user');
  50 + $pass = Config::get('ldap.service_pass');
  51 + $userPrincipal = $user . Config::get('ldap.domain_suffix', '@cefetes.br');
  52 + $ok = @ldap_bind($this->connect, $userPrincipal, $pass);
  53 + if (!$ok) {
  54 + throw new LdapException('Falha ao autenticar a conta de serviço no LDAP.');
  55 + }
  56 + }
  57 +
  58 + public function search(string $filter): array
  59 + {
  60 + $baseDn = Config::get('ldap.base_dn');
  61 + $result = @ldap_search($this->connect, $baseDn, $filter);
  62 + if ($result === false) {
  63 + throw new LdapException('Erro na busca LDAP: ' . ldap_error($this->connect));
  64 + }
  65 + $entries = @ldap_get_entries($this->connect, $result);
  66 + if ($entries === false) {
  67 + throw new LdapException('Erro ao ler entradas LDAP: ' . ldap_error($this->connect));
  68 + }
  69 + return $entries;
  70 + }
  71 +
  72 + public function findUserByCn(string $cn): ?array
  73 + {
  74 + $filter = '(cn=' . ldap_escape($cn, '', LDAP_ESCAPE_FILTER) . ')';
  75 + $entries = $this->search($filter);
  76 + return $entries['count'] > 0 ? $entries[0] : null;
  77 + }
  78 +
  79 + public function findUserByLogin(string $login): ?array
  80 + {
  81 + $filter = '(sAMAccountName=' . ldap_escape($login, '', LDAP_ESCAPE_FILTER) . ')';
  82 + $entries = $this->search($filter);
  83 + return $entries['count'] > 0 ? $entries[0] : null;
  84 + }
  85 +
  86 + public function groupMembers(string $groupDnOrFilter): array
  87 + {
  88 + // aceita tanto um filtro pronto ("cn=grpX") quanto um CN cru
  89 + $filter = str_contains($groupDnOrFilter, '=')
  90 + ? $groupDnOrFilter
  91 + : '(cn=' . ldap_escape($groupDnOrFilter, '', LDAP_ESCAPE_FILTER) . ')';
  92 + $entries = $this->search($filter);
  93 + if ($entries['count'] === 0) {
  94 + return [];
  95 + }
  96 + $members = [];
  97 + $count = (int) ($entries[0]['member']['count'] ?? 0);
  98 + for ($i = 0; $i < $count; $i++) {
  99 + $dn = $entries[0]['member'][$i];
  100 + $parts = explode(',', $dn);
  101 + $cn = str_replace('CN=', '', $parts[0]);
  102 + $members[] = $cn;
  103 + }
  104 + return $members;
  105 + }
  106 +
  107 + public function modify(string $dn, array $attributes): void
  108 + {
  109 + $ok = @ldap_mod_replace($this->connect, $dn, $attributes);
  110 + if (!$ok) {
  111 + throw new LdapException('Erro ao modificar LDAP: ' . ldap_error($this->connect));
  112 + }
  113 + }
  114 +
  115 + public function dnOf(array $entry): ?string
  116 + {
  117 + return $entry['dn'] ?? null;
  118 + }
  119 +
  120 + public static function encodePassword(string $plain): string
  121 + {
  122 + // AD exige a senha entre aspas e em UTF-16LE para unicodePwd.
  123 + return iconv('UTF-8', 'UTF-16LE', '"' . $plain . '"');
  124 + }
  125 +}
... ...
src/SettingsRepository.php 0 → 100644
  1 +++ a/src/SettingsRepository.php
... ... @@ -0,0 +1,38 @@
  1 +<?php
  2 +
  3 +final class SettingsRepository
  4 +{
  5 + private const DEFAULTS = [
  6 + 'group_aluno_filter' => 'cn=grpCI-Aluno-Info',
  7 + ];
  8 +
  9 + public static function get(string $key, $default = null)
  10 + {
  11 + $stmt = Database::pdo()->prepare('SELECT valor FROM settings WHERE chave = :chave');
  12 + $stmt->execute(['chave' => $key]);
  13 + $value = $stmt->fetchColumn();
  14 + if ($value !== false) {
  15 + return $value;
  16 + }
  17 + return self::DEFAULTS[$key] ?? $default;
  18 + }
  19 +
  20 + public static function set(string $key, string $value, string $usuario): void
  21 + {
  22 + $stmt = Database::pdo()->prepare(
  23 + 'INSERT INTO settings (chave, valor, atualizado_por) VALUES (:chave, :valor, :usuario)
  24 + ON DUPLICATE KEY UPDATE valor = VALUES(valor), atualizado_por = VALUES(atualizado_por)'
  25 + );
  26 + $stmt->execute(['chave' => $key, 'valor' => $value, 'usuario' => $usuario]);
  27 + }
  28 +
  29 + public static function all(): array
  30 + {
  31 + $rows = Database::pdo()->query('SELECT chave, valor FROM settings')->fetchAll();
  32 + $result = self::DEFAULTS;
  33 + foreach ($rows as $row) {
  34 + $result[$row['chave']] = $row['valor'];
  35 + }
  36 + return $result;
  37 + }
  38 +}
... ...
src/View.php 0 → 100644
  1 +++ a/src/View.php
... ... @@ -0,0 +1,44 @@
  1 +<?php
  2 +
  3 +final class View
  4 +{
  5 + public static function header(string $title): void
  6 + {
  7 + ?><!doctype html>
  8 +<html lang="pt-br">
  9 +<head>
  10 + <meta charset="utf-8">
  11 + <title><?= htmlspecialchars($title, ENT_QUOTES) ?> - LabInfo</title>
  12 + <link rel="stylesheet" href="assets/style.css">
  13 +</head>
  14 +<body>
  15 +<div class="topbar">
  16 + <strong>Gestão de Alunos (AD)</strong>
  17 + <nav>
  18 + <span><?= htmlspecialchars(Auth::currentName() ?? '', ENT_QUOTES) ?></span>
  19 + <a href="dashboard.php">Alunos</a>
  20 + <?php if (Auth::isAdmin()): ?>
  21 + <a href="admins.php">Administradores</a>
  22 + <a href="settings.php">Configurações</a>
  23 + <?php endif; ?>
  24 + <a href="logout.php">Sair</a>
  25 + </nav>
  26 +</div>
  27 +<div class="container">
  28 +<?php
  29 + }
  30 +
  31 + public static function footer(): void
  32 + {
  33 + ?>
  34 +</div>
  35 +</body>
  36 +</html>
  37 +<?php
  38 + }
  39 +
  40 + public static function escape(?string $value): string
  41 + {
  42 + return htmlspecialchars($value ?? '', ENT_QUOTES);
  43 + }
  44 +}
... ...
src/bootstrap.php 0 → 100644
  1 +++ a/src/bootstrap.php
... ... @@ -0,0 +1,21 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +error_reporting(E_ALL);
  6 +ini_set('display_errors', '0'); // nunca mostrar erro cru em produção
  7 +
  8 +spl_autoload_register(static function (string $class): void {
  9 + $file = __DIR__ . '/' . $class . '.php';
  10 + if (is_file($file)) {
  11 + require $file;
  12 + }
  13 +});
  14 +
  15 +set_exception_handler(static function (Throwable $e): void {
  16 + error_log('Uncaught: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
  17 + http_response_code(500);
  18 + echo 'Ocorreu um erro inesperado. Tente novamente mais tarde.';
  19 +});
  20 +
  21 +Auth::start();
... ...