You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
407 lines
12 KiB
407 lines
12 KiB
<script lang="ts">
|
|
import { onMount, tick } from 'svelte';
|
|
import { type CapsuleBytes, inspect, readCapsule } from '$lib/dkc/index.ts';
|
|
import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts';
|
|
import { buildReport, type Report } from '$lib/inspector/report.ts';
|
|
import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts';
|
|
import { browserPlatform, removeStaleTempFiles } from '$lib/inspector/tempfile.ts';
|
|
import InspectionReport from '$lib/components/InspectionReport.svelte';
|
|
import OpenPanel from '$lib/components/OpenPanel.svelte';
|
|
|
|
/** What was inspected, for the opening: the fixture bytes or the person's file. */
|
|
interface Capsule {
|
|
readonly data: Uint8Array | Blob;
|
|
readonly fixture: Fixture | undefined;
|
|
}
|
|
|
|
let report: Report | undefined = $state();
|
|
let capsule: Capsule | undefined = $state.raw();
|
|
let problem: string | undefined = $state();
|
|
let notice: string | undefined = $state();
|
|
let busy = $state(false);
|
|
let dragging = $state(false);
|
|
let announcement = $state('');
|
|
let nowMs = $state(0);
|
|
let timeZone: string | undefined = $state();
|
|
|
|
// Only the latest load may show its result.
|
|
let loadId = 0;
|
|
// dragenter and dragleave fire for every element crossed.
|
|
let dragDepth = 0;
|
|
|
|
// A capsule inspected before its date can be opened once the date comes:
|
|
// the clock of the report is refreshed then, and whenever the tab becomes
|
|
// visible again (timers stall in hidden tabs and while the device sleeps).
|
|
// setTimeout fires at once past 2^31 - 1 ms, so a far date re-arms in steps.
|
|
// open checks the live clock again at step 9.
|
|
$effect(() => {
|
|
const at = report?.unlock?.epochMs;
|
|
if (at === undefined || at <= nowMs) return;
|
|
const refresh = (): void => {
|
|
nowMs = Date.now();
|
|
};
|
|
const timer = setTimeout(refresh, Math.min(Math.max(0, at - Date.now()) + 50, 0x7fffffff));
|
|
const onVisible = (): void => {
|
|
if (document.visibilityState === 'visible') refresh();
|
|
};
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
};
|
|
});
|
|
|
|
// Plaintext left in the private storage of the browser by a page that
|
|
// ended before deleting it (tempfile.ts) is deleted on the next visit.
|
|
onMount(() => {
|
|
const platform = browserPlatform();
|
|
if (platform !== undefined) void removeStaleTempFiles(platform).catch(() => undefined);
|
|
});
|
|
|
|
async function run(name: string, read: () => Promise<CapsuleBytes & { capsule: Capsule }>, readNotice?: string): Promise<void> {
|
|
const id = ++loadId;
|
|
const shown = displayText(name).text;
|
|
busy = true;
|
|
problem = undefined;
|
|
notice = readNotice;
|
|
announcement = `Inspeccionando ${shown}.`;
|
|
try {
|
|
const { bytes, size, capsule: c } = await read();
|
|
// The base protocol V1: no extension registry, so every extension is
|
|
// unknown to this reader, as in `datekeys inspect`.
|
|
const inspection = await inspect(bytes);
|
|
if (id !== loadId) return;
|
|
report = buildReport({ fileName: name, bytes, size, inspection });
|
|
capsule = c;
|
|
nowMs = Date.now();
|
|
timeZone = viewerTimeZone();
|
|
const verdict = report.failure
|
|
? `${shown}: rechazada en el paso ${report.failure.step}, ${report.failure.code}.`
|
|
: `${shown}: estructura válida en los pasos 1 a 8.`;
|
|
// The notice (only the first dropped file is inspected) is a plain
|
|
// paragraph that focus skips, so the status message carries it too.
|
|
announcement = readNotice ? `${readNotice} ${verdict}` : verdict;
|
|
await tick();
|
|
document.getElementById('report-title')?.focus();
|
|
} catch (err) {
|
|
if (id !== loadId) return;
|
|
report = undefined;
|
|
capsule = undefined;
|
|
problem = describeProblem(shown, err);
|
|
announcement = readNotice ? `${readNotice} ${problem}` : problem;
|
|
} finally {
|
|
if (id === loadId) busy = false;
|
|
}
|
|
}
|
|
|
|
function describeProblem(name: string, err: unknown): string {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (/Web Crypto/.test(message)) {
|
|
return 'Este navegador solo ofrece Web Crypto (crypto.subtle) en contextos seguros, y el inspector lo necesita para comprobar el perfil fijado. Abre la página por https o en localhost.';
|
|
}
|
|
return `No se pudo leer ${name}: ${escapeInvisible(message)}`;
|
|
}
|
|
|
|
function inspectFile(file: File, readNotice?: string): void {
|
|
void run(file.name, async () => ({ ...(await readCapsule(file)), capsule: { data: file, fixture: undefined } }), readNotice);
|
|
}
|
|
|
|
function inspectFixture(f: Fixture): void {
|
|
void run(f.name, async () => {
|
|
const bytes = await fetchFixture(f);
|
|
return { bytes, size: bytes.length, capsule: { data: bytes, fixture: f } };
|
|
});
|
|
}
|
|
|
|
function onPick(event: Event & { currentTarget: HTMLInputElement }): void {
|
|
const input = event.currentTarget;
|
|
const file = input.files?.[0];
|
|
if (file !== undefined) inspectFile(file);
|
|
// Choosing the same file again must inspect it again.
|
|
input.value = '';
|
|
}
|
|
|
|
function carriesFiles(e: DragEvent): boolean {
|
|
return e.dataTransfer?.types.includes('Files') ?? false;
|
|
}
|
|
|
|
function onDragEnter(e: DragEvent): void {
|
|
if (!carriesFiles(e)) return;
|
|
e.preventDefault();
|
|
dragDepth++;
|
|
dragging = true;
|
|
}
|
|
|
|
function onDragOver(e: DragEvent): void {
|
|
if (!carriesFiles(e)) return;
|
|
e.preventDefault();
|
|
e.dataTransfer!.dropEffect = 'copy';
|
|
}
|
|
|
|
function onDragLeave(e: DragEvent): void {
|
|
if (!carriesFiles(e)) return;
|
|
dragDepth = Math.max(0, dragDepth - 1);
|
|
if (dragDepth === 0) dragging = false;
|
|
}
|
|
|
|
function onDrop(e: DragEvent): void {
|
|
if (!carriesFiles(e)) return;
|
|
// Without this, the browser would navigate to the dropped file.
|
|
e.preventDefault();
|
|
dragDepth = 0;
|
|
dragging = false;
|
|
const files = e.dataTransfer!.files;
|
|
const file = files[0];
|
|
if (file === undefined) return;
|
|
inspectFile(
|
|
file,
|
|
files.length > 1 ? `Has soltado ${files.length} ficheros; se inspecciona solo el primero, ${displayText(file.name).text}.` : undefined,
|
|
);
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Inspector de cápsulas DateKeys</title>
|
|
<meta
|
|
name="description"
|
|
content="Carga un fichero .dkc, comprueba los pasos 1 a 8 de DateKeys y, pasada su fecha, ábrelo con la firma de la ronda, todo en este navegador y sin red."
|
|
/>
|
|
</svelte:head>
|
|
|
|
<svelte:window ondragenter={onDragEnter} ondragover={onDragOver} ondragleave={onDragLeave} ondrop={onDrop} />
|
|
|
|
<div class="wrap page">
|
|
<div class="intro">
|
|
<h1>Inspector de cápsulas</h1>
|
|
<p class="lead">
|
|
Comprueba la parte pública de un fichero <code>.dkc</code>: los pasos 1 a 8 de la especificación (§63), con el mismo
|
|
resultado que <code>datekeys inspect</code>. Pasada su fecha de apertura, ábrelo con la firma de la ronda que
|
|
publica drand (pasos 9 a 18). El fichero no sale de este navegador y la página no se conecta a ningún otro sitio.
|
|
</p>
|
|
</div>
|
|
|
|
<section class="loader" aria-labelledby="loader-title">
|
|
<h2 id="loader-title" class="visually-hidden">Elegir una cápsula</h2>
|
|
<label class={['drop', dragging && 'dragging']}>
|
|
<input class="visually-hidden" type="file" accept=".dkc" onchange={onPick} />
|
|
<svg class="drop-icon" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
|
<rect x="9" y="5" width="30" height="38" rx="2" />
|
|
<circle cx="24" cy="24" r="7" />
|
|
<circle class="seal" cx="24" cy="24" r="2.2" />
|
|
</svg>
|
|
<span class="drop-title">Elige un fichero .dkc</span>
|
|
<span class="drop-hint">o suéltalo en cualquier parte de la página</span>
|
|
</label>
|
|
|
|
<div class="fixtures">
|
|
<h3 id="fixtures-title">Cápsulas de prueba oficiales</h3>
|
|
<p class="muted">
|
|
Cápsulas de ejemplo de la implementación de referencia en Go, incluidas en el sitio (descripciones en inglés).
|
|
</p>
|
|
<ul aria-labelledby="fixtures-title">
|
|
{#each FIXTURES as f (f.name)}
|
|
<li>
|
|
<button type="button" class="fixture" onclick={() => inspectFixture(f)}>
|
|
<span class="fixture-name">{f.name}</span>
|
|
{#if f.description}
|
|
<span class="fixture-desc" lang="en">{f.description}</span>
|
|
{/if}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<p class="visually-hidden" role="status">{announcement}</p>
|
|
|
|
{#if busy}
|
|
<p class="busy" aria-hidden="true">Inspeccionando…</p>
|
|
{/if}
|
|
{#if notice}
|
|
<p class="notice">{notice}</p>
|
|
{/if}
|
|
{#if problem}
|
|
<div class="problem">
|
|
<p>{problem}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if report}
|
|
{#key report}
|
|
{@const r = report}
|
|
{@const c = capsule}
|
|
<InspectionReport report={r} {nowMs} {timeZone}>
|
|
{#snippet opening()}
|
|
{#if r.valid && c !== undefined}
|
|
<OpenPanel report={r} capsule={c.data} fixture={c.fixture} {nowMs} />
|
|
{/if}
|
|
{/snippet}
|
|
</InspectionReport>
|
|
{/key}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if dragging}
|
|
<div class="drop-overlay" aria-hidden="true">
|
|
<p>Suelta el fichero para inspeccionarlo</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
/* minmax(0, 1fr): a child's unbreakable content must not widen the track
|
|
beyond the screen. */
|
|
.page {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: 2rem;
|
|
padding-top: clamp(2rem, 5vw, 3.5rem);
|
|
}
|
|
.intro .lead {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.loader {
|
|
display: grid;
|
|
gap: 1.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
@media (min-width: 900px) {
|
|
.loader {
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr);
|
|
align-items: stretch;
|
|
}
|
|
}
|
|
|
|
.drop {
|
|
display: grid;
|
|
place-items: center;
|
|
align-content: center;
|
|
gap: 0.35rem;
|
|
min-height: 13rem;
|
|
padding: 1.5rem;
|
|
border: 2px dashed var(--rule-strong);
|
|
border-radius: var(--radius);
|
|
background: var(--paper-2);
|
|
text-align: center;
|
|
cursor: pointer;
|
|
}
|
|
.drop:hover,
|
|
.drop.dragging {
|
|
border-color: var(--ink);
|
|
}
|
|
.drop:has(input:focus-visible) {
|
|
outline: 2px solid var(--ink);
|
|
outline-offset: 3px;
|
|
border-color: var(--ink);
|
|
}
|
|
.drop-icon {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
margin-bottom: 0.5rem;
|
|
fill: none;
|
|
stroke: var(--ink);
|
|
stroke-width: 2;
|
|
}
|
|
.drop-icon .seal {
|
|
fill: var(--orange);
|
|
stroke: none;
|
|
}
|
|
.drop-title {
|
|
font-size: var(--t-h3);
|
|
font-weight: 650;
|
|
text-decoration: underline;
|
|
text-decoration-thickness: 1px;
|
|
text-underline-offset: 0.2em;
|
|
}
|
|
.drop-hint {
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.fixtures h3 {
|
|
font-size: var(--t-body);
|
|
}
|
|
.fixtures > p {
|
|
margin-top: 0.25rem;
|
|
font-size: var(--t-small);
|
|
}
|
|
.fixtures ul {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin-top: 0.75rem;
|
|
border-top: 1px solid var(--rule);
|
|
}
|
|
.fixtures li {
|
|
border-bottom: 1px solid var(--rule);
|
|
}
|
|
.fixture {
|
|
display: grid;
|
|
gap: 0.1rem;
|
|
width: 100%;
|
|
padding: 0.65rem 0.5rem;
|
|
border: 0;
|
|
background: transparent;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
.fixture:hover {
|
|
background: var(--paper-2);
|
|
}
|
|
.fixture-name {
|
|
font-family: var(--mono);
|
|
font-size: 0.92rem;
|
|
font-weight: 600;
|
|
text-decoration: underline;
|
|
text-decoration-color: var(--rule-strong);
|
|
text-underline-offset: 0.2em;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.fixture:hover .fixture-name {
|
|
text-decoration-color: currentColor;
|
|
}
|
|
.fixture-desc {
|
|
font-size: var(--t-small);
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.busy,
|
|
.notice {
|
|
color: var(--ink-muted);
|
|
}
|
|
/* Both can quote a file name, which may have no break points. */
|
|
.notice,
|
|
.problem {
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.problem {
|
|
border-left: 4px solid var(--fail);
|
|
background: var(--fail-bg);
|
|
padding: 0.85rem 1rem;
|
|
border-radius: 0 var(--radius) var(--radius) 0;
|
|
max-width: var(--measure);
|
|
}
|
|
|
|
.drop-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 50;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 1rem;
|
|
pointer-events: none;
|
|
background: color-mix(in srgb, var(--paper) 88%, transparent);
|
|
}
|
|
.drop-overlay::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 12px;
|
|
border: 3px dashed var(--ink);
|
|
border-radius: 8px;
|
|
}
|
|
.drop-overlay p {
|
|
font-size: var(--t-h2);
|
|
font-weight: 700;
|
|
text-align: center;
|
|
}
|
|
</style>
|