Съдържание на курса
Урок 11 — Завършеното табло
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Време: две сесии от около 90 минути. Разпределение, което работи: първата, „Защо, преди как“, прегледът с клавиатурата (11.1), политиката за сигурност на съдържанието (11.2) и измерването на теглото и производителността (11.3), с отворено табло и инструментите на браузъра под ръка; втората, публикуването на сайта (11.4), петте критерия един по един върху публикувания адрес (11.5) и упражненията. Ако публикуването ти отнеме повече от предвиденото, сесия две е онази, която се проточва: акаунтът и стъпките на услугата, която избереш, не зависят от теб.
Какво изграждаш: revisor, публикуван като статичен сайт, с неговите заглавни части за сигурност и измерените му числа
Какво научаваш: прегледът с клавиатурата; политиката за сигурност на съдържанието (CSP) като заглавна част на сървъра; тегло и производителност; публикуване на статичен сайт
След урока ще можеш да
- Обхождаш цялото табло само с клавиатурата и казваш кой критерий за достъпност нарушава онова, което не се достига, не се вижда или няма изход.
- Пишеш политика за сигурност на съдържанието (CSP), обясняваш какво блокира всяка директива и защо мястото ѝ е заглавна част на сървъра, а не само таг.
- Изпробваш тази политика на компютъра си със същите заглавни части, които ще изпраща сървърът, и четеш в конзолата съобщенията за нарушения.
- Измерваш теглото на таблото и метриките му (LCP, CLS, INP) и казваш коя от тях не се появява при лабораторен тест за зареждане и защо.
- Резервираш мястото на онова, което пристига късно, и предварително зареждаш модулите, и проверяваш с числа, че е помогнало.
- Публикуваш папката като статичен сайт и проверяваш с
curl, че заглавните части са пристигнали. - Проверяваш един по един петте критерия, с които приключва курсът.
Защо, преди как
Досега таблото ти работи на твоя компютър, отворено от твоя сървър, с твоя браузър и с данни, които ти си написал. „Работи на моята машина“ е фраза, която познава добре всеки, който е публикувал нещо, и е разстоянието между упражнение и продукт. Този урок изминава това разстояние: прегледва таблото така, както би го прегледал човек, който не си ти, защитава го с още един слой, измерва го и го поставя в интернет.
Курсът освен това обеща критерий за завършване. Учебният план казва, че курсът не свършва, когато си прочел урок 11, а когато таблото ти изпълнява пет неща. Никоя програма не ги проверява вместо теб: проверяваш ги ти, с инструментите на браузъра, и този урок ти казва как:
| # | Таблото изпълнява… | Къде е научено | Къде се проверява днес |
|---|---|---|---|
| 1 | Навигира се изцяло с клавиатурата | 2, 3, 4, 5 и 7 | 11.1 |
| 2 | Няма нито една грешка в конзолата | 8, 9 и 10 | 11.2 и 11.5 |
| 3 | Работи при ширина 320 px без хоризонтално преливане | 5 | 11.1 |
| 4 | Показва трите състояния: зарежда се, грешка и празно | 9 | 11.5 |
| 5 | Текстът, който идва отвън, се чертае с textContent, никога с innerHTML |
7 | 11.2 и 11.5 |
Раздел 11.5 затваря петте с по една проверка за всеки. Преди това, три нови идеи: политиката за сигурност на съдържанието (11.2), да измерваш теглото и производителността, преди да се опитваш да ги подобряваш (11.3) и публикуването на статичен сайт (11.4). 11.1 е преглед с клавиатурата, не нова идея.
Състоянието на таблото в края на урок 10
Този урок тръгва от конкретно табло. В края на урок 10 revisor има всичко от урок 9 плюс онова от формите:
index.htmlсъс заглавната част („Última revisión“ и навигацията), обобщението, и в секцията с услуги известията (#notice,#error-notice), бутона „Reintentar“ и зона с данни с търсачката, радио бутоните „Mostrar“, „Revisar ahora“, бутона за подреждане, известието за броя и таблицата; и секция за добавяне на услуга.css/styles.cssсъс слоевете и правилата от уроци 3, 4, 5, 7, 9 и 10.js/stats.js,js/load.js,js/state.js,js/filters.js,js/form.js,js/view.jsиjs/main.js, с данните вdata/services.jsonиdata/services-empty.json.- Табло, което филтрира, подрежда, добавя услуги с родна валидация и със съобщения, които екранният четец може да обяви, и което приема
?case=empty,?case=error,?case=invalidи?case=timeout.
Онова, което още няма: никаква заглавна част за сигурност (сървърът на Python изпраща само най-необходимото), никакво измерване на тегло, нито на скорост, икона, която е пряк път, и никакъв адрес, който друг човек може да отвори. В този урок таблото преминава в папката 11-el-panel-terminado/revisor/, която е урок 10 плюс малки промени, всички обяснени по-долу: файл със заглавни части, собствена икона, няколко реда в <head> и един последен блок CSS.
Всичко в този урок използва онова, което вече имаш: обичайния сървър от папката programas/ на хранилището на курса за фигурите (тези на този урок са в programas/11-el-panel-terminado/) и един малко по-дълъг сървър на Python (един-единствен, от около 55 реда, който виждаш цял по-долу), за да изпробваш заглавните части. Нито Node, нито пакети.
Понятията
11.1 Прегледът с клавиатурата
Критерий 1 казва, че таблото се навигира изцяло с клавиатурата, без да се използва мишка. Проверява се, като го направиш, не като четеш кода. Има четири въпроса и всеки съответства на критерий от указанията за достъпност (WCAG 2.2):
- Достига ли се всичко? Всичко, което може да се направи с мишка, трябва да може да се направи с клавиатура (2.1.1, Клавиатура, ниво A). Родните елементи —
<button>,<input>,<select>— вече го изпълняват. Онова, което се чупи, е онова, което си построил на ръка:<div>с щракване. - Вижда ли се къде си? Индикаторът за фокус трябва да е видим (2.4.7, Видим фокус, ниво AA). Затова
css/styles.cssима:focus-visible { outline: 3px solid … }от урок 3 и никогаoutline: none. - Има ли смисъл редът? Фокусът трябва да обхожда контролите в ред, който запазва смисъла и позволява да се оперира (2.4.3, Ред на фокуса, ниво A). Практическото правило: редът на HTML е редът на фокуса, така че не е нужно —и почти никога не е удобно— да го пипаш с положителен
tabindex. - Може ли да се излезе? Ако фокусът влезе в един компонент, трябва да може да излезе от него само с клавиатурата (2.1.2, Без капани за клавиатурата, ниво A).
И още два критерия от версия 2.2, които се изпълняват без усилие, ако се знаят: фокусът не бива да остане изцяло закрит от съдържание, което ти си поставил (2.4.11, Фокусът не е скрит (минимално), ниво AA; фиксираната заглавна част е типичният виновник и таблото няма такава) и контролите трябва да са поне 24 × 24 CSS пиксела или да имат пространство около себе си (2.5.8, Размер на целта (минимален), ниво AA; полетата и бутоните на таблото имат минимална височина 2.5 rem, което при размера на шрифта по подразбиране е 40 пиксела: измерено в Chrome, всички са високи 40).
Предскажи: в таблото, колко пъти трябва да натиснеш Tab, започвайки от току-що заредената страница, за да стигнеш до бутона „Agregar servicio“? Помисли какво има преди това: двете връзки на навигацията, търсачката, групата радио бутони, „Revisar ahora“, бутона за подреждане, кутията на таблицата, по един бутон „Ver detalle“ за всяка услуга и трите полета на формата.
Постави този фрагмент в конзолата с отворено табло (Ctrl+Shift+K във Firefox, F12 и разделът Конзола в Chrome). Изброява, по ред, всичко, което клавиатурата може да достигне. Второто условие на филтъра оставя един-единствен радио бутон на група, отбелязания, защото групата радио бутони е една-единствена спирка на Tab (видя го в урок 2):
const stops = [...document.querySelectorAll("a[href], button, input, select, textarea, [tabindex]")]
.filter((e) => !e.disabled && e.tabIndex >= 0 && e.getClientRects().length > 0)
.filter((e) => e.type !== "radio" || e.checked);
console.log(stops.map((e) =>
`${e.tagName.toLowerCase()} · ${(e.labels?.[0]?.textContent ?? e.textContent).trim().slice(0, 30)} · tabindex ${e.tabIndex}`
).join("\n"));
В таблото на този урок, със заредени петте услуги, отпечатва шестнайсет реда:
a · Resumen · tabindex 0
a · Servicios · tabindex 0
input · Buscar servicio · tabindex 0
input · Todos · tabindex 0
button · Revisar ahora · tabindex 0
button · Ordenar por tiempo de respuest · tabindex 0
div · Estado de los servicios en la · tabindex 0
button · Ver detalle de Catálogo · tabindex 0
button · Ver detalle de Pagos · tabindex 0
button · Ver detalle de Inventario · tabindex 0
button · Ver detalle de Notificaciones · tabindex 0
button · Ver detalle de Búsqueda · tabindex 0
input · Nombre * · tabindex 0
select · Estado * · tabindex 0
input · Tiempo de respuesta (ms) * · tabindex 0
button · Agregar servicio · tabindex 0
Те са шестнайсет спирки, така че отговорът на предсказанието е шестнайсет пъти; проверих го, като наистина натиснах Tab, и фокусът обхожда точно този списък и в този ред. Онова, което има значение в списъка, не е числото, а онова, което не се появява: никакъв tabindex, различен от нула (всички казват 0), никаква връзка без цел и един-единствен div, който е там нарочно: кутията на таблицата, която урок 5 направи фокусируема, за да може клавиатурата да я превърта. Ако в твоето табло някой ред казва tabindex 3 или ако някое действие на таблото не се появява в списъка, там е недостатъкът.
Пълният обход, който отнема десет минути и си струва да се направи бавно:
- С Tab напредвай през цялата страница; с Shift+Tab се връщай назад. На всяка спирка провери, че виждаш контура на фокуса.
- В търсачката напиши
pag: таблицата трябва да остане с един ред, без да докосваш мишката. - В селектора за състояние го смени със стрелките.
- На бутон „Ver detalle“ натисни Enter или интервала: подробностите се появяват под таблицата и фокусът остава на същия бутон (урок 7 направи
updateда го връща там след повторното чертане). - Във формата, с фокус върху поле, натисни Enter с всичко празно: фокусът скача към първото поле с грешка. Поправи и изпрати: фокусът се връща на „Nombre“.
- Отвори
?case=errorи стигни до „Reintentar“ само с Tab. Натисни го: фокусът остава на него (урок 9 направиloadда го връща).
При 320 пиксела. Критерий 3 изисква таблото да работи при ширина 320 пиксела без хоризонтална лента за превъртане. Не е произволно число: това е критерият за прегрупиране (1.4.10, Reflow), ниво AA, който е равностоен на екран от 1280 пиксела с мащабиране на 400 % и съществува за онзи, който уголемява текста. Проверява се с един ред в конзолата:
document.documentElement.scrollWidth <= document.documentElement.clientWidth
Ако върне false, нещо излиза навън. Таблото стига до този урок, изпълнявайки го от урок 5, който сложи таблицата в кутия, която се превърта, а урок 7 трябваше да го защити: скритият текст на бутоните „Ver detalle“ излизаше от тази кутия и разтягаше страницата до 498 px, докато position: relative на .table-scroll не го върна вътре. Това е урокът на този критерий: не се изпълнява веднъж, измерва се наново след всяка промяна.
Автоматичните инструменти. Преди да приемеш прегледа за добър, пусни одитор. В този урок беше използван axe-core 4.14.0, двигателят зад много разширения за достъпност, върху таблото в петте му ситуации (нормална и четирите случая на ?case=): нула нарушения. Също и Lighthouse, който по-долу описваме подробно, даде 100 по достъпност. Но нулата не означава „достъпно“: означава „никакъв от недостатъците, които една машина умее да разпознае“. Една машина може да види, че едно поле има етикет; не може да знае дали съобщението за грешка се разбира. Затова обходът с клавиатурата и, ако можеш, с екранен четец, не се заменя.
11.2 Политиката за сигурност на съдържанието, като заглавна част
Два слоя. В урок 7 научи първата защита срещу атаките с инжектиране на код (XSS): текстът, който идва отвън, влиза в страницата с textContent, който го третира като текст и никога като HTML. Това е защитата, която се брои, защото не позволява проблемът да възникне. Политиката за сигурност на съдържанието —CSP, от английските думи— е втори слой, за деня, когато първият се провали: разсеян програмист, който пише innerHTML там, където не е трябвало, чужда библиотека с недостатък. MDN го казва с точните думи в своето ръководство за CSP: CSP не заменя правилното третиране на входа; трябва да се направят и двете, за да има защита в дълбочина.
Какво е. CSP е списък от правила, които сървърът праща на браузъра за това какво страницата може да зарежда и изпълнява. „Скриптовете могат да идват само от моя собствен сайт“, „изображенията, същото“, „страницата не може да се свързва с никой друг“. Браузърът ги налага: ако нещо наруши правило, го блокира и го отбелязва в конзолата. Най-простата политика е default-src 'self', която казва: всичко, което страницата зарежда, трябва да идва от собствения ѝ произход.
Заглавна част или таг. Една CSP може да пристигне по два начина. Като заглавна част на отговора HTTP —Content-Security-Policy: …, препоръчителният начин, който се изпраща с всеки отговор, не само със страницата— или като таг <meta http-equiv="Content-Security-Policy" content="…"> вътре в HTML. Тагът съществува за онзи, който не контролира сървъра, и затова се използва в най-простите статични сайтове. Но не е същото. MDN предупреждава, че тагът „не поддържа всички функции“: не може да предаде политика в режим само-доклад и директивата frame-ancestors (която пречи на други страници да те вграждат в рамка) не работи в таг. Заглавната част, напротив, покрива всичко.
Затова учебният план казва „CSP като заглавна част на сървъра“. Но да започнем с тага, който може да се изпробва с един файл и без специален сървър.
Предскажи: фигура 11.1 има таг с политиката script-src 'self' и два скрипта: единият написан вътре в HTML, а другият във файл на същия сайт. Кой се изпълнява?
<!-- fig11_01.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="script-src 'self'">
<title>Una política que bloquea el script en línea</title>
<link rel="icon" href="data:,">
</head>
<body>
<main>
<h1>Una política que bloquea el script en línea</h1>
<p id="result" role="status">Ningún script se ha ejecutado.</p>
</main>
<script>
document.getElementById("result").textContent = "El script en línea sí se ejecutó.";
</script>
<script src="fig11_01/external.js"></script>
</body>
</html>
// fig11_01/external.js
document.getElementById("result").textContent = "El script externo sí se ejecutó.";
Отвори я на http://127.0.0.1:8000/11-el-panel-terminado/fig11_01.html, със стартиран сървър от папката programas/ на хранилището. Когато се зареди, страницата показва:
Una política que bloquea el script en línea
El script externo sí se ejecutó.
Изпълни се само външният скрипт. Този вътре в HTML беше блокиран и конзолата казва защо. Това е съобщението, което ще видиш най-много пъти в живота си с CSP, и си струва да го прочетеш цялото:
Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-bTp6bKDsmuoAZZxMFjB9R21R8gPJ7kRwjoDZhQv8XQo='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
Чети го на части. „Executing inline script violates … script-src 'self'“ казва какво е блокирано (вграден скрипт) и кое правило го е блокирало. „Either the 'unsafe-inline' keyword, a hash, or a nonce is required to enable inline execution“ изброява трите начина да се позволи: да отвориш правилото изцяло или да упълномощиш този скрипт конкретно с отпечатък (hash) или с число за еднократна употреба (nonce). А „The action has been blocked“ потвърждава, че не е изпълнен. hash в примера е на точно този скрипт; при теб ще е същият, защото скриптът е същият.
Обърни внимание какво означава това за онзи, който напада: такава политика, сама по себе си, не позволява инжектиран фрагмент —<script>…</script> или атрибут onerror="…"— да се изпълни, дори да е стигнал до страницата. И обърни внимание какво означава за теб: кодът ти трябва да живее във файлове и да регистрира събитията си с addEventListener. Таблото го прави така от урок 7. Затова политиката, която ще напишем сега, не му чупи нищо.
Политиката на таблото, директива по директива. Директивата е правило за един вид ресурс. Тази на revisor е следната:
| Директива | Стойност | Какво не позволява |
|---|---|---|
default-src |
'none' |
всяко зареждане, което друга директива не упълномощава изрично |
script-src |
'self' |
скриптове, които не са файлове на същия сайт: вградени, от друг домейн, eval() |
style-src |
'self' |
стилови листове от други сайтове и атрибути style, написани в HTML |
img-src |
'self' |
изображения от други сайтове и тези от тип data: |
connect-src |
'self' |
fetch да иска данни от друг домейн |
form-action |
'none' |
формата да изпраща данните си някъде |
base-uri |
'none' |
някой да промени базовия адрес с таг <base> |
frame-ancestors |
'none' |
друга страница да вгради твоята в рамка (работи само като заглавна част) |
'self' означава „същият произход като страницата“ (схема, сървър и порт, тези, които изучи в урок 0). Единичните кавички са част от думата. default-src е резервната стойност: ако няма директива за даден вид ресурс, командва default-src. Да започнеш с 'none' те принуждава да позволяваш всяко нещо нарочно, а това е, което искаме: кратък списък, който можеш да прочетеш цял.
Таблото не използва шрифтове от други сайтове, нито външни изображения, нито един чужд скрипт. Политиката му може да е толкова затворена, защото кодът му си го написал ти, в твоите файлове.
Файлът със заглавни части. Услугите за публикуване на статични сайтове четат файл на име _headers (без разширение), който е в папката, която публикуваш. Cloudflare Pages и Netlify го приемат със същия синтаксис: ред с пътя, за който се прилага, и под него, с отстъп, по една заглавна част на ред. Това са тези на revisor:
# revisor/_headers
# Encabezados que el servidor debe enviar con cada respuesta.
# Cloudflare Pages y Netlify leen este archivo con esta misma sintaxis.
/*
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Освен CSP има още две евтини заглавни части. X-Content-Type-Options: nosniff казва на браузъра да не налучква вида на един файл: ако отговорът казва, че е текст, е текст, а един скрипт се изпълнява само ако сървърът обявява, че е JavaScript. А Referrer-Policy: no-referrer не позволява на браузъра да казва на други сайтове от коя страница идваш; таблото не сочи към никого, така че не струва нищо.
Да го изпробваш на компютъра си. Обичайният сървър (python3 -m http.server) не чете _headers. За да видиш същото, което ще види сървърът, където публикуваш, тази програма на Python прави същото като http.server и освен това изпраща заглавните части от файла. Сервира каквато и да е папка; прочети я цялата, защото са около 55 реда и няма нищо скрито:
# headers-server.py
# Sirve una carpeta como lo hace `python3 -m http.server`, pero además envía los
# encabezados que declara el archivo _headers de esa carpeta. Así pruebas en tu
# computadora lo mismo que va a enviar el servidor donde publiques. Y, como el
# slow-server.py de la lección 9, entiende ?delay=MILISEGUNDOS para tardar a propósito.
#
# Uso: python3 headers-server.py [carpeta] [puerto]
import fnmatch
import sys
import time
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
# Nadie necesita esperar más de diez segundos para ver el efecto.
MAX_DELAY_MS = 10000
def read_rules(folder):
"""Devuelve una lista de (patrón de ruta, encabezado, valor)."""
rules = []
pattern = None
headers_file = Path(folder) / "_headers"
if not headers_file.exists():
return rules
for line in headers_file.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
if not line[0].isspace():
pattern = line.strip() # una ruta: "/*" o "/index.html"
continue
name, _, value = line.strip().partition(":")
rules.append((pattern, name.strip(), value.strip()))
return rules
class Handler(SimpleHTTPRequestHandler):
rules = []
def do_GET(self):
# Lo mismo que slow-server.py: ?delay=3000 espera tres segundos antes de contestar.
query = parse_qs(urlsplit(self.path).query)
delay = query.get("delay", ["0"])[0]
if delay.isdigit():
time.sleep(min(int(delay), MAX_DELAY_MS) / 1000)
try:
super().do_GET()
except (BrokenPipeError, ConnectionResetError):
# El navegador se cansó de esperar y cerró la conexión.
pass
def end_headers(self):
path = self.path.split("?", 1)[0]
for pattern, name, value in self.rules:
if fnmatch.fnmatch(path, pattern):
self.send_header(name, value)
super().end_headers()
def main():
folder = sys.argv[1] if len(sys.argv) > 1 else "."
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8000
Handler.rules = read_rules(folder)
print(f"{len(Handler.rules)} encabezados leídos de {folder}/_headers", flush=True)
server = ThreadingHTTPServer(
("127.0.0.1", port), partial(Handler, directory=folder)
)
print(f"Sirviendo {folder} en http://127.0.0.1:{port} (Ctrl+C para parar)", flush=True)
server.serve_forever()
main()
От папката programas/11-el-panel-terminado/ на изтегленото хранилище:
python3 headers-server.py revisor 8000
Трябва да отпечата 3 encabezados leídos de revisor/_headers (3 заглавни части, прочетени от revisor/_headers) и адреса. Отвори го и провери, в друг терминал, че заглавните части пристигат:
curl -sI http://127.0.0.1:8000/ | grep -i -E "content-security|nosniff|referrer"
Трябва да излязат трите реда. Ако не излезе нито един, сървърът не е прочел файла и по-добре е да го разбереш сега, отколкото след като публикуваш. (За да спреш сървъра, Ctrl+C.) Кодът носи и ?delay от slow-server.py от урок 9 —същите редове, в do_GET—, за да може ?case=timeout да продължи да предизвиква наистина изтекло време с този сървър.
Грешката, която се появява, щом я включиш. Когато тази политика е активна, конзолата на таблото от урок 10 казва нещо, което не очакваше:
Loading the image 'data:,' violates the following Content Security Policy directive: "img-src 'self'". The action has been blocked.
Това е иконата. От урок 2 таблото носи <link rel="icon" href="data:,">, трикът от Упражнение 2 на урок 1, за да не иска браузърът /favicon.ico и да не напълни конзолата с грешки 404; урок 2 предупреди, че има цена, и това е тя. Но изображение data: не е от същия сайт и политиката (img-src 'self') го блокира. Има два изхода: да отвориш политиката с data: в img-src или да дадеш на сайта собствена икона. Вторият е по-добър, защото публикуваният сайт така или иначе трябва да има икона. revisor носи файл favicon.svg от два реда (тъмен квадрат със зелен кръг) и <head> го свързва с <link rel="icon" href="favicon.svg" type="image/svg+xml">:
<!-- revisor/favicon.svg -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1b1f24"/><circle cx="16" cy="16" r="7" fill="#1a7f37"/></svg>
Първият ред е коментар, както в страниците; в един SVG е позволен преди тага <svg>. Сегашните основни браузъри приемат SVG икона (в този урок беше проверено в Chrome и Firefox, не в Safari); такъв, който не я приема, ще поиска /favicon.ico и ще види 404, нещо, което във вътрешно табло можеш да приемеш.
Да изпробваш, преди да наложиш. Нова политика може да счупи нещо, което не си видял. Начинът да го разбереш, без да счупиш никого, е сродната заглавна част Content-Security-Policy-Report-Only (MDN): същата политика, но браузърът само отбелязва нарушенията и не блокира нищо. За да я изпробваш, смени името на заглавната част в _headers, презареди и погледни конзолата. В Chrome 154, с иконата data:, от урок 10 още поставена, нарушението се появява като информативно съобщение: „Loading the image 'data:,' violates the following Content Security Policy directive: "img-src 'self'". The policy is report-only, so the violation has been logged but no further action has been taken.“ MDN предупреждава, че за да се изпращат докладите някъде, политиката се нуждае от директивата report-to и от сървър, който да ги получава; без тях виждаш само онова, което излиза в конзолата ти. За малък сайт да гледаш конзолата стига. Когато няма съобщения, се връщаш към името Content-Security-Policy и политиката започва да се налага.
Какво CSP не прави. Да не те подведе да вярваш, че проблемът е решен. Фигура 11.2 вмъква, с innerHTML, име с атака вътре (счупено изображение с атрибут onerror), под същата политика:
<!-- fig11_02.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="script-src 'self'">
<title>La política no arregla el innerHTML</title>
<link rel="icon" href="data:,">
</head>
<body>
<main>
<h1>La política no arregla el innerHTML</h1>
<p id="result" role="status"></p>
<div id="zone"></div>
</main>
<script src="fig11_02/inject.js"></script>
</body>
</html>
// fig11_02/inject.js
// Un nombre que viene de fuera y trae un ataque dentro.
const incoming = '<img src="missing.png" onerror="document.title = \'atacado\'">';
// MAL: innerHTML interpreta el texto como HTML.
document.getElementById("zone").innerHTML = incoming;
const images = document.querySelectorAll("#zone img").length;
document.getElementById("result").textContent =
`Imágenes inyectadas: ${images}. Título de la página: ${document.title}`;
Предскажи: изпълнява ли се атрибутът onerror? Остава ли изображението в страницата?
La política no arregla el innerHTML
Imágenes inyectadas: 1. Título de la página: La política no arregla el innerHTML
Изображението остана: инжектираният HTML е в страницата, 1. Но заглавието на документа не се промени: атрибутът не се изпълни и конзолата казва защо (освен 404 на изображение, което не съществува):
Executing inline event handler violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present. The action has been blocked.
Това е CSP, която си върши работата на втори слой: ограничи вредата. Но вредата, която наистина стана —чужд HTML вътре в твоята страница— е точно онази, която textContent не позволява. CSP не поправя зле поставен innerHTML; само намалява онова, което той може да направи. И не всяка атака се нуждае от скрипт: някой, който инжектира фалшива форма или подвеждащ текст, няма нужда да изпълнява нищо.
Какво предстои. Има още два по-нови механизма, които атакуват същия проблем от друга страна, и засега не са основа на нищо, което пишеш тук. Trusted Types (MDN) кара присвояването на самотен текст на innerHTML да хвърли грешка, ако политиката го изисква с require-trusted-types-for 'script'; MDN го отбелязва като „Baseline 2026, току-що достъпно“ (от февруари 2026 г.). setHTML() и API-то за санитизиране (MDN) биха чистили HTML, преди да го вмъкнат; MDN го отбелязва като ограничена наличност, не Baseline. Правилото на курса се запазва: не се използват в производствена среда, докато не станат основа. За твоето табло textContent вече върши работата.
Тагът, когато няма друго. Ако сайтът, където публикуваш, не ти позволява да изпращаш заглавни части —документацията на GitHub Pages, която беше прегледана за подготовката на този урок, не описва никакъв начин да се направи, и затова си струва да го провериш с curl -I на публикувания си сайт—, тагът <meta> е по-добър от нищо. index.html на таблото не го носи, защото политиката му пътува в _headers; ако публикуваш там, където не се четат заглавни части, го добавяш ти. Това е този ред, със същите директиви като в _headers без frame-ancestors, която в таг не работи и браузърът я игнорира с предупреждение:
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; form-action 'none'; base-uri 'none'">
Отива възможно най-горе в <head>, точно под <meta charset="utf-8">: спецификацията на CSP предупреждава, че политика в таг не се прилага към онова, което се появява преди него, така че един <link> или <script>, написани по-горе, биха останали извън. Така беше проверено, с таблото, сервирано с обичайния python3 -m http.server (който не праща заглавни части) от подпапка, както го сервира GitHub Pages: таблицата, четирите случая на ?case= и конзолата без нито едно съобщение, освен предизвиканото 404 на ?case=error. Ще загубиш frame-ancestors и режима само-доклад и трябва да си кажеш направо, че сайтът ти има по-слаба CSP от тази на сървър, който наистина праща заглавни части.
11.3 Тегло и производителност: измервай, преди да оптимизираш
Най-честото подобрение на производителността е това, което не е било нужно. Вижда се тревожно число, прилага се рецепта от една статия и никой не измерва наново. Дисциплината на този раздел е обратната: първо се измерва, после се решава и накрая се измерва наново.
Теглото. Първото, което се измерва, е най-простото: колко байта тежи онова, което браузърът изтегля. От папката revisor/:
wc -c index.html favicon.svg css/*.css js/*.js data/services.json
Резултатът, закръглен, и колко би тежал всеки вид компресиран, изчислено с gzip -9 -c archivo | wc -c. Тази втора колона е приблизителна оценка, не измерване на онова, което пътува: услугите за публикуване обикновено компресират текстовите отговори, но всеки сървър решава дали компресира и с какъв алгоритъм (gzip, br…), и само заглавната част Content-Encoding на отговора казва какво наистина е приложено (RFC 9110, §8.4). В 11.4 го проверяваш с curl върху публикувания си сайт:
| Вид | Файлове | Байтове | Компресирано (прибл.) |
|---|---|---|---|
| HTML | index.html |
5,374 | 1,742 |
| CSS | css/styles.css |
11,192 | 3,722 |
| JavaScript | седем модула | 20,731 | 8,577 |
| Данни | data/services.json |
439 | 211 |
| Икона | favicon.svg |
194 | 180 |
| Общо | 37,930 | около 14,400 |
Трийсет и седем хиляди байта некомпресирани и около четиринайсет хиляди, ако сървърът компресира. Стиловият лист е наполовина от онова, което изглежда: голяма част от единайсетте му хиляди байта са коментарите, които обясняват всяко правило, и компресирани почти изчезват. Една-единствена снимка от мобилен телефон тежи стотици пъти повече. Изводите са три. Първо: за това табло намаляването на байтове не е проблемът. Не е нужно да минифицираш, нито да пакетираш, и не си струва да усложняваш курс „без нито един инструмент за изграждане“, за да спестиш три килобайта. Второ: най-тежкият модул е js/main.js (7,165 байта), защото там живее формата. Трето: ако някой ден таблото има изображения, там ще е теглото и тогава наистина ще трябва да се измери отново.
Трите метрики. Производителността, която усеща човек, се обобщава в три числа, наречени основни уеб метрики (Core Web Vitals), определени от web.dev. Всяка се оценява на 75-ия персентил на посещенията, тоест стойността, която 75 % от посещенията достигат или подобряват:
| Метрика | Какво мери | Добро |
|---|---|---|
| LCP (изрисуване на най-голямото съдържание) | кога се появява основното | 2.5 s или по-малко |
| INP (от взаимодействието до следващото изрисуване) | колко се бави страницата да отговори на щракване, докосване или клавиш | 200 ms или по-малко |
| CLS (натрупано изместване на оформлението) | колко се мести страницата сама, без човекът да прави нищо | 0.1 или по-малко |
За INP web.dev смята за незадоволително всичко над 500 ms; за CLS — всичко над 0.25.
Лаборатория и поле. Една подробност, която решава кой инструмент да използваш и на кого да вярваш. Метриките „от полето“ се измерват с реални хора, които използват страницата. Тези „от лабораторията“ се измерват на твоя компютър, със симулиран профил. LCP и CLS могат да се измерят и от двете страни. INP се нуждае някой да взаимодейства и това променя всичко. Тест за зареждане в лабораторията —да отвориш страницата и да измериш, без да я докосваш— не произвежда никакъв INP, защото никой не е щракнал; Lighthouse в обичайния си режим е такъв, а на негово място съобщава общото време на блокиране (TBT), което web.dev смята за разумно приближение, но не заместител. Може да се измери INP в лабораторията, ако взаимодействаш ти по време на измерването, но, както предупреждава ръководството за INP, числото зависи от кои взаимодействия си направил; това, което се брои, е това на реалните хора, в полето. Затова 100 в Lighthouse не е „съвършена производителност“, а „без проблеми в онова, което Lighthouse знае да измерва“. Когато имаш реални посещения, тези измервания са в инструмента за отчети на услугата ти за публикуване или в публичните данни на Chrome; дотогава онова, което е в обсега ти, е лабораторията.
Измерване в конзолата. За да видиш LCP и CLS на таблото си, без да инсталираш нищо, отвори таблото и постави това в конзолата:
const result = { lcp: null, cls: 0 };
new PerformanceObserver((list) => {
result.lcp = Math.round(list.getEntries().at(-1).startTime);
}).observe({ type: "largest-contentful-paint", buffered: true });
let burst = 0; // suma de la ráfaga (ventana de sesión) en curso
let burstStart = 0;
let lastShift = 0;
new PerformanceObserver((list) => {
for (const shift of list.getEntries()) {
if (shift.hadRecentInput) continue;
const sameBurst = burst > 0
&& shift.startTime - lastShift < 1000
&& shift.startTime - burstStart < 5000;
if (sameBurst) {
burst += shift.value;
} else {
burst = shift.value;
burstStart = shift.startTime;
}
lastShift = shift.startTime;
result.cls = Math.max(result.cls, burst);
}
}).observe({ type: "layout-shift", buffered: true });
setTimeout(() => console.log(result), 300);
buffered: true иска от браузъра записите, които вече са станали, преди да поставиш кода, а hadRecentInput отхвърля скоковете, причинени от нещо, което човекът е направил (които не се броят). Останалото от втория наблюдател следва действащото определение на CLS: скоковете не се сумират всичките, а по изблици (сесийни прозорци). Един скок принадлежи на текущия изблик, ако пристигне по-малко от секунда след предишния и избликът още не е продължил пет секунди; иначе започва нов изблик. CLS е избликът, който сумира най-много (Math.max). Преди 2021 г. CLS беше сборът на всички скокове през живота на страницата и затова още ще видиш фрагменти, които правят cls += value и толкова: в страница, която остава отворена дълго, този сбор расте без граница и престава да може да се сравнява с праговете 0.1 и 0.25. В таблото двата изчисления дават същото, защото цялото зареждане произвежда един-единствен скок; правилният фрагмент е онзи, който важи за всяка страница. На твоя компютър, без да ограничаваш мрежата, таблото дава LCP от около 20 до 60 ms и CLS почти 0: толкова бързо, че няма какво да се подобрява. Но това е самозаблуда: твоят компютър и локалната ти мрежа не са тези на онзи, който ще отвори таблото от телефон.
Бавен профил. В инструментите на браузъра разделът Мрежа (в Chrome и във Firefox) позволява да избереш профил на бавна връзка. За този урок беше използван фиксиран профил, за да могат числата да се повторят: 150 ms забавяне на заявка и 200 KB/s изтегляне, без кеш, с прозорец на 1280 и на 320 пиксела ширина, по три изпълнения на всеки. Това са симулирани числа в Chrome 154, получени автоматизирано; твоите ще са различни и онова, което се брои, е разликата между преди и след на твоята машина.
| Таблото от урок 10 | Таблото от този урок | |
|---|---|---|
| CLS на 1280 px | 0.077 | 0.001 |
| CLS на 320 px | 0.831 | 0.001 |
| LCP | от 420 до 440 ms | от 468 до 484 ms |
Заявката за data/services.json започва на |
около 790 ms | около 550 ms |
| Таблицата се появява на | около 950 ms | около 715 ms |
Две неща се промениха много и едно не се промени. CLS на 1280 px беше под прага 0.1, но на 320 px беше 0.831: повече от три пъти границата на „незадоволителното“, при ширината, която има най-голямо значение. А таблицата, която е онова, за което човекът е дошъл, се появява около 230 ms по-рано. Онова, което не се промени, е LCP, и си струва да се разбере защо: най-големият елемент, който браузърът изрисува, е заглавието „Revisor de servicios“, което е в HTML и се изрисува, преди да пристигне каквито и да са данни; ускоряването на данните не го мести (вариациите от десетки милисекунди са в границите на онова, което се променя от едно изпълнение на друго). Една метрика мери онова, което мери: LCP не знае кога се е появила таблицата ти и затова този урок мери и този момент. Да видим причините.
Каскадата. В раздела Мрежа всеки файл е лента и редът, в който започват лентите, разказва историята на зареждането. Браузърът изтегля index.html и едва тогава открива main.js. Изтегля js/main.js и едва тогава открива, че импортира js/load.js, js/state.js, js/view.js и js/form.js. Изтегля js/state.js и едва тогава открива, че импортира js/filters.js; изтегля js/view.js и открива js/stats.js. А заявката за данните не излиза, докато всичко това не се изпълни. Всяко „едва тогава“ е пътуване до мрежата и обратно; със забавяне 150 ms, четири нива правят половин секунда и човекът вижда „Cargando servicios…“ през цялото това време.
Решението е да кажеш на браузъра, от самото начало, кои модули ще му трябват, с rel="modulepreload" (MDN): в <head>, по един ред на модул, за да ги изтегли всичките паралелно още от първия момент. MDN го отбелязва като налично във всички браузъри от септември 2023 г. и предупреждава да не се зарежда предварително всичко, за да не се отнема честотна лента от онова, което е наистина спешно. С шест модула от по няколко килобайта няма риск.
Скокът. CLS излезе от измерване с API-то за нестабилност на оформлението, което освен това казва кои елементи са се преместили. В таблото от урок 10, на 320 px, се местеха <main> и <nav> (от вертикална позиция 134 до 160), редовете на обобщението и долният колонтитул. Три причини и трите са едно и също: нещо, което пристига късно и избутва онова, което вече беше там. Първата, „Última revisión“: заглавната част казва „todavía no“ на един ред, а когато пристигне датата, на 320 px заема два; всичко отдолу слиза с 26 px. Втората, числата на обобщението: празно <dd> няма височина и когато се запълни, редът му расте. Третата, колонтитулът: докато се зарежда, страницата е къса и колонтитулът е на показ; когато се появи таблицата, го избутва извън екрана и браузърът брои това като скок.
Решението се нарича резервиране на мястото: казваш на страницата колко ще заеме онова, което ще пристигне, преди да пристигне. Това е един последен блок в края на css/styles.css, а <p> на „Última revisión“ получава класа last-check, за да може да се сочи към него:
@layer components {
/* ---- Lección 11: reservar el hueco de lo que llega tarde ---- */
/* Las cifras del resumen se escriben cuando llegan los datos. Un <dd> vacío no tiene
altura; un espacio que no se ve le da su línea mientras tanto, del mismo alto que la cifra. */
.summary dd:empty::before {
content: "\00a0";
}
/* En una pantalla angosta, «Última revisión» y la fecha ocupan dos líneas cuando la
fecha llega; se reservan desde el principio (2 × 1.6rem). */
@media (width < 30rem) {
.last-check {
min-height: 3.2rem;
}
}
/* El pie de página no tiene por qué verse mientras los datos llegan: con el contenido
principal de al menos el alto de la ventana, la tabla que aparece ya no lo empuja
dentro de la pantalla. align-content: start impide que la rejilla reparta ese alto
de sobra entre sus renglones. */
.layout {
min-height: 100vh;
align-content: start;
}
}
Всяко правило има своя история на измерване. Това на обобщението беше изпробвано първо като min-height: 2rem на <dd>, което изглеждаше очевидното, и влоши нещата в компактните редове от урок 5: празно <dd> няма базова линия, а редът, подравнен по базата (align-items: baseline), се нагаждаше различно, отколкото с числото. Неразделящ интервал (\00a0), генериран с ::before, има базова линия и <dd> е еднакво високо празно и пълно. Генерира се само докато <dd> е празно (:empty), а един интервал не се чете на глас. Правилото за колонтитула беше изпробвано първо върху main и скокът се влоши: излишната височина се разпределяше между редовете на решетката и местеше секциите; align-content: start оставя всичко отгоре. Това е същата идея, с която урок 10 резервира реда на всяка грешка във формата, и предупреждението на web.dev в ръководството му за CLS важи тук: да резервираш място е оценка. Ако текстът се окаже по-дълъг, ще скочи малко; ако по-къс, ще остане дупка. Решението е на дизайна: малка дупка е по-добре от скок.
Какво не беше направено и защо. Нищо не беше минифицирано: таблото би тежало около четиринайсет килобайта в мрежата със сървър, който компресира. Не беше използвано loading="lazy": няма изображения; а когато има, основното изображение на страницата никога не бива да го носи, защото забавя LCP. Не бяха използвани нито async, нито defer в <script>: модулите (type="module") се отлагат сами. Всяка от тези рецепти е добра на мястото си и всяка би била шум тук. Да не оптимизираш онова, което вече е добро, е част от дисциплината.
Панелът на Lighthouse. За да приключим, одиторът. В Chrome: инструментите за разработчици, разделът Lighthouse, „Анализирай зареждането на страницата“. Документацията на Chrome изброява днес пет групи проверки: производителност, достъпност, добри практики, SEO и една нова, „навигация от агенти“ (agentic browsing), която мери колко лесно е за автоматизирана програма да разбере и използва страницата. Тази пета група влезе в обичайната конфигурация във версия 13.3.0, от май 2026 г. (бележки за версията), а собствената ѝ страница за оценяване предупреждава, че е експериментална, че се основава на стандарти, които все още са предложени, и че не дава оценка от 0 до 100, а дроб: колко от приложимите ѝ проверки си преминал. Върху завършеното табло Lighthouse 13.5.0 я показва като 2/2: минават двете, които се прилагат (дървото за достъпност да е добре оформено и CLS), а другите пет излизат като „не се прилага“, защото проверяват части, които таблото няма (три за WebMCP, файл llms.txt и ai-catalog.json). Въпреки това тук не се взема предвид: следва правилото на курса да не се опираш на онова, което още не е основа. За този урок беше пуснат Lighthouse 13.5.0 от командния му ред (ти имаш същия двигател в панела на Chrome, без да инсталираш нищо) върху таблото, сервирано със заглавните му части, и даде 100 в четирите категории, които наистина се оценяват от 0 до 100 —производителност, достъпност, добри практики и SEO—, със симулиран LCP от 1.4 s, CLS от 0.001, общо време на блокиране 0 ms и 42 KiB общо. Тези 42 KiB не противоречат на четиринайсетте килобайта отгоре: сървърът на Python не компресира (с curl -sI -H "Accept-Encoding: gzip, br" не се появява никакъв Content-Encoding), така че Lighthouse преброи 37.9 KB некомпресирани плюс заглавните части на всеки отговор. Измерено в Chrome върху същия сървър, пренесеното прави 42,709 байта, 41.7 KiB. Онова, което Lighthouse все още посочи, дори със 100, беше „Network dependency tree“ (дърво на мрежовите зависимости): веригата от заявки, която виждаш в каскадата. 100 и едно предупреждение съжителстват спокойно; предупреждението е информация, а 100 не е цел.
11.4 Да публикуваме статичен сайт
Статичният сайт е папка с файлове, която един сървър предава такава, каквато е, без да изпълнява нищо от твоя страна. Това е revisor: HTML, CSS, JavaScript, който работи в браузъра, и един JSON. „Да публикуваш“ е да копираш тази папка в услуга, която я сервира по интернет с адрес и, за предпочитане, с твоите заглавни части. Няма стъпка на изграждане, защото таблото никога не се е нуждаело от такава.
Какво се публикува. Съдържанието на revisor/ (не папката, която го съдържа): index.html трябва да остане в корена на сайта. Включва _headers. Преди да го качиш, три проверки:
- Нищо тайно. Сайтът ще е публичен; всеки файл, който качиш, може да го прочете онзи, който знае адреса. В таблото няма ключове и правилото е никога да няма.
- Файловете за тестове.
data/services-empty.jsonи таблицатаCASESвjs/main.jsсъществуват, за да се виждат състоянията на таблото. Не вредят (таблицата е затворен списък, който никога не използва текста от адреса като адрес на заявката, както беше обяснено в урок 9), но реши дали искаш публикувано табло с този режим за тестове или без него. - Данните са примерни.
data/services.json, който публикуваш, е този, който всеки ще види. Табло, което показва услуги, които не съществуват, е допустимо за упражнение; кажи го в самия сайт, ако го споделяш.
Къде. Има няколко безплатни варианта. Това са тези, които бяха прегледани за урока, с онова, което собствените им страници казват, по ред на предпочитание: първите две изпращат твоите заглавни части; GitHub Pages е последна, защото не го прави, и веднага ще видиш какво губиш заради това.
- Cloudflare Pages. Позволява да качиш папка, като я завлечеш в контролния панел („Директно качване“: в секцията Workers и Pages, „Create application“, „Get started“, „Drag and drop your files“; приема папка или zip) и оставя сайта на
nombre-del-proyecto.pages.dev(ръководство). Чете_headers(документация): до 100 правила, 2,000 знака на ред. Прегледаната документация не казва изрично дали_headersсе спазва при директно качване; провери го сcurl, както по-долу. - Netlify. „Netlify Drop“ (ръководство) позволява да завлечеш вече изградена папка и да я публикуваш без акаунт и без Git; анонимният сайт е временен и трябва да го присвоиш в първия час. Също чете
_headers(документация). - GitHub Pages, последният вариант. Публикува от хранилище в GitHub и само от две места на един клон: корена му (
/) или папка на име/docs(документация); другият път е автоматизиран поток на GitHub Actions, който този курс не използва. Затова съдържанието наrevisor/трябва да остане в корена на хранилището, не вътре в папкаrevisor/. С безплатния план хранилището трябва да е публично (документация); промените може да отнемат до десет минути, докато се видят; а ограниченията му включват сайт най-много от 1 GB, мек лимит от 100 GB трансфер на месец и забраната да се използва като безплатен хостинг на бизнес. Както се каза в 11.2, няма документиран начин да се изпращат собствени заглавни части; освен това GitHub Pages обработва сайта с Jekyll, който по подразбиране не публикува файловете, чието име започва с_, така че_headersдори не стига. Ако избереш GitHub Pages, CSP я слагаш ти: добави в<head>наindex.htmlтага<meta>от 11.2, преди да го качиш.
Защо GitHub Pages е последна: по-слаба CSP. Помисли за разликата между правило, което сървърът обявява преди да предаде страницата, и бележка, написана вътре в страницата. Заглавната част пристига първа и браузърът я прилага към всичко; тагът <meta> се чете само когато браузърът вече чете HTML. Затова спецификацията на CSP оставя извън тага три директиви: frame-ancestors, sandbox и report-uri, освен целия режим само-доклад. За таблото загубата, която има значение, е първата: frame-ancestors 'none' е онова, което не позволява друг сайт да постави страницата ти в рамка (<iframe>) и да я маскира под фалшиви бутони, за да щракне някой, без да знае върху какво (MDN казва изрично, че не работи в <meta>). И CSP не е единственото, което пътуваше в _headers: X-Content-Type-Options: nosniff няма версия в таг, така че също се губи; Referrer-Policy има, <meta name="referrer" content="no-referrer"> (MDN), ако искаш да я запазиш. За упражнение с примерни данни рискът е малък и публикуването в GitHub Pages е допустимо; за истински сайт избери услуга, която изпраща заглавни части.
Нито една от трите не се нуждае от Node или от инсталиране на каквото и да е, и трите вършат работа за това упражнение; онова, което не е второстепенно, е следното.
Публикуване в GitHub Pages без Git в терминала. В урок 1 запазваше записи с Git на компютъра си, но курсът никога не те е учил да ги качваш в услуга като GitHub, а за да публикуваш, не е нужно: GitHub позволява да се качват файлове от браузъра. Ако избереш този вариант, са пет стъпки, всички взети от документацията на GitHub:
- Акаунт. Ако нямаш, създай безплатен на
github.com. Потребителското ти име ще се появи в адреса на сайта. - Публично хранилище. Горе вдясно във всяка страница на GitHub, бутонът + и после New repository; дай му име (например
revisor), избери видимост Public и натисни Create repository (документация). - Файловете. Преди това добави в твоя
index.htmlтага<meta>на CSP (11.2). После, в страницата на хранилището, Add file и после Upload files и завлечи в прозореца на браузъра онова, което има вътре в папката тиrevisor/—index.html,favicon.svgи папкитеcss,jsиdata—, не самата папкаrevisor:index.htmlтрябва да остане в корена. Напиши съобщение, както би направил сgit commit, и потвърди (документация; браузърът допуска до 100 файла наведнъж и 25 MiB на файл, предостатъчно за таблото). - Включване на Pages. В хранилището, Settings, после Pages в страничната лента; в „Build and deployment“, в Source, избери Deploy from a branch, а в клона избери
mainи папката/ (root); запази (документация). - Адресът. Сайт от хранилище остава на
https://<tu-usuario>.github.io/<repositorio>/(документация), напримерhttps://ana.github.io/revisor/. Обърни внимание, че таблото живее в подпапка на домейна,/revisor/. Работи, защото всички пътища на таблото са относителни (css/styles.css,data/services.json); път, който започва с/, като/css/styles.css, би търсил в корена на домейна и би дал 404.
Ако вече умееш да използваш git push, също върши работа и резултатът е същият; този курс не го преподава, защото за да публикуваш една папка, не ти трябва.
Проверка на публикуваното. Обещанието на една услуга не е доказателство. С вече публикуван адрес (тук https://tu-sitio.example), три проверки в терминал:
curl -sI https://tu-sitio.example/ | grep -i -E "content-security|nosniff|referrer"
curl -sI -H "Accept-Encoding: gzip, br" https://tu-sitio.example/js/main.js | grep -i content-encoding
curl -s -o /dev/null -w "%{http_code}\n" https://tu-sitio.example/data/services.json
Първата трябва да покаже трите ти заглавни части; ако не излезе нито една, услугата не е прочела _headers и сайтът ти няма CSP, макар файлът да е там. В GitHub Pages няма да излезе нито една и това се очаква: там проверката е публикуваният index.html да носи тага <meta> (curl -s https://tu-sitio.example/ | grep -i content-security). Втората казва дали услугата компресира текстовите отговори (трябва да се появи gzip или br). Третата, 200. После отвори сайта в браузъра с отворена конзола и повтори петте критерия от следващия раздел, сега върху публичния адрес. Път, който работеше на компютъра ти, може да се счупи при публикуване: там се откриват абсолютните пътища, написани по погрешка, и имената, които се различават само по главни букви. В Linux Mint Main.js и main.js са два различни файла, както и за сървъра, така че тази грешка вече би я видял на компютъра си; но в Windows и в macOS, чиито дискове по подразбиране не различават главни от малки букви (Microsoft, Apple), един <script src="js/Main.js"> работи на компютъра и се чупи при публикуване. Ако друг човек работи по таблото от тези системи, там е причината.
И онова, което не се публикува. Сървърът на Python, с който работи (python3 -m http.server и headers-server.py от този урок), е инструмент за разработка, не за обслужване на публиката. Ако го стартираш без --bind 127.0.0.1, слуша на всички адреси на компютъра ти и всеки от мрежата ти може да чете папката, от която си го пуснал; документацията на Python освен това предупреждава, че модулът не е за производствена среда и че реализира само основни проверки за сигурност. Нашият слуша само на 127.0.0.1, който е адресът на твоята собствена машина.
11.5 Петте критерия, един по един
Сега вече, обещаното заключение. За всеки критерий има проверка, която можеш да повториш, и резултатът, получен при подготовката на урока, с таблото, сервирано от headers-server.py, и отворена конзола, в Chrome 154. Ако твоят резултат е различен, критерият не е изпълнен и курсът не свършва, докато не бъде.
1. Навигира се изцяло с клавиатурата. Проверка: обходът от 11.1, с фрагмента за спирките. Резултат: шестнайсет спирки в разумен ред, всички с tabindex 0, всички с видим контур на фокуса (плътен outline от 3 px) и никакъв капан: след последния бутон фокусът се връща в началото на страницата.
2. Няма нито една грешка в конзолата. Проверка: презареди таблото с отворена конзола и обходи целия урок 10 (филтрирай, подреждай, добавяй, греши). Резултат: никакво съобщение, нито грешка, нито предупреждение, при обичайния обход и при ?case=empty, ?case=invalid и ?case=timeout. Едно изключение, което трябва да познаваш: ?case=error иска файл, който не съществува, и браузърът сам отбелязва „Failed to load resource: … 404“. Това е заявката, която предизвика нарочно, а не недостатък на кода ти; критерият е за обичайния обход. Единственото, което го омърси по пътя, беше иконата data:, при активирането на CSP, и вече е поправено (виж 11.2).
3. Работи при ширина 320 px. Проверка: прозорецът на 320 px (режимът на устройство на инструментите) и редът от 11.1. Резултат: scrollWidth и clientWidth са 320 в петте ситуации: няма преливане.
И ако минеш стиловия лист през валидатора. В урок 3 възприе навика да минаваш своя styles.css през CSS валидатора на W3C, преди да приемеш един стилов лист за добър, и си струва да го запазиш. Със завършения стилов лист ще видиш нещо, което в урок 3 не излизаше: валидаторът отговаря с две грешки, „Property “container-type” doesn't exist“ и „Unrecognized at-rule “@container”“, освен двете обичайни предупреждения за променливите. Така отговори, когато му беше изпратен revisor/css/styles.css при подготовката на урока. Двете грешки идват от контейнерната заявка, която добави в урок 5, и не са грешки на стиловия ти лист: контейнерните заявки са част от спецификацията CSS Containment Module Level 3 и работят във всички браузъри, както видя в урок 5, но валидаторът още не ги разпознава. Практическото правило: прочети всяка грешка и реши; ако онова, което отбелязва, е container-type или @container, това е ограничение на валидатора и го оставяш. Всяка друга грешка се поправя, както в урок 3.
4. Показва трите състояния. Проверка: отвори тези адреси върху публикуваното или локалното табло.
| Адрес | Трябва да се види |
|---|---|
/ |
таблицата, „Mostrando 5 de 5 servicios.“ |
/?case=empty |
„No hay servicios que revisar.“, без таблица, с „Reintentar“ и с формата за добавяне на първата |
/?case=error |
„El servidor respondió con el código 404.“, с „Reintentar“ |
/?case=timeout |
„Cargando servicios…“ три секунди и после „El servidor no respondió en 3000 ms.“, с „Reintentar“. В публикувания сайт няма ?delay, така че там таблицата се зарежда нормално |
И за състоянието „зарежда се“, което на твоята машина трае милисекунди, разделът Мрежа с бавен профил или заявка, която никога не отговаря. Резултат: четирите адреса показват онова, което казва таблицата, и при заявка за данни, която не отговаря, „Cargando servicios…“ се появява веднага и на третата секунда известието казва „El servidor no respondió en 3000 ms.“ с „Reintentar“.
5. Текстът, който идва отвън, се чертае с textContent, никога с innerHTML. Проверка: потърси в кода си.
grep -n -E "innerHTML|outerHTML|insertAdjacentHTML|document\.write|eval\(" revisor/js/*.js
Резултат: нито един ред. И функционалната проверка от урок 7 продължава да важи: добави услуга на име <img src=x onerror=alert(1)>; появява се като текст в таблицата, такъв, какъвто е, и нищо повече не се случва. CSP от 11.2 е подкрепа на това, не негов заместител.
Пет проверки, пет резултата. Ако петте излязат както по-горе в твоето табло, курсът е свършил. А за критерий 1 има второ автоматично мнение: axe-core 4.14.0 върху петте ситуации на таблото не намери никакъв пропуск.
Това е завършеният index.html:
<!-- revisor/index.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Revisor de servicios</title>
<meta name="description" content="Panel que muestra el estado y el tiempo de respuesta de una lista de servicios.">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="css/styles.css">
<link rel="modulepreload" href="js/state.js">
<link rel="modulepreload" href="js/filters.js">
<link rel="modulepreload" href="js/view.js">
<link rel="modulepreload" href="js/stats.js">
<link rel="modulepreload" href="js/load.js">
<link rel="modulepreload" href="js/form.js">
<script type="module" src="js/main.js"></script>
</head>
<body>
<header class="page-header">
<h1>Revisor de servicios</h1>
<p class="last-check">Última revisión: <span id="checked-at">todavía no</span></p>
<nav>
<p>Ir a: <a href="#summary">Resumen</a> o <a href="#services">Servicios</a></p>
</nav>
</header>
<main class="layout">
<section id="summary">
<h2>Resumen</h2>
<dl class="summary">
<div>
<dt>Servicios revisados</dt>
<dd id="summary-total"></dd>
</div>
<div>
<dt>Disponibles</dt>
<dd id="summary-available"></dd>
</div>
<div>
<dt>Caídos</dt>
<dd id="summary-down"></dd>
</div>
<div>
<dt>Respuesta promedio</dt>
<dd id="summary-average"></dd>
</div>
</dl>
</section>
<section id="services" class="layout-tall">
<h2>Servicios</h2>
<!-- Las dos zonas de aviso existen desde el principio: así los lectores de pantalla
las vigilan antes de que aparezca el primer mensaje. -->
<p id="notice" class="notice" role="status">Cargando servicios…</p>
<p id="error-notice" class="notice notice-error" role="alert"></p>
<button type="button" id="retry" hidden>Reintentar</button>
<div id="data-zone" hidden>
<div class="controls">
<p class="field">
<label for="search">Buscar servicio</label>
<input type="search" id="search" name="search">
</p>
<fieldset id="status-filter">
<legend>Mostrar</legend>
<label><input type="radio" name="filter" value="all" checked> Todos</label>
<label><input type="radio" name="filter" value="available"> Disponibles</label>
<label><input type="radio" name="filter" value="down"> Caídos</label>
</fieldset>
<p><button type="button" id="check-now">Revisar ahora</button></p>
<p><button type="button" id="sort" aria-pressed="false">Ordenar por tiempo de respuesta</button></p>
</div>
<p id="count" class="notice" role="status"></p>
<div class="table-scroll" id="table-zone" role="region" aria-labelledby="table-caption" tabindex="0">
<table>
<caption id="table-caption">Estado de los servicios en la última revisión</caption>
<thead>
<tr>
<th scope="col">Servicio</th>
<th scope="col">Estado</th>
<th scope="col" class="number">Tiempo de respuesta</th>
<th scope="col">Acción</th>
</tr>
</thead>
<tbody id="services-body"></tbody>
</table>
</div>
<p id="detail" role="status"></p>
</div>
</section>
<section id="add-zone" aria-labelledby="add-title" hidden>
<h2 id="add-title">Agregar un servicio</h2>
<p>Los campos marcados con * son obligatorios.</p>
<form id="add-form" class="form-grid" autocomplete="off">
<div class="field">
<label for="new-name">Nombre *</label>
<input id="new-name" name="name" type="text" required minlength="2"
pattern="\S(.*\S)?" aria-describedby="new-name-hint new-name-error">
<p id="new-name-hint" class="hint">Al menos 2 caracteres. No puede repetirse.</p>
<p id="new-name-error" class="field-error"></p>
</div>
<div class="field">
<label for="new-status">Estado *</label>
<select id="new-status" name="status" required aria-describedby="new-status-error">
<option value="">Elige un estado</option>
<option value="available">Disponible</option>
<option value="down">Caído</option>
</select>
<p id="new-status-error" class="field-error"></p>
</div>
<div class="field">
<label for="new-response-ms">Tiempo de respuesta (ms) *</label>
<input id="new-response-ms" name="responseMs" type="number" required min="0" max="60000" step="1"
aria-describedby="new-response-ms-hint new-response-ms-error">
<p id="new-response-ms-hint" class="hint">De 0 a 60 000. Si el servicio está caído, no se pide.</p>
<p id="new-response-ms-error" class="field-error"></p>
</div>
<button type="submit">Agregar servicio</button>
<p id="form-result" class="notice" role="status"></p>
</form>
</section>
</main>
<footer>
<p>Datos de ejemplo del archivo data/services.json.</p>
</footer>
</body>
</html>
Спрямо урок 10 се промениха <head> —описанието на страницата, собствената икона и шестте реда на modulepreload— и един клас в заглавната част, last-check, за да се резервира мястото ѝ. Останалото от index.html, седемте модула и данните са идентични с тези от урок 10, освен първия ред, който в хранилището казва къде живее всеки файл; css/styles.css е този от урок 10 плюс блока от 11.3.
Грешката, която ще видиш
Грешката на политиката, която блокира вградения скрипт и която вече видя във фигура 11.1. Това е най-характерното съобщение на една CSP и се чете в три такта:
Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-…') is required to enable inline execution. The action has been blocked.
Какво означава: страницата се е опитала да изпълни скрипт, написан вътре в HTML, а политиката (script-src 'self') допуска само скриптове, които са файлове на същия сайт. Как се поправя: премести кода във файл .js и го зареди с <script src="…"> или, още по-добре, с <script type="module" src="…">. Другите два изхода, които съобщението назовава —отпечатък или число за еднократна употреба— съществуват за специални случаи, а да отвориш всичко с 'unsafe-inline' разваля защитата, така че не е изход.
Един вариант, който се появява, когато вграденият код е атрибут (onclick="…" или onerror="…"): съобщението казва „Executing inline event handler violates…“ и добавя бележка: отпечатъците не вършат работа с обработчиците на събития. Правилният изход е същият, addEventListener във файл, и е онзи, който таблото използва.
И още един, с различна причина: ако липсваща директива пада в default-src 'none', съобщението го казва: „Note that 'connect-src' was not explicitly set, so 'default-src' is used as a fallback“. Предизвиква се в упражнение 2.
Какво се прави погрешно
- Да копираш CSP от интернет, без да я прочетеш. Как изглежда: ред от двеста знака с дузина домейни, които сайтът ти никога не използва. Цена: всеки позволен домейн е място, от което може да се зареди код в страницата ти. Една политика е списък на онова, от което сайтът ти се нуждае, не на онова, което някой друг е използвал.
'unsafe-inline', за да „спре да дава грешки“. Как изглежда:script-src 'self' 'unsafe-inline'. Цена: отваря точно онова, което политиката затваря. Без тази защита заглавната част остава за украса.- Да вярваш, че CSP оправя XSS. Как изглежда: „вече имам CSP, значи мога да използвам
innerHTML“. Цена: фигура 11.2 го показва: инжектираният HTML остава в страницата.textContentе защитата; CSP, втората преграда. - Публикуван сайт, без да е проверено, че заглавните части са пристигнали. Как изглежда: файлът
_headersе в папката и никой не е направилcurl -I. Цена: услугата може да не го прочете (или да го чете само в друг режим на качване); имаш политика, която съществува на компютъра ти, а не в интернет. - Да оптимизираш, без да измерваш. Как изглежда: минифициране, пакетиране и отлагане на неща, защото „така трябва“. Цена: сложност, която не си купил с никакво число. Първо измери, промени едно нещо, измери отново.
- Да измерваш само на компютъра си. Как изглежда: „зарежда се за 70 милисекунди“. Цена: това е мярка на най-бързата машина и най-късата мрежа, които съществуват. Ограничи мрежата в инструментите и погледни каскадата.
loading="lazy"върху основното изображение. Как изглежда: рецепта за производителност, приложена към всички изображения. Цена: според web.dev влошава LCP, защото най-важното изображение се иска късно. Само за онези, които са извън екрана.- Да сервираш на публиката с
python3 -m http.serverили без--bind 127.0.0.1. Как изглежда: да стартираш сървъра за разработка, за да видят другите страницата. Цена: без--bindвсеки в мрежата ти чете папката, от която си го стартирал; и в никакъв случай не е сървър, направен за публика. - Да качиш файл с ключове в публичен сайт. Как изглежда:
.env, ключ вservices.json. Цена: цялата папка е четима от всеки с адреса; а публикуван таен ключ трябва да се счита за изгубен, дори да го изтриеш.
Упражнения
Упражнение 1 — Да прочетеш политика
Без да изпълняваш нищо, кажи какво прави всяка от тези две политики с таблото и коя от двете му чупи нещо: (а) default-src 'self'; (б) default-src 'self'; script-src 'self' 'unsafe-inline'. После сложи (а) в _headers на таблото и го отвори: променя ли се нещо спрямо политиката на таблото?
Упражнение 2 — Да счупиш нещо нарочно
В копие на папката revisor/ махни connect-src 'self'; от _headers, стартирай headers-server.py върху копието и отвори таблото. Запиши какво вижда човекът на екрана и какво казва конзолата. После поправи политиката и провери отново.
Упражнение 3 — Измери ти
С таблото, сервирано от твоя компютър, и бавен мрежови профил в инструментите на браузъра, измери с фрагмента от 11.3 CLS и LCP на таблото без шестте реда на modulepreload и с тях, по три пъти. Запиши в дневника шестте си числа и едно изречение: помогна ли? Колко? Ако не помогна, напиши защо мислиш, че не.
Решения
Упражнение 1. (а) Позволява да се зарежда всичко, но само от същия сайт, и тъй като default-src е резервната стойност, покрива скриптове, стилове, изображения и връзки. Не блокира нищо от онова, което таблото прави: всичко идва от един и същ произход. Променя се само в онова, което не покрива: не включва нито form-action, нито base-uri, нито frame-ancestors, които не падат в default-src; тоест е по-малко строга от тази на таблото; с нея таблото се вижда и работи същото. (б) Тръгва от същото, но с 'unsafe-inline' в script-src допуска скриптове, написани вътре в HTML. Не чупи нищо на таблото и е най-лошата от двете: затваря много по-малко. Урокът: политика, която „не чупи нищо“, не е заради това добра; трябва да се гледа онова, което оставя отворено.
Упражнение 2. Без connect-src резервното правило default-src 'none' блокира fetch. Човекът вижда „No se pudo conectar con el servidor.“ с бутона „Reintentar“ (таблото третира повредата на мрежата като всяка друга, и това е добра причина js/load.js да превръща грешките в съобщения). Конзолата казва, в Chrome 154: „Connecting to 'http://127.0.0.1:…/data/services.json' violates the following Content Security Policy directive: "default-src 'none'". Note that 'connect-src' was not explicitly set, so 'default-src' is used as a fallback. The action has been blocked.“, последвано от „Fetch API cannot load … Refused to connect because it violates the document's Content Security Policy“. Поправя се, като върнеш connect-src 'self';. Онова, което се научава: таблото деградира елегантно, но причината е в конзолата, не на екрана.
Упражнение 3. Числата зависят от твоята машина; онова, което трябва да излезе, е формата: с modulepreload заявката за data/services.json започва по-рано и таблицата се появява по-рано; LCP, който е заглавието, почти не се мести. Ако не виждаш разлика, не е грешка на упражнението: с бърза локална мрежа и без ограничение каскадата е толкова къса, че не се забелязва. Ограничи мрежата и опитай отново. Ако CLS вече беше 0 и в двата случая, това също е резултат: скокът, който поправихме, се появява, когато обобщението се бави да пристигне, така че може да не се получи с бърза мрежа.
Как разбирам, че съм успял
-
curl -sI http://127.0.0.1:8000/ | grep -i content-security, със стартиранheaders-server.py revisor 8000, отпечатва политиката от раздел 11.2. - Фигура 11.1 (
fig11_01.html) показва „El script externo sí se ejecutó.“ (Външният скрипт се изпълни.) и конзолата носи съобщението „Executing inline script violates…“. - Фигура 11.2 показва „Imágenes inyectadas: 1. Título de la página: La política no arregla el innerHTML“ (Инжектирани изображения: 1. Заглавие на страницата: Политиката не поправя innerHTML) и конзолата носи „Executing inline event handler violates…“.
- С таблото от
revisor/, сервирано със заглавните му части, конзолата е празна при обичайния обход. - Петте критерия от 11.5 излизат както са описани, всеки със своята проверка: шестнайсет спирки на клавиатурата, празна конзола, 320 = 320, четирите адреса с известието си и нула съвпадения в
grep. - Имаш своя таблица с теглото на таблото (
wc -cиgzip -9 -c … | wc -c) и, поне, LCP и CLS, измерени с фрагмента от конзолата. - Таблото е публикувано на адрес, който не е
127.0.0.1, иcurl -sIвърху този адрес показва трите заглавни части (в GitHub Pages, вместо това, публикуваниятindex.htmlноси тага<meta>на CSP). Ако не си могъл да го публикуваш, запиши в дневника какво те е спряло.
Преговор на предишни уроци (отговори, без да гледаш, и после провери):
- В урок 1: защо един модул на JavaScript не се зарежда, ако отвориш файла с
file://, и какво направи, за да го избегнеш? - В урок 3: защо
* { box-sizing: border-box }прави ширина от 300 px да е 300 px, макар да има вътрешен отстъп? - В урок 7: защо
textContentне изпълнява<img onerror=…>? - В урок 10: каква е разликата между
:invalidи:user-invalid?
Ако нещо ти е убягнало, запиши го в дневника: курсът свърши, но този списък е началото на онова, което следва.
И онова, което следва. Таблото, което изгради, се прави наново с типове и с React в нашия курс по TypeScript. Сравни двете версии: от сравнението се учи повече, отколкото от започването на друг проект.
За допълнително четене
- MDN — Content Security Policy (CSP): пълното ръководство, с директивите, режима само-доклад и строгата политика с
nonce. - web.dev — Core Web Vitals: трите метрики, праговете им и разликата между лаборатория и поле.
- Документация на Cloudflare Pages — Персонализирани заглавни части: файлът
_headersсъс синтаксиса и ограниченията му. - W3C — Разбиране на WCAG 2.2: всеки критерий за достъпност, обяснен, с техниките си и типичните си пропуски.
Предпочитате имейл? Пишете ни на hola@habil.mx