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.
DateKeys-App/src/routes/inspect/+page.svelte

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>

Powered by TurnKey Linux.