12. Notificaciones push

Versión para imprimir.

1. Introduccion

2. Diagrama entidad relación

Diagrama entidad relación

3. Diagrama de despliegue

Diagrama de despliegue

4. Hazlo funcionar

  1. Este ejercicio usa la librería WebPush para PHP. Puedes profundizar en este tema en la URL https://github.com/web-push-libs/web-push-php

  2. Prueba el ejemplo en https://notipush.rf.gd/.

  3. Descarga el archivo /src/notipush.zip y descompáctalo.

  4. Crea tu proyecto en GitHub:

    1. Crea una cuenta de email, por ejemplo, pepito@google.com

    2. Crea una cuenta de GitHub usando el email anterior y selecciona el nombre de usuario unsando la parte inicial del correo electrónico, por ejemplo pepito.

    3. Crea un repositorio nuevo. En la página principal de GitHub cliquea 📘 New.

    4. En la página Create a new repository introduce los siguientes datos:

      • Proporciona el nombre de tu repositorio debajo de donde dice Repository name *.

      • Mantén la selección Public para que otros usuarios puedan ver tu proyecto.

      • Verifica la casilla Add a README file. En este archivo se muestra información sobre tu proyecto.

      • Cliquea License: None. y selecciona la licencia que consideres más adecuada para tu proyecto.

      • Cliquea Create repository.

  5. Importa el proyecto en GitHub:

    1. En la página principal de tu proyecto en GitHub, en la pestaña < > Code, cliquea < > Code y en la sección Branches y copia la dirección que está en HTTPS, debajo de Clone.

    2. En Visual Studio Code, usa el botón de la izquierda para Source Control.

      Imagen de Source Control
    3. Cliquea el botón Clone Repository.

    4. Pega la url que copiaste anteriormente hasta arriba, donde dice algo como Provide repository URL y presiona la teclea Intro.

    5. Selecciona la carpeta donde se guardará la carpeta del proyecto.

    6. Abre la carpeta del proyecto importado.

    7. Añade el contenido de la carpeta descompactada que contiene el código del ejemplo.

  6. Edita los archivos que desees.

  7. El ejercicio usa una llave pública en el servidor y la repite el código de JavaScript en el navegador. También se requiere la correspondiente llave privada que se almacena solo en el servidor.

  8. Las llaves se pueden generar con el enlace Genera llaves del ejemplo. Para copiar los valores despliega el código fuente de la página, porque en el navegador algunos caracteres pueden tener interpretación diferente.

  9. El proyecto ya contiene la carpeta vendor y el archivo composer.lock, pero es posible crearlos con estos pasos:

    1. Instalar composer. Para Windows, usa el instalador de https://getcomposer.org/download/.

    2. Abre una terminal y ejecuta el comando
      composer update

  10. Haz clic derecho en index.html, selecciona PHP Server: serve project y se abre el navegador para que puedas probar localmente el ejemplo.

  11. Para depurar paso a paso haz lo siguiente:

    1. En el navegador, haz clic derecho en la página que deseas depurar y selecciona inspeccionar.

    2. Recarga la página, de preferencia haciendo clic derecho en el ícono de volver a cargar la página Ïmagen del ícono de recarga y seleccionando vaciar caché y volver a cargar de manera forzada (o algo parecido). Si no aparece un menú emergente, simplemente cliquea volver a cargar la página Ïmagen del ícono de recarga. Revisa que no aparezca ningún error ni en la pestañas Consola, ni en Red.

    3. Selecciona la pestaña Fuentes (o Sources si tu navegador está en Inglés).

    4. Selecciona el archivo donde vas a empezar a depurar.

    5. Haz clic en el número de la línea donde vas a empezar a depurar.

    6. En Visual Studio Code, abre el archivo de PHP donde vas a empezar a depurar.

    7. Haz clic en Run and Debug .

    8. Si no está configurada la depuración, haz clic en create a launch json file.

    9. Haz clic en la flechita RUN AND DEBUG, al lado de la cual debe decir Listen for Xdebug .

    10. Aparece un cuadro con los controles de depuración.

    11. Selecciona otra vez el archivo de PHP y haz clic en el número de la línea donde vas a empezar a depurar.

    12. Regresa al navegador, recarga la página de manera normal y empieza a usarla.

    13. Si se ejecuta alguna de las líneas de código seleccionadas, aparece resaltada en la pestaña de fuentes. Usa los controles de depuración para avanzar, como se muestra en este video.

  12. Sube el proyecto al hosting que elijas sin incluir el archivo .htaccess. En algunos casos puedes usar filezilla (https://filezilla-project.org/)

  13. En algunos host como InfinityFree, tienes que configurar el certificado SSL.

  14. En algunos host, como InfinityFree, debes subir el archivo .htaccess cuando el certificado SSL se ha creado e instalado. Sirve para forzar el uso de https, para eliminar el control de cache, pues ahora lo lleva el service worker y para asignar el mime type correcto para el archivo de manifest.

  15. Abre un navegador y prueba el proyecto en tu hosting.

  16. En el hosting InfinityFree, la primera ves que corres la página, puede marcar un mensaje de error, pero al recargar funciona correctamente. Puedes evitar este problema usando un dominio propio.

  17. Para subir el código a GitHub, en la sección de SOURCE CONTROL, en Message introduce un mensaje sobre los cambios que hiciste, por ejemplo index.html corregido, selecciona v y luego Commit & Push.

    Imagen de Commit & Push

5. Archivos

Haz clic en los triángulos para expandir las carpetas

6. index.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Notificaciones Push</title>
10
11</head>
12
13<body onload="preparaVista()">
14
15 <h1>Notificaciones Push</h1>
16
17 <menu style="display: flex; list-style: none; flex-wrap: wrap; gap: 0.5rem;">
18 <li>
19 <button id="btnSuscribe" type="button" hidden onclick="suscribe()">
20 Suscríbete
21 </button>
22 </li>
23 <li>
24 <button id="btnCancela" type="button" hidden onclick="cancela()">
25 Cancela suscripción
26 </button>
27 </li>
28 <li>
29 <button id="btnNotifica" type="button" hidden
30 onclick="notificaDesdeElServidor()">
31 Notifica
32 </button>
33 </li>
34 <li>
35 <a href="srv/genera-llaves.php" target="_blank">Genera llaves</a>
36 </li>
37 </menu>
38
39 <p>
40 <output id="outEstado">
41 <progress max="100">Cargando…</progress>
42 </output>
43 </p>
44
45 <fieldset>
46 <legend>Reporte de envío a endpoints</legend>
47
48 <dl id="reporte"></dl>
49
50 </fieldset>
51
52 <script type="module">
53
54 import { exportaAHtml } from "./lib/js/exportaAHtml.js"
55 import {
56 activaNotificacionesPush
57 } from "./lib/js/activaNotificacionesPush.js"
58 import { getSuscripcionPush } from "./lib/js/getSuscripcionPush.js"
59 import { suscribeAPush } from "./lib/js/suscribeAPush.js"
60 import { cancelaSuscripcionPush } from "./lib/js/cancelaSuscripcionPush.js"
61 import { consumeJson } from "./lib/js/consumeJson.js"
62 import { enviaJson } from "./lib/js/enviaJson.js"
63 import { muestraError } from "./lib/js/muestraError.js"
64 import { muestraObjeto } from "./lib/js/muestraObjeto.js"
65 import { urlBase64ToUint8Array } from "./lib/js/urlBase64ToUint8Array.js"
66 import {
67 calculaDtoParaSuscripcion
68 } from "./lib/js/calculaDtoParaSuscripcion.js"
69
70 const applicationServerKey = urlBase64ToUint8Array(
71 "BMBlr6YznhYMX3NgcWIDRxZXs0sh7tCv7_YCsWcww0ZCv9WGg-tRCXfMEHTiBPCksSqeve1twlbmVAZFv7GSuj0")
72 /** @enum {string} */
73 const Estado = {
74 CALCULANDO: "Calculando…",
75 SUSCRITO: "Suscrito",
76 DESUSCRITO: "Sin suscripción",
77 INCOMPATIBLE: "Incompatible"
78 }
79
80 async function preparaVista() {
81 try {
82 await activaNotificacionesPush("sw.js")
83 const suscripcion = await getSuscripcionPush()
84 if (suscripcion === null) {
85 muestraEstado(Estado.DESUSCRITO)
86 } else {
87 // Modifica la suscripción en el servidor,
88 const dto = calculaDtoParaSuscripcion(suscripcion)
89 await enviaJson("srv/suscripcion-modifica.php", dto)
90 muestraEstado(Estado.SUSCRITO)
91 }
92 } catch (error) {
93 muestraEstado(Estado.INCOMPATIBLE)
94 muestraError(error)
95 }
96 }
97 exportaAHtml(preparaVista)
98
99 async function notificaDesdeElServidor() {
100 try {
101 reporte.innerHTML =
102 /* html */ `<progress max="100">Cargando…</progress>`
103 const render = await consumeJson("srv/notifica.php")
104 await muestraObjeto(document, render.body)
105 } catch (error) {
106 muestraError(error)
107 }
108 }
109 exportaAHtml(notificaDesdeElServidor)
110
111 async function suscribe() {
112 try {
113 muestraEstado(Estado.CALCULANDO)
114 const suscripcion = await suscribeAPush(applicationServerKey)
115 // Agrega la suscripción al servidor,
116 const dto = calculaDtoParaSuscripcion(suscripcion)
117 await enviaJson("srv/suscripcion-modifica.php", dto)
118 muestraEstado(Estado.SUSCRITO)
119 } catch (error) {
120 muestraError(error)
121 }
122 }
123 exportaAHtml(suscribe)
124
125 async function cancela() {
126 try {
127 muestraEstado(Estado.CALCULANDO)
128 const suscripcion = await cancelaSuscripcionPush()
129 if (suscripcion !== null) {
130 // Elimina la suscripción en el servidor,
131 const dto = calculaDtoParaSuscripcion(suscripcion)
132 await enviaJson("srv/suscripcion-elimina.php", dto)
133 muestraEstado(Estado.DESUSCRITO)
134 }
135 } catch (error) {
136 muestraError(error)
137 }
138 }
139 exportaAHtml(cancela)
140
141 /** @param {Estado} estado */
142 function muestraEstado(estado) {
143 outEstado.value = estado
144 if (estado === Estado.INCOMPATIBLE || estado === Estado.CALCULANDO) {
145 btnSuscribe.hidden = true
146 btnCancela.hidden = true
147 btnNotifica.hidden = true
148 } else if (estado === Estado.SUSCRITO) {
149 btnSuscribe.hidden = true
150 btnCancela.hidden = false
151 btnNotifica.hidden = false
152 } else if (estado === Estado.DESUSCRITO) {
153 btnSuscribe.hidden = false
154 btnCancela.hidden = true
155 btnNotifica.hidden = true
156 }
157 }
158
159 </script>
160
161</body>
162
163</html>

7. sw.js

1const URL_SERVIDOR = "https://notipush.rf.gd"
2
3if (self instanceof ServiceWorkerGlobalScope) {
4
5 // El siguiente código se activa cuando llega una notificación push.
6 self.addEventListener("push", (/** @type {PushEvent} */ event) => {
7 const notificacion = event.data
8 /* Si el navegador tiene permitido mostrar notificaciones push,
9 * nuestra la que se ha recibido. */
10 if (notificacion !== null && self.Notification.permission === 'granted') {
11 event.waitUntil(muestraNotificacion(notificacion))
12 }
13 })
14
15 self.addEventListener("notificationclick",
16 (/** @type {NotificationEvent} */ event) => {
17 event.notification.close()
18 event.waitUntil(muestraVentana())
19 })
20}
21
22/**
23 * @param {PushMessageData} notificacion
24 */
25async function muestraNotificacion(notificacion) {
26 if (self instanceof ServiceWorkerGlobalScope) {
27 const mensaje = notificacion.text()
28 await self.registration.showNotification(mensaje)
29 }
30}
31
32async function muestraVentana() {
33 if (self instanceof ServiceWorkerGlobalScope) {
34 const clientes = await self.clients.matchAll({ type: "window" })
35 for (const cliente of clientes) {
36 if (cliente.url.startsWith(URL_SERVIDOR)) {
37 return cliente.focus()
38 }
39 }
40 return self.clients.openWindow("/")
41 }
42}

8. Carpeta « srv »

Versión para imprimir.

A. srv / Bd.php

1<?php
2
3class Bd
4{
5
6 private static ?PDO $pdo = null;
7
8 static function pdo(): PDO
9 {
10 if (self::$pdo === null) {
11
12 self::$pdo = new PDO(
13 // cadena de conexión
14 "sqlite:notificacionespush.db",
15 // usuario
16 null,
17 // contraseña
18 null,
19 // Opciones: pdos no persistentes y lanza excepciones.
20 [PDO::ATTR_PERSISTENT => false, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
21 );
22
23 self::$pdo->exec(
24 'CREATE TABLE IF NOT EXISTS SUSCRIPCION (
25 SUS_ENDPOINT TEXT NOT NULL,
26 SUS_PUB_KEY TEXT NOT NULL,
27 SUS_AUT_TOK TEXT NOT NULL,
28 SUS_CONT_ENCOD TEXT NOT NULL,
29 CONSTRAINT SUS_PK
30 PRIMARY KEY(SUS_ENDPOINT),
31 CONSTRAINT SUS_ENDPNT_NV
32 CHECK(LENGTH(SUS_ENDPOINT) > 0)
33 )'
34 );
35 }
36
37 return self::$pdo;
38 }
39}
40

B. srv / genera-llaves.php

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Llaves VAPID</title>
10
11</head>
12
13<body>
14
15 <h1>Llaves VAPID</h1>
16
17 <pre>
18 <?php
19
20 require __DIR__ . "/../vendor/autoload.php";
21
22 use Minishlink\WebPush\VAPID;
23
24 var_dump(VAPID::createVapidKeys());
25
26 ?>
27 </pre>
28
29</body>
30
31</html>

C. srv / notifica.php

1<?php
2
3require_once __DIR__ . "/../vendor/autoload.php";
4require_once __DIR__ . "/../lib/php/ejecutaServicio.php";
5require_once __DIR__ . "/../lib/php/select.php";
6require_once __DIR__ . "/../lib/php/devuelveJson.php";
7require_once __DIR__ . "/Bd.php";
8require_once __DIR__ . "/TABLA_SUSCRIPCION.php";
9require_once __DIR__ . "/Suscripcion.php";
10require_once __DIR__ . "/suscripcionElimina.php";
11
12use Minishlink\WebPush\WebPush;
13
14const AUTH = [
15 "VAPID" => [
16 "subject" => "https://notificacionesphp.gilbertopachec2.repl.co/",
17 "publicKey" => "BMBlr6YznhYMX3NgcWIDRxZXs0sh7tCv7_YCsWcww0ZCv9WGg-tRCXfMEHTiBPCksSqeve1twlbmVAZFv7GSuj0",
18 "privateKey" => "vplfkITvu0cwHqzK9Kj-DYStbCH_9AhGx9LqMyaeI6w"
19 ]
20];
21
22ejecutaServicio(function () {
23
24 $webPush = new WebPush(AUTH);
25 $mensaje = "Hola! 👋";
26
27 // Envia el mensaje a todas las suscripciones.
28
29 $pdo = Bd::pdo();
30
31 $suscripciones = select(
32 pdo: $pdo,
33 from: SUSCRIPCION,
34 mode: PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE,
35 opcional: Suscripcion::class
36 );
37
38 foreach ($suscripciones as $suscripcion) {
39 $webPush->queueNotification($suscripcion, $mensaje);
40 }
41 $reportes = $webPush->flush();
42
43 // Genera el reporte de envio a cada suscripcion.
44 $reporteDeEnvios = "";
45 foreach ($reportes as $reporte) {
46 $endpoint = $reporte->getRequest()->getUri();
47 $htmlEndpoint = htmlentities($endpoint);
48 if ($reporte->isSuccess()) {
49 // Reporte de éxito.
50 $reporteDeEnvios .= "<dt>$htmlEndpoint</dt><dd>Éxito</dd>";
51 } else {
52 if ($reporte->isSubscriptionExpired()) {
53 suscripcionElimina($pdo, $endpoint);
54 }
55 // Reporte de fallo.
56 $explicacion = htmlentities($reporte->getReason());
57 $reporteDeEnvios .= "<dt>$endpoint</dt><dd>Fallo: $explicacion</dd>";
58 }
59 }
60
61 devuelveJson(["reporte" => ["innerHTML" => $reporteDeEnvios]]);
62});
63

D. srv / suscripcion-elimina.php

1<?php
2
3require_once __DIR__ . "/../lib/php/ejecutaServicio.php";
4require_once __DIR__ . "/../lib/php/recuperaJson.php";
5require_once __DIR__ . "/../lib/php/devuelveNoContent.php";
6require_once __DIR__ . "/Bd.php";
7require_once __DIR__ . "/suscripcionRecupera.php";
8require_once __DIR__ . "/suscripcionElimina.php";
9
10ejecutaServicio(function () {
11
12 $modelo = suscripcionRecupera();
13 suscripcionElimina(Bd::pdo(), $modelo[SUS_ENDPOINT]);
14 devuelveNoContent();
15});
16

E. srv / suscripcion-modifica.php

1<?php
2
3require_once __DIR__ . "/../lib/php/ejecutaServicio.php";
4require_once __DIR__ . "/../lib/php/selectFirst.php";
5require_once __DIR__ . "/../lib/php/insert.php";
6require_once __DIR__ . "/../lib/php/update.php";
7require_once __DIR__ . "/../lib/php/devuelveCreated.php";
8require_once __DIR__ . "/../lib/php/devuelveJson.php";
9require_once __DIR__ . "/Bd.php";
10require_once __DIR__ . "/Suscripcion.php";
11require_once __DIR__ . "/suscripcionRecupera.php";
12
13ejecutaServicio(function () {
14 $modelo = suscripcionRecupera();
15 $pdo = Bd::pdo();
16 if (
17 selectFirst($pdo, SUSCRIPCION, [SUS_ENDPOINT => $modelo[SUS_ENDPOINT]])
18 === false
19 ) {
20 insert(pdo: $pdo, into: SUSCRIPCION, values: $modelo);
21 devuelveCreated("", $modelo);
22 } else {
23 update(
24 pdo: $pdo,
25 table: SUSCRIPCION,
26 set: $modelo,
27 where: [SUS_ENDPOINT => $modelo[SUS_ENDPOINT]]
28 );
29 devuelveJson($modelo);
30 }
31});
32

F. srv / Suscripcion.php

1<?php
2
3require_once __DIR__ . "/../vendor/autoload.php";
4
5use Minishlink\WebPush\SubscriptionInterface;
6
7class Suscripcion implements SubscriptionInterface
8{
9
10 public string $SUS_ENDPOINT;
11 public string $SUS_PUB_KEY;
12 public string $SUS_AUT_TOK;
13 public string $SUS_CONT_ENCOD;
14
15 public function __construct(
16 string $SUS_ENDPOINT = "",
17 string $SUS_PUB_KEY = "",
18 string $SUS_AUT_TOK = "",
19 string $SUS_CONT_ENCOD = ""
20 ) {
21 $this->SUS_ENDPOINT = $SUS_ENDPOINT;
22 $this->SUS_PUB_KEY = $SUS_PUB_KEY;
23 $this->SUS_AUT_TOK = $SUS_AUT_TOK;
24 $this->SUS_CONT_ENCOD = $SUS_CONT_ENCOD;
25 }
26
27 public function getEndpoint(): string
28 {
29 return $this->SUS_ENDPOINT;
30 }
31
32 public function getPublicKey(): ?string
33 {
34 return $this->SUS_PUB_KEY;
35 }
36
37 public function getAuthToken(): ?string
38 {
39 return $this->SUS_AUT_TOK;
40 }
41
42 public function getContentEncoding(): ?string
43 {
44 return $this->SUS_CONT_ENCOD;
45 }
46}
47

G. srv / suscripcionElimina.php

1<?php
2
3require_once __DIR__ . "/../lib/php/delete.php";
4require_once __DIR__ . "/TABLA_SUSCRIPCION.php";
5
6function suscripcionElimina(PDO $pdo, string $endpoint)
7{
8 delete(pdo: $pdo, from: SUSCRIPCION, where: [SUS_ENDPOINT => $endpoint]);
9}
10

H. srv / suscripcionRecupera.php

1<?php
2
3require_once __DIR__ . "/../lib/php/BAD_REQUEST.php";
4require_once __DIR__ . "/../lib/php/recuperaJson.php";
5require_once __DIR__ . "/../lib/php/validaJson.php";
6require_once __DIR__ . "/../lib/php/ProblemDetails.php";
7require_once __DIR__ . "/TABLA_SUSCRIPCION.php";
8
9function suscripcionRecupera()
10{
11
12 $objeto = recuperaJson();
13 $objeto = validaJson($objeto);
14
15 if (!isset($objeto->publicKey) || !is_string($objeto->publicKey))
16 throw new ProblemDetails(
17 type: "/error/publickeyincorrecta.html",
18 status: BAD_REQUEST,
19 title: "La publicKey debe ser texto.",
20 );
21
22 if (!isset($objeto->authToken) || !is_string($objeto->authToken))
23 throw new ProblemDetails(
24 type: "/error/authtokenincorrecto.html",
25 status: BAD_REQUEST,
26 title: "El authToken debe ser texto.",
27 );
28
29 if (!isset($objeto->contentEncoding) || !is_string($objeto->contentEncoding))
30 throw new ProblemDetails(
31 type: "/error/contentencodingincorrecta.html",
32 status: BAD_REQUEST,
33 title: "La contentEncoding debe ser texto.",
34 );
35
36 return [
37 SUS_ENDPOINT => $objeto->endpoint,
38 SUS_PUB_KEY => $objeto->publicKey,
39 SUS_AUT_TOK => $objeto->authToken,
40 SUS_CONT_ENCOD => $objeto->contentEncoding
41 ];
42}
43

I. srv / TABLA_SUSCRIPCION.php

1<?php
2
3const SUSCRIPCION = "SUSCRIPCION";
4const SUS_ENDPOINT = "SUS_ENDPOINT";
5const SUS_PUB_KEY = "SUS_PUB_KEY";
6const SUS_AUT_TOK = "SUS_AUT_TOK";
7const SUS_CONT_ENCOD = "SUS_CONT_ENCOD";
8

9. composer.json

1{
2 "require": {
3 "minishlink/web-push": "^9.0.1"
4 }
5}

10. composer.lock

1-- No se muestra el contenido de este archivo --

11. Carpeta « lib »

Versión para imprimir.

A. Carpeta « lib / js »

1. lib / js / activaNotificacionesPush.js

1/**
2 * @param { string | URL } urlDeServiceWorkerQueRecibeNotificaciones
3 */
4export async function activaNotificacionesPush(
5
6 urlDeServiceWorkerQueRecibeNotificaciones) {
7
8 // Valida que el navegador soporte notificaciones push.
9 if (!('PushManager' in window))
10 throw new Error("Este navegador no soporta notificaciones push.")
11
12 // Valida que el navegador soporte notificaciones,
13 if (!("Notification" in window))
14 throw new Error("Este navegador no soporta notificaciones push.")
15
16 // Valida que el navegador soporte service workers,
17 if (!("serviceWorker" in navigator))
18 throw new Error("Este navegador no soporta service workers.")
19
20 // Recupera el permiso para usar notificaciones
21 let permiso = Notification.permission
22 if (permiso === "default") {
23 // Permiso no asignado. Pide al usuario su autorización.
24 permiso = await Notification.requestPermission()
25 }
26
27 // Valida que el usuario haya permitido usar notificaciones..
28 if (permiso === "denied")
29 throw new Error("Notificaciones bloqueadas.")
30
31 const registro = await navigator.serviceWorker.register(
32 urlDeServiceWorkerQueRecibeNotificaciones)
33 console.log(urlDeServiceWorkerQueRecibeNotificaciones, "registrado.")
34 console.log(registro)
35
36 if (!("showNotification" in registro))
37 throw new Error("Este navegador no soporta notificaciones.")
38}

2. lib / js / calculaDtoParaSuscripcion.js

1/**
2 * Devuelve una literal de objeto que se puede usar para enviar
3 * en formato JSON al servidor.
4 * DTO es un acrónimo para Data Transder Object, u
5 * objeto para transferencia de datos.
6 * @param { PushSubscription } suscripcion
7 */
8export function calculaDtoParaSuscripcion(suscripcion) {
9 const key = suscripcion.getKey("p256dh")
10 const token = suscripcion.getKey("auth")
11 const supported = PushManager.supportedContentEncodings
12 const encodings = Array.isArray(supported) && supported.length > 0
13 ? supported
14 : ["aesgcm"]
15 const endpoint = suscripcion.endpoint
16 const publicKey = key === null
17 ? null
18 : btoa(String.fromCharCode.apply(null, new Uint8Array(key)))
19 const authToken = token === null
20 ? null
21 : btoa(String.fromCharCode.apply(null, new Uint8Array(token)))
22 const contentEncoding = encodings[0]
23 return {
24 endpoint,
25 publicKey,
26 authToken,
27 contentEncoding
28 }
29}
30

3. lib / js / cancelaSuscripcionPush.js

1import { getSuscripcionPush } from "./getSuscripcionPush.js"
2
3export async function cancelaSuscripcionPush() {
4 const suscripcion = await getSuscripcionPush()
5 const resultado = suscripcion === null
6 ? false
7 : await suscripcion.unsubscribe()
8 return resultado === true ? suscripcion : null
9}

4. lib / js / consumeJson.js

1import { exportaAHtml } from "./exportaAHtml.js"
2import { ProblemDetails } from "./ProblemDetails.js"
3
4/**
5 * Espera a que la promesa de un fetch termine. Si
6 * hay error, lanza una excepción. Si no hay error,
7 * interpreta la respuesta del servidor como JSON y
8 * la convierte en una literal de objeto.
9 *
10 * @param { string | Promise<Response> } servicio
11 */
12export async function consumeJson(servicio) {
13
14 if (typeof servicio === "string") {
15 servicio = fetch(servicio, {
16 headers: { "Accept": "application/json, application/problem+json" }
17 })
18 } else if (!(servicio instanceof Promise)) {
19 throw new Error("Servicio de tipo incorrecto.")
20 }
21
22 const respuesta = await servicio
23
24 const headers = respuesta.headers
25
26 if (respuesta.ok) {
27 // Aparentemente el servidor tuvo éxito.
28
29 if (respuesta.status === 204) {
30 // No contiene texto de respuesta.
31
32 return { headers, body: {} }
33
34 } else {
35
36 const texto = await respuesta.text()
37
38 try {
39
40 return { headers, body: JSON.parse(texto) }
41
42 } catch (error) {
43
44 // El contenido no es JSON. Probablemente sea texto de un error.
45 throw new ProblemDetails(respuesta.status, headers, texto,
46 "/error/errorinterno.html")
47
48 }
49
50 }
51
52 } else {
53 // Hay un error.
54
55 const texto = await respuesta.text()
56
57 if (texto === "") {
58
59 // No hay texto. Se usa el texto predeterminado.
60 throw new ProblemDetails(respuesta.status, headers, respuesta.statusText)
61
62 } else {
63 // Debiera se un ProblemDetails en JSON.
64
65 try {
66
67 const { title, type, detail } = JSON.parse(texto)
68
69 throw new ProblemDetails(respuesta.status, headers,
70 typeof title === "string" ? title : respuesta.statusText,
71 typeof type === "string" ? type : undefined,
72 typeof detail === "string" ? detail : undefined)
73
74 } catch (error) {
75
76 if (error instanceof ProblemDetails) {
77 // El error si era un ProblemDetails
78
79 throw error
80
81 } else {
82
83 throw new ProblemDetails(respuesta.status, headers, respuesta.statusText,
84 undefined, texto)
85
86 }
87
88 }
89
90 }
91
92 }
93
94}
95
96exportaAHtml(consumeJson)

5. lib / js / enviaJson.js

1import { consumeJson } from "./consumeJson.js"
2import { exportaAHtml } from "./exportaAHtml.js"
3
4/**
5 * @param { string } url
6 * @param { Object } body
7 * @param { "GET" | "POST"| "PUT" | "PATCH" | "DELETE" | "TRACE" | "OPTIONS"
8 * | "CONNECT" | "HEAD" } metodoHttp
9 */
10export async function enviaJson(url, body, metodoHttp = "POST") {
11 return await consumeJson(fetch(url, {
12 method: metodoHttp,
13 headers: {
14 "Content-Type": "application/json",
15 "Accept": "application/json, application/problem+json"
16 },
17 body: JSON.stringify(body)
18 }))
19}
20
21exportaAHtml(enviaJson)

6. lib / js / exportaAHtml.js

1/**
2 * Permite que los eventos de html usen la función.
3 * @param {function} functionInstance
4 */
5export function exportaAHtml(functionInstance) {
6 window[nombreDeFuncionParaHtml(functionInstance)] = functionInstance
7}
8
9/**
10 * @param {function} valor
11 */
12export function nombreDeFuncionParaHtml(valor) {
13 const names = valor.name.split(/\s+/g)
14 return names[names.length - 1]
15}

7. lib / js / getSuscripcionPush.js

1export async function getSuscripcionPush() {
2 // Recupera el service worker registrado.
3 const registro = await navigator.serviceWorker.ready
4 return registro.pushManager.getSubscription()
5}

8. lib / js / muestraError.js

1import { exportaAHtml } from "./exportaAHtml.js"
2import { ProblemDetails } from "./ProblemDetails.js"
3
4/**
5 * Muestra un error en la consola y en un cuadro de
6 * alerta el mensaje de una excepción.
7 * @param { ProblemDetails | Error | null } error descripción del error.
8 */
9export function muestraError(error) {
10
11 if (error === null) {
12
13 console.error("Error")
14 alert("Error")
15
16 } else if (error instanceof ProblemDetails) {
17
18 let mensaje = error.title
19 if (error.detail) {
20 mensaje += `\n\n${error.detail}`
21 }
22 mensaje += `\n\nCódigo: ${error.status}`
23 if (error.type) {
24 mensaje += ` ${error.type}`
25 }
26
27 console.error(mensaje)
28 console.error(error)
29 console.error("Headers:")
30 error.headers.forEach((valor, llave) => console.error(llave, "=", valor))
31 alert(mensaje)
32
33 } else {
34
35 console.error(error)
36 alert(error.message)
37
38 }
39
40}
41
42exportaAHtml(muestraError)

9. lib / js / muestraObjeto.js

1import { exportaAHtml } from "./exportaAHtml.js"
2
3/**
4 * @param { Document | HTMLElement } raizHtml
5 * @param { any } objeto
6 */
7export function muestraObjeto(raizHtml, objeto) {
8
9 for (const [nombre, definiciones] of Object.entries(objeto)) {
10
11 if (Array.isArray(definiciones)) {
12
13 muestraArray(raizHtml, nombre, definiciones)
14
15 } else if (definiciones !== undefined && definiciones !== null) {
16
17 const elementoHtml = buscaElementoHtml(raizHtml, nombre)
18
19 if (elementoHtml instanceof HTMLInputElement) {
20
21 muestraInput(raizHtml, elementoHtml, definiciones)
22
23 } else if (elementoHtml !== null) {
24
25 for (const [atributo, valor] of Object.entries(definiciones)) {
26 if (atributo in elementoHtml) {
27 elementoHtml[atributo] = valor
28 }
29 }
30
31 }
32
33 }
34
35 }
36
37}
38exportaAHtml(muestraObjeto)
39
40/**
41 * @param { Document | HTMLElement } raizHtml
42 * @param { string } nombre
43 */
44export function buscaElementoHtml(raizHtml, nombre) {
45 return raizHtml.querySelector(
46 `#${nombre},[name="${nombre}"],[data-name="${nombre}"]`)
47}
48
49/**
50 * @param { Document | HTMLElement } raizHtml
51 * @param { string } propiedad
52 * @param {any[]} valores
53 */
54function muestraArray(raizHtml, propiedad, valores) {
55
56 const conjunto = new Set(valores)
57 const elementos =
58 raizHtml.querySelectorAll(`[name="${propiedad}"],[data-name="${propiedad}"]`)
59
60 if (elementos.length === 1) {
61 const elemento = elementos[0]
62
63 if (elemento instanceof HTMLSelectElement) {
64 const options = elemento.options
65 for (let i = 0, len = options.length; i < len; i++) {
66 const option = options[i]
67 option.selected = conjunto.has(option.value)
68 }
69 return
70 }
71
72 }
73
74 for (let i = 0, len = elementos.length; i < len; i++) {
75 const elemento = elementos[i]
76 if (elemento instanceof HTMLInputElement) {
77 elemento.checked = conjunto.has(elemento.value)
78 }
79 }
80
81}
82
83/**
84 * @param { Document | HTMLElement } raizHtml
85 * @param { HTMLInputElement } input
86 * @param { any } definiciones
87 */
88function muestraInput(raizHtml, input, definiciones) {
89
90 for (const [atributo, valor] of Object.entries(definiciones)) {
91
92 if (atributo == "data-file") {
93
94 const img = getImgParaElementoHtml(raizHtml, input)
95 if (img !== null) {
96 input.dataset.file = valor
97 input.value = ""
98 if (valor === "") {
99 img.src = ""
100 img.hidden = true
101 } else {
102 img.src = valor
103 img.hidden = false
104 }
105 }
106
107 } else if (atributo in input) {
108
109 input[atributo] = valor
110
111 }
112 }
113
114}
115
116/**
117 * @param { Document | HTMLElement } raizHtml
118 * @param { HTMLElement } elementoHtml
119 */
120export function getImgParaElementoHtml(raizHtml, elementoHtml) {
121 const imgId = elementoHtml.getAttribute("data-img")
122 if (imgId === null) {
123 return null
124 } else {
125 const input = buscaElementoHtml(raizHtml, imgId)
126 if (input instanceof HTMLImageElement) {
127 return input
128 } else {
129 return null
130 }
131 }
132}

10. lib / js / ProblemDetails.js

1/**
2 * Detalle de los errores devueltos por un servicio.
3 */
4export class ProblemDetails extends Error {
5
6 /**
7 * @param {number} status
8 * @param {Headers} headers
9 * @param {string} title
10 * @param {string} [type]
11 * @param {string} [detail]
12 */
13 constructor(status, headers, title, type, detail) {
14 super(title)
15 /**
16 * @readonly
17 */
18 this.status = status
19 /**
20 * @readonly
21 */
22 this.headers = headers
23 /**
24 * @readonly
25 */
26 this.type = type
27 /**
28 * @readonly
29 */
30 this.detail = detail
31 /**
32 * @readonly
33 */
34 this.title = title
35 }
36
37}

11. lib / js / suscribeAPush.js

1/**
2 * @param { Uint8Array } applicationServerKey
3 */
4export async function suscribeAPush(applicationServerKey) {
5 // Recupera el service worker registrado.
6 const registro = await navigator.serviceWorker.ready
7 return registro.pushManager.subscribe({
8 userVisibleOnly: true,
9 applicationServerKey
10 })
11}

12. lib / js / urlBase64ToUint8Array.js

1/**
2 * @param { string } base64String
3 */
4export function urlBase64ToUint8Array(base64String) {
5 const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
6 const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/')
7 const rawData = atob(base64)
8 const outputArray = new Uint8Array(rawData.length)
9 for (let i = 0; i < rawData.length; ++i) {
10 outputArray[i] = rawData.charCodeAt(i)
11 }
12 return outputArray
13}

B. Carpeta « lib / php »

1. lib / php / BAD_REQUEST.php

1<?php
2
3const BAD_REQUEST = 400;
4

2. lib / php / calculaArregloDeParametros.php

1<?php
2
3function calculaArregloDeParametros(array $arreglo)
4{
5 $parametros = [];
6 foreach ($arreglo as $llave => $valor) {
7 $parametros[":$llave"] = $valor;
8 }
9 return $parametros;
10}
11

3. lib / php / calculaSqlDeAsignaciones.php

1<?php
2
3function calculaSqlDeAsignaciones(string $separador, array $arreglo)
4{
5 $primerElemento = true;
6 $sqlDeAsignacion = "";
7 foreach ($arreglo as $llave => $valor) {
8 $sqlDeAsignacion .=
9 ($primerElemento === true ? "" : $separador) . "$llave=:$llave";
10 $primerElemento = false;
11 }
12 return $sqlDeAsignacion;
13}
14

4. lib / php / calculaSqlDeCamposDeInsert.php

1<?php
2
3function calculaSqlDeCamposDeInsert(array $values)
4{
5 $primerCampo = true;
6 $sqlDeCampos = "";
7 foreach ($values as $nombreDeValue => $valorDeValue) {
8 $sqlDeCampos .= ($primerCampo === true ? "" : ",") . "$nombreDeValue";
9 $primerCampo = false;
10 }
11 return $sqlDeCampos;
12}
13

5. lib / php / calculaSqlDeValues.php

1<?php
2
3function calculaSqlDeValues(array $values)
4{
5 $primerValue = true;
6 $sqlDeValues = "";
7 foreach ($values as $nombreDeValue => $valorDeValue) {
8 $sqlDeValues .= ($primerValue === true ? "" : ",") . ":$nombreDeValue";
9 $primerValue = false;
10 }
11 return $sqlDeValues;
12}
13

6. lib / php / delete.php

1<?php
2
3require_once __DIR__ . "/calculaArregloDeParametros.php";
4require_once __DIR__ . "/calculaSqlDeAsignaciones.php";
5
6function delete(PDO $pdo, string $from, array $where)
7{
8 $sql = "DELETE FROM $from";
9
10 if (sizeof($where) === 0) {
11 $pdo->exec($sql);
12 } else {
13 $sqlDeWhere = calculaSqlDeAsignaciones(" AND ", $where);
14 $sql .= " WHERE $sqlDeWhere";
15
16 $statement = $pdo->prepare($sql);
17 $parametros = calculaArregloDeParametros($where);
18 $statement->execute($parametros);
19 }
20}
21

7. lib / php / devuelveCreated.php

1<?php
2
3require_once __DIR__ . "/devuelveResultadoNoJson.php";
4
5function devuelveCreated($urlDelNuevo, $resultado)
6{
7
8 $json = json_encode($resultado);
9
10 if ($json === false) {
11
12 devuelveResultadoNoJson();
13 } else {
14
15 http_response_code(201);
16 header("Location: {$urlDelNuevo}");
17 header("Content-Type: application/json");
18 echo $json;
19 }
20}
21

8. lib / php / devuelveErrorInterno.php

1<?php
2
3require_once __DIR__ . "/INTERNAL_SERVER_ERROR.php";
4require_once __DIR__ . "/devuelveProblemDetails.php";
5require_once __DIR__ . "/devuelveProblemDetails.php";
6
7function devuelveErrorInterno(Throwable $error)
8{
9 devuelveProblemDetails(new ProblemDetails(
10 status: INTERNAL_SERVER_ERROR,
11 title: $error->getMessage(),
12 type: "/error/errorinterno.html"
13 ));
14}
15

9. lib / php / devuelveJson.php

1<?php
2
3require_once __DIR__ . "/devuelveResultadoNoJson.php";
4
5function devuelveJson($resultado)
6{
7
8 $json = json_encode($resultado);
9
10 if ($json === false) {
11
12 devuelveResultadoNoJson();
13 } else {
14
15 http_response_code(200);
16 header("Content-Type: application/json");
17 echo $json;
18 }
19}
20

10. lib / php / devuelveNoContent.php

1<?php
2
3function devuelveNoContent()
4{
5 http_response_code(204);
6}
7

11. lib / php / devuelveProblemDetails.php

1<?php
2
3require_once __DIR__ . "/devuelveResultadoNoJson.php";
4require_once __DIR__ . "/ProblemDetails.php";
5
6function devuelveProblemDetails(ProblemDetails $details)
7{
8
9 $body = ["title" => $details->title];
10 if ($details->type !== null) {
11 $body["type"] = $details->type;
12 }
13 if ($details->detail !== null) {
14 $body["detail"] = $details->detail;
15 }
16
17 $json = json_encode($body);
18
19 if ($json === false) {
20
21 devuelveResultadoNoJson();
22 } else {
23
24 http_response_code($details->status);
25 header("Content-Type: application/problem+json");
26 echo $json;
27 }
28}
29

12. lib / php / devuelveResultadoNoJson.php

1<?php
2
3require_once __DIR__ . "/INTERNAL_SERVER_ERROR.php";
4
5function devuelveResultadoNoJson()
6{
7
8 http_response_code(INTERNAL_SERVER_ERROR);
9 header("Content-Type: application/problem+json");
10 echo '{' .
11 '"title": "El resultado no puede representarse como JSON."' .
12 '"type": "/error/resultadonojson.html"' .
13 '}';
14}
15

13. lib / php / ejecutaServicio.php

1<?php
2
3require_once __DIR__ . "/ProblemDetails.php";
4require_once __DIR__ . "/devuelveProblemDetails.php";
5require_once __DIR__ . "/devuelveErrorInterno.php";
6
7function ejecutaServicio(callable $codigo)
8{
9 try {
10 $codigo();
11 } catch (ProblemDetails $details) {
12 devuelveProblemDetails($details);
13 } catch (Throwable $error) {
14 devuelveErrorInterno($error);
15 }
16}
17

14. lib / php / fetch.php

1<?php
2
3function fetch(
4 PDOStatement|false $statement,
5 $parametros = [],
6 int $mode = PDO::FETCH_ASSOC,
7 $opcional = null
8) {
9
10 if ($statement === false) {
11
12 return false;
13 } else {
14
15 if (sizeof($parametros) > 0) {
16 $statement->execute($parametros);
17 }
18
19 if ($opcional === null) {
20 return $statement->fetch($mode);
21 } else {
22 $statement->setFetchMode($mode, $opcional);
23 return $statement->fetch();
24 }
25 }
26}
27

15. lib / php / fetchAll.php

1<?php
2
3function fetchAll(
4 PDOStatement|false $statement,
5 $parametros = [],
6 int $mode = PDO::FETCH_ASSOC,
7 $opcional = null
8): array {
9
10 if ($statement === false) {
11
12 return [];
13 } else {
14
15 if (sizeof($parametros) > 0) {
16 $statement->execute($parametros);
17 }
18
19 $resultado = $opcional === null
20 ? $statement->fetchAll($mode)
21 : $statement->fetchAll($mode, $opcional);
22
23 if ($resultado === false) {
24 return [];
25 } else {
26 return $resultado;
27 }
28 }
29}
30

16. lib / php / insert.php

1<?php
2
3require_once __DIR__ . "/calculaSqlDeCamposDeInsert.php";
4require_once __DIR__ . "/calculaSqlDeValues.php";
5require_once __DIR__ . "/calculaArregloDeParametros.php";
6
7function insert(PDO $pdo, string $into, array $values)
8{
9 $sqlDeCampos = calculaSqlDeCamposDeInsert($values);
10 $sqlDeValues = calculaSqlDeValues($values);
11 $sql = "INSERT INTO $into ($sqlDeCampos) VALUES ($sqlDeValues)";
12 $parametros = calculaArregloDeParametros($values);
13 $pdo->prepare($sql)->execute($parametros);
14}
15

17. lib / php / INTERNAL_SERVER_ERROR.php

1<?php
2
3const INTERNAL_SERVER_ERROR = 500;

18. lib / php / ProblemDetails.php

1<?php
2
3/** Detalle de los errores devueltos por un servicio. */
4class ProblemDetails extends Exception
5{
6
7 public int $status;
8 public string $title;
9 public ?string $type;
10 public ?string $detail;
11
12 public function __construct(
13 int $status,
14 string $title,
15 ?string $type = null,
16 ?string $detail = null,
17 Throwable $previous = null
18 ) {
19 parent::__construct($title, $status, $previous);
20 $this->status = $status;
21 $this->type = $type;
22 $this->title = $title;
23 $this->detail = $detail;
24 }
25}
26

19. lib / php / recuperaJson.php

1<?php
2
3function recuperaJson()
4{
5 return json_decode(file_get_contents("php://input"));
6}
7

20. lib / php / select.php

1<?php
2
3require_once __DIR__ . "/fetchAll.php";
4require_once __DIR__ . "/calculaSqlDeAsignaciones.php";
5
6function select(
7 PDO $pdo,
8 string $from,
9 array $where = [],
10 string $orderBy = "",
11 int $mode = PDO::FETCH_ASSOC,
12 $opcional = null
13) {
14 $sql = "SELECT * FROM $from";
15
16 if (sizeof($where) > 0) {
17 $sqlDeWhere = calculaSqlDeAsignaciones(" AND ", $where);
18 $sql .= " WHERE $sqlDeWhere";
19 }
20
21 if ($orderBy !== "") {
22 $sql .= " ORDER BY $orderBy";
23 }
24
25 if (sizeof($where) === 0) {
26 $statement = $pdo->query($sql);
27 return fetchAll($statement, [], $mode, $opcional);
28 } else {
29 $statement = $pdo->prepare($sql);
30 $parametros = calculaArregloDeParametros($where);
31 return fetchAll($statement, $parametros, $mode, $opcional);
32 }
33}
34

21. lib / php / selectFirst.php

1<?php
2
3require_once __DIR__ . "/fetch.php";
4require_once __DIR__ . "/calculaArregloDeParametros.php";
5require_once __DIR__ . "/calculaSqlDeAsignaciones.php";
6
7function selectFirst(
8 PDO $pdo,
9 string $from,
10 array $where = [],
11 string $orderBy = "",
12 int $mode = PDO::FETCH_ASSOC,
13 $opcional = null
14) {
15 $sql = "SELECT * FROM $from";
16
17 if (sizeof($where) > 0) {
18 $sqlDeWhere = calculaSqlDeAsignaciones(" AND ", $where);
19 $sql .= " WHERE $sqlDeWhere";
20 }
21
22 if ($orderBy !== "") {
23 $sql .= " ORDER BY $orderBy";
24 }
25
26 if (sizeof($where) === 0) {
27 $statement = $pdo->query($sql);
28 return fetch($statement, [], $mode, $opcional);
29 } else {
30 $statement = $pdo->prepare($sql);
31 $parametros = calculaArregloDeParametros($where);
32 return fetch($statement, $parametros, $mode, $opcional);
33 }
34}
35

22. lib / php / update.php

1<?php
2
3require_once __DIR__ . "/calculaArregloDeParametros.php";
4require_once __DIR__ . "/calculaSqlDeAsignaciones.php";
5
6
7function update(PDO $pdo, string $table, array $set, array $where)
8{
9 $sqlDeSet = calculaSqlDeAsignaciones(",", $set);
10 $sqlDeWhere = calculaSqlDeAsignaciones(" AND ", $where);
11 $sql = "UPDATE $table SET $sqlDeSet WHERE $sqlDeWhere";
12
13 $parametros = calculaArregloDeParametros($set);
14 foreach ($where as $nombreDeWhere => $valorDeWhere) {
15 $parametros[":$nombreDeWhere"] = $valorDeWhere;
16 }
17 $statement = $pdo->prepare($sql);
18 $statement->execute($parametros);
19}
20

23. lib / php / validaJson.php

1<?php
2
3require_once __DIR__ . "/BAD_REQUEST.php";
4require_once __DIR__ . "/ProblemDetails.php";
5
6function validaJson($objeto)
7{
8
9 if ($objeto === null)
10 throw new ProblemDetails(
11 status: BAD_REQUEST,
12 title: "Los datos recibidos no son JSON.",
13 type: "/error/datosnojson.html",
14 detail: "Los datos recibidos no están en formato JSON.O",
15 );
16
17 return $objeto;
18}
19

12. Carpeta « error »

Versión para imprimir.

A. error / authtokenincorrecto.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>El authToken debe ser texto</title>
10
11<body>
12
13 <h1>El authToken debe ser texto</h1>
14
15</body>
16
17</html>

B. error / contentencodingincorrecta.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>La contentEncoding debe ser texto</title>
10
11<body>
12
13 <h1>La contentEncoding debe ser texto</h1>
14
15</body>
16
17</html>

C. error / datosnojson.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Los datos recibidos no son JSON</title>
10
11</head>
12
13<body>
14
15 <h1>Los datos recibidos no son JSON</h1>
16
17 <p>
18 Los datos recibidos no están en formato JSON.
19 </p>
20
21</body>
22
23</html>

D. error / endpointincorrecto.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>El endpoint debe ser texto</title>
10
11<body>
12
13 <h1>El endpoint debe ser texto</h1>
14
15</body>
16
17</html>

E. error / errorinterno.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Error interno del servidor</title>
10
11</head>
12
13<body>
14
15 <h1>Error interno del servidor</h1>
16
17 <p>Se presentó de forma inesperada un error interno del servidor.</p>
18
19</body>
20
21</html>

F. error / publickeyincorrecta.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>La publicKey debe ser texto</title>
10
11<body>
12
13 <h1>La publicKey debe ser texto</h1>
14
15</body>
16
17</html>

G. error / resultadonojson.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>El resultado no puede representarse como JSON</title>
10
11</head>
12
13<body>
14
15 <h1>El resultado no puede representarse como JSON</h1>
16
17 <p>
18 Debido a un error interno del servidor, el resultado generado, no se puede
19 recuperar.
20 </p>
21
22</body>
23
24</html>

13. Carpeta « vendor »

Versión para imprimir.

A. vendor / -- No se muestra el contenido de esta carpeta --

1

14. .htaccess

1RewriteEngine On
2
3RewriteCond %{HTTP:X-Forwarded-Proto} !https
4RewriteCond %{HTTPS} off
5RewriteCond %{HTTP:CF-Visitor} !{"scheme":"https"}
6RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
7

15. jsconfig.json

1{
2 "compilerOptions": {
3 "checkJs": true,
4 "strictNullChecks": true,
5 "target": "ES6",
6 "module": "Node16",
7 "moduleResolution": "Node16",
8 "lib": [
9 "ES2017",
10 "WebWorker",
11 "DOM"
12 ]
13 }
14}

16. Resumen