api.php dashboard ...>>> ?

This commit is contained in:
fenix 2026-03-02 21:45:21 +01:00
parent e8869005da
commit 6528a069cb
5 changed files with 860 additions and 1 deletions

148
PLAN.md Normal file
View File

@ -0,0 +1,148 @@
# Plan: Emacs Integration for napi — Remote File-Notify via TRAMP
## Context
Fénix wants **real-time awareness in Emacs** of student deliveries on zzz (qu3v3d0.tech). The GNU Emacs manual confirms that `file-notify-add-watch` works on **remote machines** via TRAMP — it runs `inotifywait` on the remote host through SSH and streams events back in real-time. No polling.
This means: **no modifications to watcher scripts on zzz**, no reverse SSH tunnels, no Porthole/JSON-RPC. Pure Emacs, using built-in capabilities + a dedicated daemon.
## Architecture
```
emacs --daemon=napi (local)
├── TRAMP SSH → zzz:/home/*/python/ (ASIR1 — 21 dirs)
│ └── inotifywait running on zzz (auto-started by TRAMP)
├── TRAMP SSH → zzz:/home/*/html/ (DDAW2 — 19 dirs)
│ └── inotifywait running on zzz (auto-started by TRAMP)
└── On file event → forward notification to `water` daemon
├── Desktop notification (D-Bus)
├── Sound alert (paplay)
├── *napi-log* buffer (persistent log)
└── Minibuffer message
emacsclient --socket-name=napi --eval '(napi-dashboard)' ← interact
emacsclient --socket-name=water --eval '(napi-log)' ← view in main UI
```
## Prerequisites
- `inotify-tools` installed on zzz (provides `inotifywait`) — **already present** (watchers use it)
- SSH key access from local → zzz — **already working** (`ssh fenix@qu3v3d0.tech`)
- Emacs 30.1 on local — **confirmed**
## Implementation Steps
### Step 1: Create `~/napi/emacs/napi.el`
The core elisp package. ~200 lines. Key components:
| Component | Purpose |
|:----------|:--------|
| `napi-watch-start` | Sets up TRAMP file-notify watches on all student dirs |
| `napi-watch-stop` | Removes all watches cleanly |
| `napi-notify` | Handles a file event: log + desktop notification + sound |
| `napi-log` | Interactive: open `*napi-log*` buffer |
| `napi-open-student` | Interactive: completing-read → open student dir/notas.md |
| `napi-dashboard` | Interactive: overview of all students + last delivery |
| `C-c n` prefix | Keybindings for all interactive commands |
**Student name maps** (username → full name) embedded in elisp, mirroring watcher scripts.
**TRAMP watch setup** — the core innovation:
```elisp
;; For each ASIR1 student:
(file-notify-add-watch
"/ssh:fenix@qu3v3d0.tech:/home/jara/python/"
'(change)
#'napi--handle-event)
;; 40 watches total (21 ASIR1 + 19 DDAW2)
;; Each spawns a persistent inotifywait on zzz via SSH
```
**Event handler** filters noise (Thumbs.db, __pycache__, .tmp, etc.) and forwards clean notifications to `water` daemon via:
```elisp
(shell-command
"emacsclient --socket-name=water --eval '(napi--show-notification ...)' &")
```
**Reconnection**: TRAMP sends a `stopped` event when SSH drops. The handler auto-re-establishes the watch after a delay.
### Step 2: Create systemd user unit for `napi` daemon
File: `~/.config/systemd/user/emacs-napi.service`
```ini
[Unit]
Description=Emacs daemon for napi student monitoring
After=network-online.target
Wants=network-online.target
[Service]
Type=forking
ExecStart=/usr/bin/emacs --daemon=napi -l ~/napi/emacs/napi-init.el
ExecStop=/usr/bin/emacsclient --socket-name=napi --eval "(kill-emacs)"
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
```
### Step 3: Create `~/napi/emacs/napi-init.el`
Minimal init file for the `napi` daemon (not the full `~/.emacs.d/init.el`):
```elisp
;; Loaded ONLY by emacs --daemon=napi
(load "~/napi/emacs/napi.el")
(napi-watch-start)
(message "napi: watching %d student directories on zzz" napi--watch-count)
```
### Step 4: Also load `napi.el` in `water` daemon
Add to `~/.emacs.d/init.el` (near line 1478, following `funciones-art.el` pattern):
```elisp
(load "~/napi/emacs/napi.el")
```
This gives the `water` daemon the `napi-log`, `napi-dashboard`, `napi-open-student` commands and the `C-c n` keybindings — but **no watches** (those run in the `napi` daemon only).
### Step 5: Verify `inotifywait` on zzz
```bash
ssh fenix@qu3v3d0.tech "which inotifywait && inotifywait --help | head -1"
```
## Files to Create/Modify
| File | Action | Location |
|:-----|:-------|:---------|
| `emacs/napi.el` | **CREATE** | `~/napi/emacs/napi.el` |
| `emacs/napi-init.el` | **CREATE** | `~/napi/emacs/napi-init.el` |
| `emacs-napi.service` | **CREATE** | `~/.config/systemd/user/emacs-napi.service` |
| `init.el` | **MODIFY** (1 line) | `~/.emacs.d/init.el` |
**No modifications to zzz** — no watcher changes, no new SSH keys, no reverse tunnels.
## Testing
1. **Unit test** — load `napi.el` in `water`, call `(napi-notify ...)` manually
2. **TRAMP watch test** — single watch on one student dir, touch a file on zzz
3. **Full integration** — start `napi` daemon, simulate SFTP upload as student
4. **Stress test** — confirm 40 concurrent TRAMP watches are stable
5. **Reconnection** — kill SSH, verify watch auto-restores
6. **UI test**`C-c n l` (log), `C-c n s` (student), `C-c n d` (dashboard)
## Risk: 40 Concurrent SSH/inotifywait Processes
Each `file-notify-add-watch` via TRAMP spawns a separate `inotifywait` process on zzz. 40 watches = 40 persistent SSH channels + 40 inotifywait processes.
**Mitigation**: TRAMP reuses SSH connections (ControlMaster). So it's 1 SSH connection + 40 remote processes. The zzz server's `inotify` limit (`fs.inotify.max_user_watches`) is typically 65536 — 40 is nothing.
**Fallback**: If 40 watches proves unstable, consolidate to 2 watches on parent dirs (`/home/` with recursive) + filter events by path. Or switch to the emacsclient-via-SSH approach (see FINDINGS.md).

BIN
Screenshots/napi-sketch.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 KiB

View File

@ -18,4 +18,37 @@ es decir, mencionar el/la autor/a de la entrega por ambos, nombre y apellido
* esto
* TODO Emacs packages using JSON-RPC o other kinda-API thing i could be using in a complementary way to streamline my interaction with the students via 'zzz' server where 'napi' is hosted / serving
---
Emacs Packages Using JSON-RPC / Structured Protocols
For napi/ Specifically (file-sync + notification)
┌──────────────────────────────────────────────────────────────────────────────────────────────┬──────────────────────────────────────────┬────────────────────────────────────────────────────────────────────┐
│ Package │ What It Does │ napi Complement │
├──────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────────────────┤
│ https://github.com/legoscia/emacs-jabber │ XMPP client for Emacs │ Direct integration with your slixmpp notification system │
├──────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────────────────┤
│ https://www.gnu.org/software/emacs/manual/html_node/elisp/File-Notifications.html (built-in) │ inotify/kqueue file watching │ Watch ~/napi-data/ for student uploads natively in Emacs │
├──────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────────────────┤
│ https://github.com/jcaw/json-rpc-server.el + https://github.com/jcaw/porthole │ Expose Emacs functions via HTTP JSON-RPC │ Let external scripts (your inotify watchers) trigger Emacs actions │
└──────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────────┴────────────────────────────────────────────────────────────────────┘
** Explore the idea (as referred on above's gnu.org manual - link) "Notifications on File Changes", which states :
"Several operating systems support watching of filesystems for changes to files or their attributes. If configured
properly, Emacs links a respective library like inotify and others. These libraries enable watching of filesystems on the local machine.
**It is also possible to watch filesystems on remote machines**, see Remote Files in The GNU Emacs Manual. This does not
depend on one of the libraries linked to Emacs.
Since all these libraries emit different events upon notified file changes, Emacs provides a special library filenotify
which presents a unified interface to applications. Lisp programs that want to receive file notifications should always
use this library in preference to the native ones. [..]
the **bold** is mine ! ...
*** Am i crazy saying that ~/napi could be running remotely, as long as a 'emacs -daemon=napi' which i could hook into an 'emacsclient' ?

56
api.php Normal file
View File

@ -0,0 +1,56 @@
<?php
header('Content-Type: text/plain; charset=utf-8');
// Obtener usuario autenticado
$user = $_SERVER['REMOTE_USER'] ?? '';
if (empty($user)) {
http_response_code(401);
exit("# Error\n\nNo autenticado.");
}
// Leer markdown
$file = __DIR__ . '/data/tabla-de-seguimiento.md';
if (!file_exists($file)) {
http_response_code(404);
exit("# Dashboard sin Datos\n\nEl profesor aún no ha subido la tabla.");
}
$markdown = file_get_contents($file);
$lines = explode("\n", $markdown);
// Filtrar solo sección del usuario
$output = [];
$inUserSection = false;
$headerShown = false;
foreach ($lines as $line) {
// Título principal
if (!$headerShown && preg_match('/^# /', $line)) {
$output[] = $line;
$headerShown = true;
continue;
}
// Detectar sección ## username
if (preg_match('/^## (.+)$/', $line, $matches)) {
$sectionUser = trim($matches[1]);
$inUserSection = ($sectionUser === $user);
if ($inUserSection) {
$output[] = $line;
}
continue;
}
// Si estamos en sección del usuario, capturar
if ($inUserSection) {
$output[] = $line;
}
}
// Si no hay datos para el usuario
if (count($output) <= 1) {
$output[] = "\n## $user\n";
$output[] = "**No hay datos registrados.**\n";
}
echo implode("\n", $output);

622
dashboard-seguimiento.md Normal file
View File

@ -0,0 +1,622 @@
# Dashboard de Seguimiento - Guía de Implementación
> Sistema KISS de seguimiento académico: Markdown + Basic Auth + PHP + Vanilla JS
**Servidor:** qu3v3d0.tech | **Filosofía:** Unix + KISS
---
## Qué hace este sistema
```
Profesor sube tabla-de-seguimiento.md
Alumnos acceden con user/pass SFTP
Cada alumno ve SOLO su progreso
```
---
## Paso 1: Crear htpasswd para Basic Auth
```bash
# Generar archivo de passwords (password = username por defecto)
sudo touch /etc/nginx/.htpasswd_sftp
for user in alumno{01..20}; do
echo "$user:$(openssl passwd -apr1 $user)" | \
sudo tee -a /etc/nginx/.htpasswd_sftp > /dev/null
done
# Permisos restrictivos
sudo chmod 640 /etc/nginx/.htpasswd_sftp
sudo chown root:www-data /etc/nginx/.htpasswd_sftp
# Verificar
sudo cat /etc/nginx/.htpasswd_sftp
```
**✅ Verificación:**
```
alumno01:$apr1$xyz$...
alumno02:$apr1$abc$...
...
```
---
## Paso 2: Crear estructura de directorios
```bash
# Crear directorios
sudo mkdir -p /home/admin/html/dashboard/data
# Permisos
sudo chown -R admin:www-data /home/admin/html/dashboard
sudo chmod 755 /home/admin/html/dashboard
sudo chmod 775 /home/admin/html/dashboard/data
```
**Estructura final:**
```
/home/admin/html/dashboard/
├── index.html # Cliente (vanilla JS + marked.js)
├── api.php # filtro por usuario
├── style.css # estilo minimalista
└── data/
└── tabla-de-seguimiento.md
```
---
## Paso 3: Crear api.php
```bash
sudo tee /home/admin/html/dashboard/api.php > /dev/null << 'EOF'
<?php
header('Content-Type: text/plain; charset=utf-8');
// Obtener usuario autenticado
$user = $_SERVER['REMOTE_USER'] ?? '';
if (empty($user)) {
http_response_code(401);
exit("# Error\n\nNo autenticado.");
}
// Leer markdown
$file = __DIR__ . '/data/tabla-de-seguimiento.md';
if (!file_exists($file)) {
http_response_code(404);
exit("# Dashboard sin Datos\n\nEl profesor aún no ha subido la tabla.");
}
$markdown = file_get_contents($file);
$lines = explode("\n", $markdown);
// Filtrar solo sección del usuario
$output = [];
$inUserSection = false;
$headerShown = false;
foreach ($lines as $line) {
// Título principal
if (!$headerShown && preg_match('/^# /', $line)) {
$output[] = $line;
$headerShown = true;
continue;
}
// Detectar sección ## username
if (preg_match('/^## (.+)$/', $line, $matches)) {
$sectionUser = trim($matches[1]);
$inUserSection = ($sectionUser === $user);
if ($inUserSection) {
$output[] = $line;
}
continue;
}
// Si estamos en sección del usuario, capturar
if ($inUserSection) {
$output[] = $line;
}
}
// Si no hay datos para el usuario
if (count($output) <= 1) {
$output[] = "\n## $user\n";
$output[] = "**No hay datos registrados.**\n";
}
echo implode("\n", $output);
EOF
sudo chown admin:www-data /home/admin/html/dashboard/api.php
sudo chmod 644 /home/admin/html/dashboard/api.php
```
---
## Paso 4: Crear index.html
```bash
sudo tee /home/admin/html/dashboard/index.html > /dev/null << 'EOF'
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard de Seguimiento</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<header>
<h1>🎯 Dashboard de Seguimiento</h1>
<p class="subtitle">qu3v3d0.tech</p>
</header>
<main id="dashboard">
<div class="loader">
<p>Cargando tu progreso...</p>
<div class="spinner"></div>
</div>
</main>
<footer>
<p>Filosofía KISS + Unix</p>
</footer>
<script>
const parseMarkdown = (md) => marked.parse(md);
const renderHTML = (html) => {
document.getElementById('dashboard').innerHTML = html;
};
const showError = (msg) => {
const html = `<div class="error"><h2>⚠️ Error</h2><p>${msg}</p></div>`;
renderHTML(html);
};
fetch('api.php')
.then(r => r.ok ? r.text() : Promise.reject(r))
.then(parseMarkdown)
.then(renderHTML)
.catch(e => showError(e.message));
</script>
</body>
</html>
EOF
sudo chown admin:www-data /home/admin/html/dashboard/index.html
sudo chmod 644 /home/admin/html/dashboard/index.html
```
---
## Paso 5: Crear style.css
```bash
sudo tee /home/admin/html/dashboard/style.css > /dev/null << 'EOF'
:root {
--bg: #f5f5f5;
--fg: #333;
--accent: #0066cc;
--success: #28a745;
--error: #dc3545;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
color: var(--fg);
background: var(--bg);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: var(--accent);
color: white;
padding: 1.5rem;
text-align: center;
}
header h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
.subtitle { font-size: 0.9rem; opacity: 0.9; }
main {
flex: 1;
max-width: 900px;
width: 100%;
margin: 2rem auto;
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.loader { text-align: center; padding: 2rem; }
.spinner {
width: 40px;
height: 40px;
margin: 1rem auto;
border: 4px solid #ddd;
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
h1 {
color: var(--accent);
border-bottom: 2px solid var(--accent);
padding-bottom: 0.5rem;
margin-bottom: 1.5rem;
}
h2 { margin-top: 1.5rem; margin-bottom: 1rem; color: #555; }
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: var(--bg);
font-weight: 600;
color: var(--accent);
}
tbody tr:hover { background: #f9f9f9; }
ul { margin-left: 1.5rem; margin-bottom: 1rem; }
li { margin-bottom: 0.5rem; }
input[type="checkbox"] { margin-right: 0.5rem; }
.error {
background: #fff3cd;
border: 2px solid #ffc107;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
}
.error h2 { color: var(--error); margin-bottom: 1rem; }
footer {
background: #2c3e50;
color: white;
text-align: center;
padding: 1rem;
font-size: 0.85rem;
}
@media (max-width: 768px) {
main { margin: 1rem; padding: 1rem; }
header h1 { font-size: 1.4rem; }
}
EOF
sudo chown admin:www-data /home/admin/html/dashboard/style.css
sudo chmod 644 /home/admin/html/dashboard/style.css
```
---
## Paso 6: Configurar nginx
```bash
sudo tee /etc/nginx/sites-available/dashboard > /dev/null << 'EOF'
server {
listen 80;
server_name dashboard.qu3v3d0.tech;
root /home/admin/html/dashboard;
index index.html;
# Basic Auth
auth_basic "Dashboard Alumnos";
auth_basic_user_file /etc/nginx/.htpasswd_sftp;
# PHP-FPM
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
fastcgi_param REMOTE_USER $remote_user;
}
# Caché de estáticos
location ~* \.(css|js)$ {
expires 1h;
add_header Cache-Control "public";
}
# Proteger data/
location /data/ { deny all; }
# Logs
access_log /var/log/nginx/dashboard-access.log;
error_log /var/log/nginx/dashboard-error.log;
autoindex off;
}
EOF
# Activar
sudo ln -s /etc/nginx/sites-available/dashboard /etc/nginx/sites-enabled/
# Validar y recargar
sudo nginx -t
sudo systemctl reload nginx
```
---
## Paso 7: Crear tabla-de-seguimiento.md
```bash
sudo tee /home/admin/html/dashboard/data/tabla-de-seguimiento.md > /dev/null << 'EOF'
# Seguimiento - Grupo 2026
## alumno01
| Tarea | Estado | Fecha |
|:------|:------:|------:|
| Config nginx básica | ❌ | - |
| JSON con CORS | ❌ | - |
| Error pages | ❌ | - |
| Caché estático | ❌ | - |
## alumno02
| Tarea | Estado | Fecha |
|:------|:------:|------:|
| Config nginx básica | ❌ | - |
| JSON con CORS | ❌ | - |
| Error pages | ❌ | - |
| Caché estático | ❌ | - |
## alumno03
| Tarea | Estado | Fecha |
|:------|:------:|------:|
| Config nginx básica | ❌ | - |
| JSON con CORS | ❌ | - |
| Error pages | ❌ | - |
| Caché estático | ❌ | - |
EOF
sudo chown admin:www-data /home/admin/html/dashboard/data/tabla-de-seguimiento.md
sudo chmod 664 /home/admin/html/dashboard/data/tabla-de-seguimiento.md
```
**Formato alternativo con checkboxes:**
```markdown
## alumno01
- [ ] Config nginx básica
- [ ] JSON con CORS
- [ ] Error pages
- [ ] Caché estático
```
---
## Paso 8: Verificación
```bash
# 1. Permisos
ls -la /home/admin/html/dashboard/
ls -la /home/admin/html/dashboard/data/
# 2. Nginx
sudo nginx -t
sudo systemctl status nginx
# 3. PHP-FPM
sudo systemctl status php*-fpm
# 4. Test htpasswd
htpasswd -v /etc/nginx/.htpasswd_sftp alumno01
# 5. Test curl
curl -u alumno01:alumno01 http://localhost/dashboard/api.php
```
**✅ Debe retornar:**
```markdown
# Seguimiento - Grupo 2026
## alumno01
| Tarea | Estado | Fecha |
...
```
---
## Paso 9: Test en navegador
1. Abrir: `http://dashboard.qu3v3d0.tech`
2. Login: `alumno01` / `alumno01`
3. Debe mostrar solo la sección de alumno01
4. Probar con alumno02, alumno03...
---
## Uso para el Profesor
### Actualizar tabla via SFTP
```
Host: qu3v3d0.tech
User: admin
Password: ****
Path: /html/dashboard/data/
File: tabla-de-seguimiento.md
```
### Actualizar desde servidor
```bash
ssh admin@qu3v3d0.tech
nano ~/html/dashboard/data/tabla-de-seguimiento.md
# Editar y guardar
```
### Formato de la tabla
**Emojis de estado:**
- ✅ Completado
- ❌ Pendiente
- ⏳ En progreso
**Ejemplo:**
```markdown
## alumno01
| Tarea | Estado | Fecha |
|:------|:------:|------:|
| Config nginx básica | ✅ | 2026-01-20 |
| JSON con CORS | ⏳ | - |
| Error pages | ❌ | - |
**Observaciones:** Buen progreso, revisar CORS.
```
---
## Troubleshooting
### Error 401 Unauthorized
```bash
# Verificar htpasswd
ls -la /etc/nginx/.htpasswd_sftp
grep alumno01 /etc/nginx/.htpasswd_sftp
htpasswd -v /etc/nginx/.htpasswd_sftp alumno01
```
### Error 403 Forbidden
```bash
# Verificar permisos
ls -la /home/admin/html/dashboard/
ps aux | grep nginx # Usuario debe ser www-data
```
### PHP no procesa
```bash
# Verificar PHP-FPM
sudo systemctl status php*-fpm
ls -la /var/run/php/php*-fpm.sock
sudo tail -f /var/log/nginx/dashboard-error.log
```
### Página en blanco
```bash
# Test API directamente
curl -u alumno01:alumno01 http://dashboard.qu3v3d0.tech/api.php
# Ver consola navegador (F12)
# Verificar que marked.js carga correctamente
```
### Usuario ve datos de otros
```bash
# En api.php, agregar debug temporal:
echo "DEBUG: User = $user\n";
# Verificar que ## username coincide exactamente
# (sin espacios extras, case-sensitive)
```
---
## Extensión: Notificación XMPP
Notificar cuando profesor actualiza la tabla:
```bash
# Script watcher
sudo tee /usr/local/bin/dashboard-notify.sh > /dev/null << 'EOF'
#!/bin/bash
WATCH_FILE="/home/admin/html/dashboard/data/tabla-de-seguimiento.md"
inotifywait -m -e close_write "$WATCH_FILE" | while read event; do
/usr/local/bin/xmpp-notify.py "📊 Tabla actualizada por el profesor"
done
EOF
sudo chmod +x /usr/local/bin/dashboard-notify.sh
# Servicio systemd
sudo tee /etc/systemd/system/dashboard-notify.service > /dev/null << 'EOF'
[Unit]
Description=Dashboard Update Notifier
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/dashboard-notify.sh
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now dashboard-notify.service
```
---
## Checklist de Implementación
- [ ] htpasswd creado (`/etc/nginx/.htpasswd_sftp`)
- [ ] Directorios creados (`/home/admin/html/dashboard/`)
- [ ] api.php desplegado (644)
- [ ] index.html desplegado (644)
- [ ] style.css desplegado (644)
- [ ] nginx config creado (`/etc/nginx/sites-available/dashboard`)
- [ ] nginx config activado (symlink a sites-enabled)
- [ ] nginx reload sin errores (`nginx -t`)
- [ ] tabla-de-seguimiento.md inicial (664)
- [ ] PHP-FPM corriendo
- [ ] Test curl exitoso
- [ ] Test navegador alumno01 OK
- [ ] Test navegador alumno02 OK
- [ ] Cada alumno ve solo sus datos
- [ ] Markdown renderiza correctamente
- [ ] Responsive en móvil OK
---
## Resumen
**Componentes:**
- nginx: Auth + routing
- PHP: Filtrado por usuario
- Markdown: Storage
- marked.js: Parsing
- Vanilla JS: Cliente
**Filosofía KISS:**
- 3 archivos principales
- Sin DB
- Sin frameworks
- Sin compilación
**Resultado:** Sistema robusto, mantenible, educativo.
---
*Implementado: 2026-02-13 | qu3v3d0.tech | fenix + Claude*