Cuestión 1 — Estructura, maquetación y accesibilidad
Se emplea HTML5 semántico: el navegador, los lectores de pantalla y los buscadores interpretan el rol de cada bloque sin depender de class. La página se organiza en header (identidad y navegación), main (contenido principal con el formulario y la tabla) y footer.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reserva de aulas de informática · IES Jordi de Sant Jordi</title>
<link rel="stylesheet" href="/css/estilos.css">
</head>
<body>
<header>
<h1>Reserva de aulas de informática</h1>
<nav aria-label="Navegación principal">
<a href="/">Inicio</a> · <a href="/mis-reservas">Mis reservas</a>
</nav>
</header>
<main>
<!-- Formulario de reserva -->
<section aria-labelledby="titulo-form">
<h2 id="titulo-form">Nueva reserva</h2>
<form id="form-reserva" novalidate>
<div class="campo">
<label for="aula">Aula</label>
<select id="aula" name="aula" required>
<option value="">— Selecciona —</option>
<option value="INF-1">Aula Informática 1</option>
<option value="INF-2">Aula Informática 2</option>
</select>
</div>
<div class="campo">
<label for="fecha">Fecha</label>
<input type="date" id="fecha" name="fecha" required>
</div>
<fieldset class="campo">
<legend>Franja horaria</legend>
<label for="franja">Franja</label>
<select id="franja" name="franja" required>
<option value="">— Selecciona —</option>
<option value="1">1.ª (08:00–09:00)</option>
<option value="2">2.ª (09:00–10:00)</option>
<option value="3">3.ª (10:00–11:00)</option>
</select>
</fieldset>
<button type="submit">Reservar</button>
<p id="mensaje" role="status" aria-live="polite"></p>
</form>
</section>
<!-- Reservas del día -->
<section aria-labelledby="titulo-tabla">
<h2 id="titulo-tabla">Reservas de hoy</h2>
<table>
<caption>Aulas reservadas en el día</caption>
<thead>
<tr>
<th scope="col">Aula</th>
<th scope="col">Franja</th>
<th scope="col">Profesor/a</th>
</tr>
</thead>
<tbody id="tbody-reservas">
<!-- filas inyectadas por JS -->
</tbody>
</table>
</section>
</main>
<footer>
<p>IES «Jordi de Sant Jordi» · València</p>
</footer>
<script src="/js/reservas.js"></script>
</body>
</html>
Enfoque CSS (sin detallarlo entero): diseño mobile-first y responsive con media queries. La barra de navegación y los botones se alinean con flexbox; el formulario se distribuye en columnas con CSS Grid (display:grid; grid-template-columns: repeat(auto-fit, minmax(200px,1fr))), colapsando a una sola columna en móvil. La tabla se hace responsive con overflow-x:auto en su contenedor.
Dos buenas prácticas de accesibilidad (WCAG 2.1 / WAI-ARIA):
- Etiquetado explícito de controles: todo
input/select lleva su <label for="…"> asociado al id del control, y los grupos de opciones se envuelven en fieldset/legend. Esto permite que un lector de pantalla anuncie el propósito de cada campo y aumenta el área de clic.
- Estructura percibible y mensajes anunciados: uso de cabeceras de tabla con
scope="col" y <caption>, atributo lang="es", y una región role="status" con aria-live="polite" para que el resultado de la reserva se anuncie sin recargar. Se garantiza además navegación por teclado (orden lógico del DOM, :focus visible) y contraste suficiente (ratio ≥ 4.5:1, WCAG 1.4.3).
Cuestión 2 — Cliente (JavaScript con fetch)
Se intercepta el envío del formulario, se evita la recarga (preventDefault), se construye el cuerpo en JSON y se ramifica según el código de estado de la respuesta. Se usa async/await con try/catch para errores de red.
const form = document.getElementById('form-reserva');
const mensaje = document.getElementById('mensaje');
form.addEventListener('submit', async (e) => {
e.preventDefault(); // no recargar la página
mensaje.textContent = 'Enviando…';
mensaje.className = '';
// 1) Recoger datos del formulario
const datos = {
aula: document.getElementById('aula').value,
fecha: document.getElementById('fecha').value,
franja: document.getElementById('franja').value,
};
try {
// 2) Petición POST con cuerpo JSON
const resp = await fetch('/api/reservas', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(datos),
});
// 3) Gestión de códigos de estado
if (resp.status === 201) {
const reserva = await resp.json();
mensaje.textContent = `Reserva creada correctamente (aula ${reserva.aula}, franja ${reserva.franja}).`;
mensaje.className = 'ok';
form.reset();
// Opcional: refrescar la tabla del día
} else if (resp.status === 409) {
mensaje.textContent = 'Esa aula ya está reservada en la franja elegida. Prueba otra franja.';
mensaje.className = 'error';
} else if (resp.status === 422) {
const err = await resp.json();
mensaje.textContent = 'Datos inválidos: ' + (err.errores ? err.errores.join(', ') : 'revisa el formulario.');
mensaje.className = 'error';
} else {
mensaje.textContent = `Error inesperado del servidor (HTTP ${resp.status}).`;
mensaje.className = 'error';
}
} catch (red) {
// 4) Error de red / conexión
mensaje.textContent = 'No se pudo conectar con el servidor. Inténtalo de nuevo.';
mensaje.className = 'error';
}
});
Justificación: el Content-Type: application/json informa al servidor del formato del cuerpo; JSON.stringify serializa el objeto. La distinción por status (en vez de comprobar solo resp.ok) permite mensajes específicos: 201 (éxito), 409 (conflicto de negocio), 422 (validación). El role="status"/aria-live ya definido hace que esos mensajes sean también accesibles.
Cuestión 3 — Servidor (PHP, REST)
Endpoint POST /api/reservas. Pasos: leer el cuerpo crudo, decodificarlo, validar (→ 422), comprobar disponibilidad con consulta preparada (→ 409) e insertar con consulta preparada (→ 201). Siempre se fija la cabecera JSON y el código HTTP correcto.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 452" font-family="ui-sans-serif, system-ui, Arial, sans-serif">
<text x="380" y="24" text-anchor="middle" font-size="12" font-weight="bold" fill="#2563eb">POST /api/reservas — flujo del servidor y códigos de estado</text>
<!-- Entrada -->
<rect x="240" y="40" width="280" height="46" rx="9" fill="#eff6ff" stroke="#2563eb" stroke-width="1.6"/>
<text x="380" y="60" text-anchor="middle" font-size="12" font-weight="bold" fill="#0f172a">Recibe cuerpo JSON</text>
<text x="380" y="77" text-anchor="middle" font-size="10" fill="#2563eb">POST /api/reservas</text>
<line x1="380" y1="86" x2="380" y2="106" stroke="#334155" stroke-width="1.8"/>
<polygon points="380,106 375,94 385,94" fill="#334155"/>
<!-- Decisión 1: válido -->
<polygon points="380,106 492,152 380,198 268,152" fill="#f8fafc" stroke="#334155" stroke-width="1.5"/>
<text x="380" y="156" text-anchor="middle" font-size="12" font-weight="bold" fill="#0f172a">¿JSON válido?</text>
<text x="392" y="214" font-size="10" fill="#64748b">sí</text>
<text x="520" y="146" font-size="10" fill="#64748b">no</text>
<!-- rama no -> 422 -->
<line x1="492" y1="152" x2="558" y2="152" stroke="#334155" stroke-width="1.6"/>
<polygon points="558,152 546,147 546,157" fill="#334155"/>
<rect x="560" y="128" width="192" height="48" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
<text x="576" y="159" font-size="18" font-weight="bold" fill="#dc2626">422</text>
<text x="614" y="148" font-size="10" font-weight="bold" fill="#0f172a">Unprocessable</text>
<text x="614" y="164" font-size="10" fill="#dc2626">datos inválidos</text>
<!-- baja a decisión 2 -->
<line x1="380" y1="198" x2="380" y2="220" stroke="#334155" stroke-width="1.8"/>
<polygon points="380,220 375,208 385,208" fill="#334155"/>
<!-- Decisión 2: aula libre -->
<polygon points="380,220 492,266 380,312 268,266" fill="#f8fafc" stroke="#334155" stroke-width="1.5"/>
<text x="380" y="270" text-anchor="middle" font-size="12" font-weight="bold" fill="#0f172a">¿Aula libre?</text>
<text x="392" y="328" font-size="10" fill="#64748b">sí</text>
<text x="520" y="260" font-size="10" fill="#64748b">no</text>
<!-- rama no -> 409 -->
<line x1="492" y1="266" x2="558" y2="266" stroke="#334155" stroke-width="1.6"/>
<polygon points="558,266 546,261 546,271" fill="#334155"/>
<rect x="560" y="242" width="192" height="48" rx="8" fill="#fffbeb" stroke="#d97706" stroke-width="1.5"/>
<text x="576" y="273" font-size="18" font-weight="bold" fill="#d97706">409</text>
<text x="614" y="262" font-size="10" font-weight="bold" fill="#0f172a">Conflict</text>
<text x="614" y="278" font-size="10" fill="#d97706">aula ocupada</text>
<!-- baja a INSERT -->
<line x1="380" y1="312" x2="380" y2="332" stroke="#334155" stroke-width="1.8"/>
<polygon points="380,332 375,320 385,320" fill="#334155"/>
<rect x="290" y="332" width="180" height="42" rx="8" fill="#eef2ff" stroke="#6366f1" stroke-width="1.6"/>
<text x="380" y="358" text-anchor="middle" font-size="12" font-weight="bold" fill="#0f172a">INSERT reserva</text>
<!-- baja a 201 -->
<line x1="380" y1="374" x2="380" y2="394" stroke="#334155" stroke-width="1.8"/>
<polygon points="380,394 375,382 385,382" fill="#334155"/>
<rect x="280" y="394" width="200" height="48" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="1.6"/>
<text x="304" y="425" font-size="18" font-weight="bold" fill="#059669">201</text>
<text x="344" y="414" font-size="10" font-weight="bold" fill="#0f172a">Created</text>
<text x="344" y="430" font-size="10" fill="#059669">reserva registrada</text>
</svg>
Figura 1. Flujo de la petición y códigos de estado.
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
// 0) Solo se admite POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); // Method Not Allowed
header('Allow: POST');
echo json_encode(['error' => 'Método no permitido']);
exit;
}
// 1) Leer y decodificar el cuerpo JSON
$raw = file_get_contents('php://input');
$datos = json_decode($raw, true);
// 2) Validación → 422 Unprocessable Entity
$errores = [];
if (!is_array($datos)) {
$errores[] = 'Cuerpo JSON inválido';
} else {
if (empty($datos['aula'])) $errores[] = 'aula obligatoria';
if (empty($datos['fecha']) ||
!preg_match('/^\d{4}-\d{2}-\d{2}$/', $datos['fecha'])) $errores[] = 'fecha inválida (YYYY-MM-DD)';
if (empty($datos['franja']) || !ctype_digit((string)$datos['franja']))
$errores[] = 'franja inválida';
}
if ($errores) {
http_response_code(422);
echo json_encode(['errores' => $errores]);
exit;
}
$aula = $datos['aula'];
$fecha = $datos['fecha'];
$franja = (int) $datos['franja'];
// 3) Conexión con manejo de errores
$conn = new mysqli('localhost', 'usuario', 'secreto', 'reservas_db');
$conn->set_charset('utf8mb4');
if ($conn->connect_errno) {
http_response_code(500);
echo json_encode(['error' => 'Error de conexión']);
exit;
}
// 4) ¿Aula libre en esa franja? — consulta PREPARADA → 409 Conflict
$check = $conn->prepare(
'SELECT 1 FROM reservas WHERE aula = ? AND fecha = ? AND franja = ? LIMIT 1'
);
$check->bind_param('ssi', $aula, $fecha, $franja);
$check->execute();
$check->store_result();
if ($check->num_rows > 0) {
http_response_code(409); // Conflict
echo json_encode(['error' => 'El aula ya está reservada en esa franja']);
$check->close();
$conn->close();
exit;
}
$check->close();
// 5) Insertar — consulta PREPARADA → 201 Created
$ins = $conn->prepare(
'INSERT INTO reservas (aula, fecha, franja) VALUES (?, ?, ?)'
);
$ins->bind_param('ssi', $aula, $fecha, $franja);
if ($ins->execute()) {
http_response_code(201); // Created
echo json_encode([
'id' => $conn->insert_id,
'aula' => $aula,
'fecha' => $fecha,
'franja' => $franja,
]);
} else {
http_response_code(500);
echo json_encode(['error' => 'No se pudo crear la reserva']);
}
$ins->close();
$conn->close();
Justificación técnica:
- Consultas preparadas (
prepare/bind_param): los valores viajan separados de la sentencia SQL, lo que impide la inyección SQL (OWASP A03:2021). Es el punto más valorado.
- Comprobación de disponibilidad antes de insertar evita reservas solapadas; en producción se reforzaría con un índice
UNIQUE(aula, fecha, franja) que garantiza la integridad incluso ante concurrencia (condición de carrera), devolviendo el error de clave duplicada que se traduciría también en 409.
- Códigos HTTP semánticos: 201 (recurso creado, idealmente con cabecera
Location), 409 (conflicto con el estado actual), 422 (entidad bien formada pero semánticamente inválida), 405 (método no admitido), 500 (error de servidor).
header('Content-Type: application/json') y json_encode garantizan que el cliente reciba siempre JSON; utf8mb4 evita problemas con tildes y la ñ.
Cuestión 4 — Buscar y corregir errores
El fragmento original concentra varios fallos graves. Se identifican cinco:
| # |
Error |
Riesgo |
| 1 |
Inyección SQL: $aula y $fecha se concatenan directamente en la cadena SQL. |
Un atacante puede inyectar SQL y leer o alterar datos que jamás deberían ser accesibles (p. ej. ' OR '1'='1 o un UNION SELECT); el clásico '); DROP TABLE reservas;-- ilustra el vector destructivo, aunque borrar la tabla exigiría consultas apiladas (mysqli_multi_query). Es la vulnerabilidad más crítica (OWASP A03). |
| 2 |
Método HTTP incorrecto: se leen los datos por $_GET, es decir, un alta que crea estado se está haciendo por GET. |
GET es seguro e idempotente: la URL queda en historial, logs, caché y puede ser prefetcheada o repetida, creando reservas duplicadas no deseadas. Un alta debe ir por POST. |
| 3 |
Código de estado incorrecto: devuelve 200 OK para una creación; además llama a http_response_code() después del echo. |
Rompe el contrato REST (debería ser 201 Created) y, al fijar el código tras enviar cuerpo, puede no aplicarse (cabeceras ya enviadas). |
| 4 |
Ausencia de validación de los datos de entrada. |
Datos malformados o vacíos llegan a la BD; debería devolver 422. |
| 5 |
Sin cabecera Content-Type JSON ni control de errores de la consulta. |
Se responde texto plano «Reserva creada» aunque la consulta falle: el cliente no puede distinguir éxito de error. |
Versión corregida del fragmento:
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
// (2) Solo POST, no GET
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
echo json_encode(['error' => 'Use POST']);
exit;
}
// Datos del cuerpo, no de la URL
$datos = json_decode(file_get_contents('php://input'), true);
// (4) Validación → 422
if (!is_array($datos) || empty($datos['aula']) ||
empty($datos['fecha']) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $datos['fecha'])) {
http_response_code(422);
echo json_encode(['error' => 'Datos inválidos']);
exit;
}
// (1) Consulta PREPARADA: sin inyección SQL
$stmt = $conn->prepare('INSERT INTO reservas (aula, fecha) VALUES (?, ?)');
$stmt->bind_param('ss', $datos['aula'], $datos['fecha']);
// (5) Control de errores
if ($stmt->execute()) {
http_response_code(201); // (3) 201 Created, ANTES del cuerpo
echo json_encode(['id' => $conn->insert_id, 'mensaje' => 'Reserva creada']);
} else {
http_response_code(500);
echo json_encode(['error' => 'No se pudo crear la reserva']);
}
$stmt->close();
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 244" font-family="ui-sans-serif, system-ui, Arial, sans-serif">
<text x="380" y="14" text-anchor="middle" font-size="11" font-weight="bold" fill="#2563eb">Del fragmento vulnerable a la versión segura</text>
<!-- Fila a: inyección SQL -->
<rect x="20" y="24" width="300" height="56" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="1.4"/>
<text x="34" y="46" font-size="11" font-weight="bold" fill="#0f172a">SQL por concatenación de $aula</text>
<text x="34" y="66" font-size="10" fill="#dc2626">inyección SQL (OWASP A03)</text>
<line x1="330" y1="52" x2="430" y2="52" stroke="#334155" stroke-width="1.6"/>
<polygon points="430,52 418,47 418,57" fill="#334155"/>
<rect x="440" y="24" width="300" height="56" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="1.4"/>
<text x="454" y="46" font-size="11" font-weight="bold" fill="#0f172a">consulta preparada (bind_param)</text>
<text x="454" y="66" font-size="10" fill="#059669">valores separados de la sentencia</text>
<!-- Fila b: método GET -->
<rect x="20" y="94" width="300" height="56" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="1.4"/>
<text x="34" y="116" font-size="11" font-weight="bold" fill="#0f172a">datos por $_GET (método GET)</text>
<text x="34" y="136" font-size="10" fill="#dc2626">un alta no debe ir por GET</text>
<line x1="330" y1="122" x2="430" y2="122" stroke="#334155" stroke-width="1.6"/>
<polygon points="430,122 418,117 418,127" fill="#334155"/>
<rect x="440" y="94" width="300" height="56" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="1.4"/>
<text x="454" y="116" font-size="11" font-weight="bold" fill="#0f172a">cuerpo JSON por POST</text>
<text x="454" y="136" font-size="10" fill="#059669">php://input + json_decode</text>
<!-- Fila c: código HTTP -->
<rect x="20" y="164" width="300" height="56" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="1.4"/>
<text x="34" y="186" font-size="11" font-weight="bold" fill="#0f172a">http_response_code(200) tras echo</text>
<text x="34" y="206" font-size="10" fill="#dc2626">cabeceras ya enviadas</text>
<line x1="330" y1="192" x2="430" y2="192" stroke="#334155" stroke-width="1.6"/>
<polygon points="430,192 418,187 418,197" fill="#334155"/>
<rect x="440" y="164" width="300" height="56" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="1.4"/>
<text x="454" y="186" font-size="11" font-weight="bold" fill="#0f172a">201 Created ANTES del cuerpo</text>
<text x="454" y="206" font-size="10" fill="#059669">contrato REST correcto</text>
</svg>
Figura 2. Los tres fallos del fragmento y su corrección.
Cuestión 5 — GET vs POST e idempotencia
GET solicita un recurso: los parámetros viajan en la URL (query string), es seguro (safe: no modifica el estado del servidor), idempotente, cacheable y puede quedar en historial, marcadores y logs. POST envía datos en el cuerpo de la petición para que el servidor cree o procese un recurso; no es seguro (modifica estado), no es idempotente y, por defecto, no es cacheable.
El alta de una reserva no debe hacerse por GET porque:
- Crea estado (inserta una fila): viola el principio de que GET debe ser seguro.
- Al ser GET cacheable y prefetcheable, el navegador o un proxy podría repetir la petición y duplicar la reserva.
- Los datos quedarían expuestos en la URL (historial, logs del servidor, cabecera
Referer), un problema de privacidad.
Relación con la idempotencia: un método es idempotente cuando ejecutarlo N veces produce el mismo efecto sobre el estado del servidor que ejecutarlo una sola vez. Si f es el método y s el estado del servidor:
\text{estado}\big(f^{N}(s)\big) = \text{estado}\big(f(s)\big) \quad \forall\, N \ge 1
GET, PUT y DELETE son idempotentes; POST no lo es, porque cada invocación crea un recurso nuevo (N reservas: \text{estado}(f^{N}(s)) \neq \text{estado}(f(s))). Por eso el alta encaja en POST: refleja honestamente que no es repetible sin efectos. Si se quisiera una operación idempotente (p. ej. «reserva con id conocido»), se usaría PUT sobre /api/reservas/{id}, que crea o reemplaza el recurso de forma idempotente. La distinción seguro (no altera estado) frente a idempotente (efecto invariante ante repetición) es clave: GET es ambas cosas; POST no es ninguna.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 202" font-family="ui-sans-serif, system-ui, Arial, sans-serif">
<!-- Fondo tabla -->
<rect x="20" y="16" width="680" height="170" rx="6" fill="#f8fafc" stroke="#334155" stroke-width="1.4"/>
<!-- Cabecera -->
<rect x="20" y="16" width="680" height="34" rx="6" fill="#e2e8f0" stroke="#334155" stroke-width="1.4"/>
<rect x="20" y="34" width="680" height="16" fill="#e2e8f0"/>
<text x="34" y="38" font-size="12" font-weight="bold" fill="#0f172a">Propiedad</text>
<text x="430" y="38" text-anchor="middle" font-size="12" font-weight="bold" fill="#0f172a">GET</text>
<text x="610" y="38" text-anchor="middle" font-size="12" font-weight="bold" fill="#0f172a">POST</text>
<!-- Líneas de columna -->
<line x1="340" y1="16" x2="340" y2="186" stroke="#334155" stroke-width="1"/>
<line x1="520" y1="16" x2="520" y2="186" stroke="#334155" stroke-width="1"/>
<!-- Líneas de fila -->
<line x1="20" y1="84" x2="700" y2="84" stroke="#cbd5e1" stroke-width="1"/>
<line x1="20" y1="118" x2="700" y2="118" stroke="#cbd5e1" stroke-width="1"/>
<line x1="20" y1="152" x2="700" y2="152" stroke="#cbd5e1" stroke-width="1"/>
<!-- Fila 1: Seguro -->
<text x="34" y="71" font-size="11" fill="#0f172a">Seguro (no cambia estado)</text>
<text x="430" y="73" text-anchor="middle" font-size="15" font-weight="bold" fill="#059669">✓</text>
<text x="610" y="73" text-anchor="middle" font-size="15" font-weight="bold" fill="#dc2626">✗</text>
<!-- Fila 2: Idempotente -->
<text x="34" y="105" font-size="11" fill="#0f172a">Idempotente</text>
<text x="430" y="107" text-anchor="middle" font-size="15" font-weight="bold" fill="#059669">✓</text>
<text x="610" y="107" text-anchor="middle" font-size="15" font-weight="bold" fill="#dc2626">✗</text>
<!-- Fila 3: Cacheable -->
<text x="34" y="139" font-size="11" fill="#0f172a">Cacheable</text>
<text x="430" y="141" text-anchor="middle" font-size="15" font-weight="bold" fill="#059669">✓</text>
<text x="610" y="141" text-anchor="middle" font-size="15" font-weight="bold" fill="#dc2626">✗</text>
<!-- Fila 4: Datos en -->
<text x="34" y="173" font-size="11" fill="#0f172a">Datos en…</text>
<text x="430" y="173" text-anchor="middle" font-size="10.5" fill="#0f172a">URL (query string)</text>
<text x="610" y="173" text-anchor="middle" font-size="10.5" fill="#0f172a">cuerpo (body)</text>
</svg>
Figura 3. GET frente a POST e idempotencia.