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.
920 lines
34 KiB
920 lines
34 KiB
<script lang="ts">
|
|
// The "abrir" action of the inspector (plan of phase 2, section 9): steps 9
|
|
// to 18 of spec §63 on a capsule that passed steps 1 to 8, with the release
|
|
// the person supplies directly, pasted from drand or taken from the record
|
|
// of an official fixture (decision 4: the page never fetches it), and the
|
|
// credentials that time_and_key asks for. The opening code, with noble and
|
|
// age-encryption, is imported on demand, so the page's first load does not
|
|
// carry it.
|
|
//
|
|
// The plaintext of a fixture is opened in memory. The plaintext of the
|
|
// person's own file goes to a private temporary file of the browser (OPFS,
|
|
// tempfile.ts), committed only after step 18 (spec §56), offered for
|
|
// download and deleted on request, when another capsule is opened or
|
|
// loaded, and when the page is left; in memory, up to MEMORY_LIMIT, when
|
|
// the browser has no such file or refuses it. Once the capsule opened, the
|
|
// start of the plaintext is shown when it is text (plaintextPreview). An
|
|
// opening in progress stops when the panel is destroyed, and its file is
|
|
// removed.
|
|
//
|
|
// The files of a format 3 capsule go to that same temporary file through a
|
|
// ZipSink: the file itself when there is one of one segment, and a ZIP
|
|
// otherwise, of which each file is a slice (design of format 3, section
|
|
// 5). The page shows the verdicts of the security area first, then the
|
|
// declared author and the comment, then each path as text with its
|
|
// warnings (spec §29.7), and offers the downloads.
|
|
import { onDestroy, tick } from 'svelte';
|
|
import { toHex } from '$lib/dkc/index.ts';
|
|
import type { Fixture } from '$lib/inspector/fixtures.ts';
|
|
import { errorGloss, escapeInvisible, formatByteCount, formatDateTime, formatInteger, unexpectedProblem } from '$lib/inspector/format.ts';
|
|
import type { OpenedFiles } from '$lib/inspector/opener.ts';
|
|
import { buildOpenReport, contentExtension, type OpenReport, plaintextFileName, plaintextPreview, SHOWN_TEXT } from '$lib/inspector/opening.ts';
|
|
import { fetchRelease } from '$lib/inspector/drand.ts';
|
|
import { drandReleaseURL, parseReleaseText, releaseText } from '$lib/inspector/release-input.ts';
|
|
import type { Report } from '$lib/inspector/report.ts';
|
|
import { browserPlatform, cancellable, createTempFile, freeSpace, type TempFile } from '$lib/inspector/tempfile.ts';
|
|
import ExtensionList from './ExtensionList.svelte';
|
|
import StepList from './StepList.svelte';
|
|
|
|
let {
|
|
report,
|
|
capsule,
|
|
fixture,
|
|
nowMs,
|
|
}: {
|
|
/** The inspection of the capsule: valid, with its round and profile. */
|
|
report: Report;
|
|
/** What was inspected: the bytes of a fixture or the person's file. */
|
|
capsule: Uint8Array | Blob;
|
|
/** The official fixture, when it is one. */
|
|
fixture: Fixture | undefined;
|
|
/** When the report was made. */
|
|
nowMs: number;
|
|
} = $props();
|
|
|
|
/** The largest plaintext opened in memory when the browser has no OPFS. */
|
|
const MEMORY_LIMIT = 64 << 20;
|
|
|
|
/** The files of a format 3 capsule that opened, and their downloads. */
|
|
interface FilesView {
|
|
readonly files: OpenedFiles;
|
|
/** The main download and the ZIP, as object URLs, for the person's own capsule. */
|
|
readonly primary?: { readonly url: string; readonly name: string; readonly size: number };
|
|
readonly zip?: { readonly url: string; readonly name: string; readonly size: number };
|
|
/** Whether each file has its own download. */
|
|
readonly each: boolean;
|
|
/** Whether the files are in the temporary file, or in memory. */
|
|
readonly temporary: boolean;
|
|
/** The start of the one file, when there is one and it is text. */
|
|
readonly shown?: { readonly text: string; readonly cut: boolean };
|
|
}
|
|
|
|
/** How many files the list shows; the ZIP holds them all. */
|
|
const LISTED = 500;
|
|
|
|
interface Result {
|
|
readonly report: OpenReport;
|
|
readonly ms: number;
|
|
readonly files?: FilesView;
|
|
/** The plaintext offered for download: a temporary file, or memory. */
|
|
readonly download?: { readonly url: string; readonly name: string; readonly size: number; readonly temporary: boolean };
|
|
}
|
|
|
|
// Read once: the panel is made again for each inspected capsule.
|
|
const initial = (): { round: number; release: string } => ({
|
|
round: report.capsule!.round,
|
|
release: fixture?.release === undefined ? '' : releaseText(fixture.release.round, toHex(fixture.release.signature)),
|
|
});
|
|
const { round, release: initialRelease } = initial();
|
|
|
|
/** The input a problem is about. */
|
|
type ProblemField = 'release' | 'identities' | 'accessKey';
|
|
const FIELD_IDS: Readonly<Record<ProblemField, string>> = {
|
|
release: 'release-input',
|
|
identities: 'ids-input',
|
|
accessKey: 'dkk-input',
|
|
};
|
|
|
|
let releaseInput = $state(initialRelease);
|
|
let identities = $state('');
|
|
let accessKey: File | undefined = $state();
|
|
let problem: string | undefined = $state();
|
|
let problemField: ProblemField | undefined = $state();
|
|
let busy = $state(false);
|
|
let result: Result | undefined = $state();
|
|
let deleted = $state(false);
|
|
let announcement = $state('');
|
|
|
|
const timeAndKey = $derived(report.capsule?.accessPolicy === 'time_and_key');
|
|
const due = $derived(report.unlock?.epochMs !== undefined && report.unlock.epochMs <= nowMs);
|
|
const drandURL = $derived(drandReleaseURL(report.profile!.chainHash, round));
|
|
|
|
// The release from the relays of drand, only when the person asks for it:
|
|
// the one connection the page makes to another site (drand.ts).
|
|
let asking = $state(false);
|
|
// The words of a key of words, as the person types them.
|
|
let words = $state('');
|
|
let askNote = $state('');
|
|
async function askDrand(): Promise<void> {
|
|
asking = true;
|
|
askNote = '';
|
|
try {
|
|
const r = await fetchRelease(report.profile!.chainHash, round);
|
|
releaseInput = releaseText(r.round, r.signature);
|
|
askNote = `Firma recibida de ${new URL(r.relay).host}. Se comprueba al abrir la cápsula.`;
|
|
} catch (err) {
|
|
askNote = `${err instanceof Error ? err.message : String(err)} Puedes pegarla a mano.`;
|
|
} finally {
|
|
asking = false;
|
|
}
|
|
}
|
|
const payloadLength = $derived(report.prelude?.payloadLength ?? 0);
|
|
|
|
// The temporary file and the object URLs of the last opening.
|
|
let temp: TempFile | undefined;
|
|
let objectURLs: string[] = [];
|
|
// The temporary file of the opening in progress, until it becomes `temp`
|
|
// or is removed: discard() removes it too, so a panel destroyed or a page
|
|
// left in the middle of an opening leaves nothing behind.
|
|
let pending: TempFile | undefined;
|
|
// Only the latest opening may show its result; destroying the panel
|
|
// invalidates the one in progress, which then stops writing.
|
|
let openId = 0;
|
|
|
|
async function discard(): Promise<void> {
|
|
for (const url of objectURLs) URL.revokeObjectURL(url);
|
|
objectURLs = [];
|
|
const files = [temp, pending];
|
|
temp = undefined;
|
|
pending = undefined;
|
|
await Promise.all(files.map((f) => f?.remove()));
|
|
}
|
|
|
|
onDestroy(() => {
|
|
openId++;
|
|
void discard();
|
|
});
|
|
|
|
// pagehide also fires when the page goes into the back/forward cache: if
|
|
// it comes back, it must not offer what was deleted here.
|
|
function leave(): void {
|
|
if (result?.download !== undefined || result?.files?.primary !== undefined) deleted = true;
|
|
void discard();
|
|
}
|
|
|
|
// An object URL of this opening, revoked by discard.
|
|
function objectURL(blob: Blob): string {
|
|
const url = URL.createObjectURL(blob);
|
|
objectURLs.push(url);
|
|
return url;
|
|
}
|
|
|
|
// Saves file i of a format 3 capsule on its own: an object URL of its
|
|
// slice, revoked a minute after the click.
|
|
function saveFile(files: OpenedFiles, i: number, name: string): void {
|
|
const url = URL.createObjectURL(files.file(i));
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name;
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
}
|
|
|
|
async function deleteNow(): Promise<void> {
|
|
await discard();
|
|
deleted = true;
|
|
announcement = 'Fichero temporal borrado.';
|
|
}
|
|
|
|
function onAccessKey(event: Event & { currentTarget: HTMLInputElement }): void {
|
|
accessKey = event.currentTarget.files?.[0];
|
|
}
|
|
|
|
async function submit(event: SubmitEvent): Promise<void> {
|
|
event.preventDefault();
|
|
if (busy) return;
|
|
problem = undefined;
|
|
problemField = undefined;
|
|
const parsed = parseReleaseText(releaseInput, round);
|
|
if (!parsed.ok) {
|
|
await fail(parsed.problem, 'release');
|
|
return;
|
|
}
|
|
const id = ++openId;
|
|
const stale = (): boolean => id !== openId;
|
|
busy = true;
|
|
result = undefined;
|
|
deleted = false;
|
|
announcement = 'Abriendo la cápsula.';
|
|
await discard();
|
|
// This opening's temporary file, until it becomes `temp`: the finally
|
|
// block removes it on every other path.
|
|
let t: TempFile | undefined;
|
|
try {
|
|
const opener = await import('$lib/inspector/opener.ts');
|
|
if (stale()) return;
|
|
let free: number | undefined;
|
|
if (fixture === undefined) {
|
|
const platform = browserPlatform();
|
|
if (platform !== undefined) {
|
|
try {
|
|
free = await freeSpace(platform);
|
|
if (free === undefined || free >= payloadLength) t = pending = await createTempFile(platform);
|
|
} catch {
|
|
// The OPFS is there but the browser refuses it (a private window,
|
|
// site data blocked): the capsule opens in memory instead.
|
|
}
|
|
if (stale()) return;
|
|
}
|
|
if (t === undefined && payloadLength > MEMORY_LIMIT) {
|
|
await fail(
|
|
free !== undefined && free < payloadLength
|
|
? `El navegador deja ${formatByteCount(free)} libres para esta página y el contenido cifrado ocupa ${formatByteCount(payloadLength)}, más de los ${formatByteCount(MEMORY_LIMIT)} que la página abre en memoria. Libera espacio o usa la CLI (datekeys decrypt).`
|
|
: `Este navegador no deja a la página un fichero temporal privado (OPFS) y el contenido cifrado ocupa ${formatByteCount(payloadLength)}, más de los ${formatByteCount(MEMORY_LIMIT)} que la página abre en memoria. Usa otro navegador o la CLI (datekeys decrypt).`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
const out = t;
|
|
const attempt = await opener.openCapsule({
|
|
capsule,
|
|
release: parsed.release,
|
|
...(timeAndKey ? { identities } : {}),
|
|
...(timeAndKey && accessKey !== undefined ? { accessKey } : {}),
|
|
...(timeAndKey && words.trim() !== '' ? { words: { text: words, chainHash: report.profile!.chainHash, round, capsuleId: report.capsule!.capsuleId } } : {}),
|
|
// A stale opening stops writing, so open aborts the file and stops.
|
|
...(out === undefined ? {} : { output: { writable: cancellable(out.writable, stale), file: () => out.file(), remove: () => out.remove() } }),
|
|
// Format 3: the files without an mtime take the time of the round in
|
|
// a ZIP, which must fit in the room the browser leaves.
|
|
roundTime: Math.floor((report.unlock?.epochMs ?? 0) / 1000),
|
|
...(free === undefined ? {} : { room: free }),
|
|
capsuleName: report.fileName,
|
|
});
|
|
if (stale()) {
|
|
if (attempt.ok) attempt.plaintext?.fill(0);
|
|
return;
|
|
}
|
|
if (!attempt.ok) {
|
|
await fail(attempt.problem, attempt.field);
|
|
return;
|
|
}
|
|
if (attempt.noRoom !== undefined) {
|
|
await fail(
|
|
`Los ficheros de la cápsula ocupan ${formatByteCount(attempt.noRoom.needed)} y el navegador deja ${formatByteCount(attempt.noRoom.room)} libres para esta página. Libera espacio o usa la CLI (datekeys decrypt).`,
|
|
);
|
|
return;
|
|
}
|
|
const opened = attempt.opened.error === undefined;
|
|
let shown: { text: string; cut: boolean } | undefined;
|
|
// The extension of the download, from the content: the capsule does
|
|
// not keep the name of the file it seals.
|
|
let ext = '';
|
|
if (opened && attempt.preview !== undefined) {
|
|
shown = plaintextPreview(attempt.preview.bytes, attempt.preview.whole);
|
|
ext = contentExtension(attempt.preview.bytes, shown !== undefined);
|
|
attempt.preview.bytes.fill(0);
|
|
}
|
|
const r = buildOpenReport(
|
|
attempt.opened,
|
|
attempt.digest === undefined
|
|
? undefined
|
|
: {
|
|
...attempt.digest,
|
|
...(fixture?.plaintextSHA256 === undefined ? {} : { expectedSHA256: fixture.plaintextSHA256 }),
|
|
...(shown === undefined ? {} : { text: shown.text, textCut: shown.cut }),
|
|
},
|
|
);
|
|
let download: Result['download'];
|
|
let files: FilesView | undefined;
|
|
if (attempt.files !== undefined) {
|
|
const f = attempt.files;
|
|
const d = f.downloads;
|
|
const offer = fixture === undefined;
|
|
const primary =
|
|
offer && d.primary !== undefined
|
|
? d.primary.kind === 'zip'
|
|
? { url: objectURL(f.zip!), name: d.primary.name, size: f.zip!.size }
|
|
: { url: objectURL(f.file(d.primary.index)), name: d.primary.name, size: f.file(d.primary.index).size }
|
|
: undefined;
|
|
const zip = offer && d.zip !== undefined ? { url: objectURL(f.zip!), name: d.zip, size: f.zip!.size } : undefined;
|
|
files = {
|
|
files: f,
|
|
...(primary === undefined ? {} : { primary }),
|
|
...(zip === undefined ? {} : { zip }),
|
|
each: offer && d.each,
|
|
temporary: t !== undefined,
|
|
...(shown === undefined ? {} : { shown }),
|
|
};
|
|
if (t !== undefined) {
|
|
temp = t;
|
|
pending = undefined;
|
|
t = undefined;
|
|
}
|
|
} else if (opened && fixture === undefined) {
|
|
const name = plaintextFileName(report.fileName, ext);
|
|
if (t !== undefined) {
|
|
const file = await t.file();
|
|
if (stale()) return;
|
|
temp = t;
|
|
pending = undefined;
|
|
t = undefined;
|
|
download = { url: objectURL(file), name, size: file.size, temporary: true };
|
|
} else {
|
|
const blob = new Blob([attempt.plaintext! as Uint8Array<ArrayBuffer>]);
|
|
download = { url: objectURL(blob), name, size: blob.size, temporary: false };
|
|
}
|
|
}
|
|
attempt.plaintext?.fill(0);
|
|
result = { report: r, ms: attempt.ms, ...(download === undefined ? {} : { download }), ...(files === undefined ? {} : { files }) };
|
|
announcement = r.opened
|
|
? 'Cápsula abierta: pasos 9 a 18 superados.'
|
|
: `Apertura rechazada en el paso ${r.failure!.step}, ${r.failure!.code}.`;
|
|
await tick();
|
|
document.getElementById('open-result-title')?.focus();
|
|
} catch (err) {
|
|
if (!stale()) await fail(unexpectedProblem('abrir la cápsula', err));
|
|
} finally {
|
|
// A file that did not become `temp`: a failure, or a stale opening.
|
|
if (t !== undefined) {
|
|
if (pending === t) pending = undefined;
|
|
await t.remove();
|
|
}
|
|
if (!stale()) busy = false;
|
|
}
|
|
}
|
|
|
|
// Shows why the opening could not run, announces it and moves the focus to
|
|
// the field at fault, or to the message: the submit button that had it is
|
|
// disabled while the opening runs.
|
|
async function fail(message: string, field?: ProblemField): Promise<void> {
|
|
problem = message;
|
|
problemField = field;
|
|
announcement = '';
|
|
await tick();
|
|
announcement = message;
|
|
document.getElementById(field === undefined ? 'open-problem' : FIELD_IDS[field])?.focus();
|
|
}
|
|
|
|
function seconds(ms: number): string {
|
|
return new Intl.NumberFormat('es-ES', { maximumFractionDigits: 2 }).format(ms / 1000);
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onpagehide={leave} />
|
|
|
|
<section class="open" aria-labelledby="open-title">
|
|
<h3 id="open-title">Abrir la cápsula</h3>
|
|
|
|
{#if !due}
|
|
<p class="prose">
|
|
La fecha de apertura todavía no ha llegado según el reloj de este dispositivo. Hasta entonces drand no publica la
|
|
firma de la ronda {round} y nadie puede abrir la cápsula, tampoco esta página (paso 9, ERR_RELEASE_UNAVAILABLE).
|
|
</p>
|
|
{:else}
|
|
<form class="form" onsubmit={submit} novalidate>
|
|
{#if timeAndKey}
|
|
<fieldset>
|
|
<legend>La llave</legend>
|
|
<p class="hint">
|
|
Esta cápsula se creó «solo con una llave»: además de la firma de la ronda, hace falta su fichero <code>.dkk</code>,
|
|
las palabras con las que se creó o la clave secreta de <code>age</code> de una de las personas a las que se dio. No
|
|
salen de este navegador.
|
|
</p>
|
|
<div class="field">
|
|
<label for="dkk-input">Fichero de la llave (.dkk)</label>
|
|
<input
|
|
id="dkk-input"
|
|
type="file"
|
|
accept=".dkk"
|
|
onchange={onAccessKey}
|
|
aria-invalid={problemField === 'accessKey' ? 'true' : undefined}
|
|
aria-describedby={problemField === 'accessKey' ? 'open-problem' : undefined}
|
|
/>
|
|
</div>
|
|
<div class="field">
|
|
<label for="words-input">O tus palabras</label>
|
|
<input id="words-input" type="text" bind:value={words} autocomplete="off" spellcheck="false" aria-describedby="words-hint" />
|
|
<p id="words-hint" class="hint">Las que se escribieron al crear la cápsula. Dan igual mayúsculas, acentos y espacios.</p>
|
|
</div>
|
|
<div class="field">
|
|
<label for="ids-input">O tu clave secreta de age</label>
|
|
<textarea
|
|
id="ids-input"
|
|
rows="2"
|
|
bind:value={identities}
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder="AGE-SECRET-KEY-1…"
|
|
aria-invalid={problemField === 'identities' ? 'true' : undefined}
|
|
aria-describedby={problemField === 'identities' ? 'open-problem ids-hint' : 'ids-hint'}
|
|
></textarea>
|
|
<p id="ids-hint" class="hint">
|
|
Pega el fichero de tu clave entero, el que creó <code>age-keygen</code>: vale la línea
|
|
<code>AGE-SECRET-KEY-1…</code>, y las que empiezan por <code>#</code> se ignoran. Solo se usa en este navegador y se
|
|
borra después.
|
|
</p>
|
|
</div>
|
|
</fieldset>
|
|
{/if}
|
|
|
|
<div class="field">
|
|
<label for="release-input">Firma de la ronda {round}, el release que publica drand</label>
|
|
<textarea
|
|
id="release-input"
|
|
rows="3"
|
|
bind:value={releaseInput}
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
aria-invalid={problemField === 'release' ? 'true' : undefined}
|
|
aria-describedby={problemField === 'release' ? 'open-problem release-hint' : 'release-hint'}
|
|
></textarea>
|
|
{#if fixture?.release === undefined}
|
|
<div class="actions">
|
|
<button class="button quiet" type="button" onclick={askDrand} disabled={asking || busy}>
|
|
{asking ? 'Pidiendo la firma…' : 'Pedir la firma a drand'}
|
|
</button>
|
|
</div>
|
|
{#if askNote !== ''}
|
|
<p class="hint" role="status">{askNote}</p>
|
|
{/if}
|
|
{/if}
|
|
<p id="release-hint" class="hint">
|
|
{#if fixture?.release !== undefined}
|
|
Viene del registro del fixture: es la que publicó drand para la ronda {round}, como muestra
|
|
<a href={drandURL} target="_blank" rel="noopener noreferrer">su página en drand</a>. Cámbiala para ver cómo la
|
|
rechaza el paso 10.
|
|
{:else}
|
|
«Pedir la firma a drand» la pide a sus relays públicos, que ven tu dirección IP y qué ronda pides. También puedes
|
|
abrir <a href={drandURL} target="_blank" rel="noopener noreferrer">la firma de la ronda {round} en drand</a> en otra
|
|
pestaña, copiar lo que muestra y pegarlo aquí; vale también la firma sola, en hexadecimal.
|
|
{/if}
|
|
Solo se leen la ronda y la firma, que se verifican aquí con la clave pública del perfil fijado (§51).
|
|
</p>
|
|
</div>
|
|
|
|
{#if problem}
|
|
<p id="open-problem" class="problem" tabindex="-1">{problem}</p>
|
|
{/if}
|
|
|
|
<div class="actions">
|
|
<button class="button" type="submit" disabled={busy}>{busy ? 'Abriendo…' : 'Abrir la cápsula'}</button>
|
|
{#if fixture === undefined}
|
|
<p class="hint">
|
|
El texto descifrado se escribe en un fichero temporal privado de este navegador y solo se muestra y se ofrece
|
|
si age lo autentica entero (§56); si es texto, la página muestra el principio. Si la cápsula guarda varios
|
|
ficheros, ese fichero temporal es un ZIP con todos ellos. Se borra cuando lo pides, al abrir o cargar otra
|
|
cápsula y al salir de la página; sin ese fichero, se abre en la memoria de la página hasta 64 MiB.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
<p class="visually-hidden" role="status">{announcement}</p>
|
|
|
|
{#if result}
|
|
{@const r = result.report}
|
|
<div class={['verdict', r.opened ? 'valid' : 'invalid']}>
|
|
<h4 id="open-result-title" class="state-word" tabindex="-1">
|
|
{r.opened ? 'Abierta' : r.failure ? `Rechazada en el paso ${r.failure.step}` : 'Rechazada'}
|
|
</h4>
|
|
{#if r.opened}
|
|
<p>
|
|
Supera los pasos 9 a 18 en {seconds(result.ms)} s. age ha autenticado el texto entero con la clave de
|
|
PAYLOAD_AGE, pero eso no prueba quién lo escribió, ni que sea el original si otros abrieron la cápsula antes
|
|
(§55.1).
|
|
</p>
|
|
{:else if r.failure}
|
|
<p class="code">{r.failure.code}</p>
|
|
<p>{errorGloss(r.failure.code)}</p>
|
|
{#if r.steps.length === 0}
|
|
<p class="detail">{escapeInvisible(r.failure.message)}</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if r.plaintext}
|
|
{@const p = r.plaintext}
|
|
<section class="block" aria-labelledby="plaintext-title">
|
|
<h3 id="plaintext-title">Contenido</h3>
|
|
{#if p.text !== undefined}
|
|
{#if p.text === ''}
|
|
<p class="muted">El texto en claro está vacío.</p>
|
|
{:else}
|
|
<pre class="plaintext">{p.textCut ? `${p.text}\n…` : p.text}</pre>
|
|
{#if p.textCut}
|
|
<p class="muted">
|
|
Se muestra el principio, como mucho {formatInteger(SHOWN_TEXT)} caracteres.{fixture === undefined
|
|
? ' Descárgalo para verlo entero.'
|
|
: ''}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
{:else}
|
|
<p class="muted">
|
|
No es texto UTF-8 imprimible, así que la página no lo muestra{fixture === undefined
|
|
? ': descárgalo y ábrelo con el programa que corresponda.'
|
|
: '.'}
|
|
</p>
|
|
{/if}
|
|
{#if result.download}
|
|
{@const d = result.download}
|
|
{#if deleted}
|
|
<p class="muted">
|
|
{d.temporary
|
|
? 'Fichero temporal borrado. Para descargarlo otra vez, abre de nuevo la cápsula.'
|
|
: 'El texto descifrado ya no está en la página. Para descargarlo otra vez, abre de nuevo la cápsula.'}
|
|
</p>
|
|
{:else}
|
|
<div class="actions">
|
|
<a class="button" href={d.url} download={d.name}>Descargar {d.name}</a>
|
|
{#if d.temporary}
|
|
<button class="button quiet" type="button" onclick={deleteNow}>Borrar el fichero temporal</button>
|
|
{/if}
|
|
</div>
|
|
<p class="hint">
|
|
{#if d.temporary}
|
|
Está en un fichero temporal privado de este navegador ({formatByteCount(d.size)}). Se borra cuando lo pides, al
|
|
abrir o cargar otra cápsula y al salir de la página.
|
|
{:else}
|
|
Está en la memoria de esta página ({formatByteCount(d.size)}), porque el navegador no le deja un fichero
|
|
temporal privado. Se libera al abrir o cargar otra cápsula y al salir de la página.
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
<dl>
|
|
<div>
|
|
<dt>Tamaño</dt>
|
|
<dd>{formatByteCount(p.length)}</dd>
|
|
</div>
|
|
{#if r.padding}
|
|
<div>
|
|
<dt>Relleno</dt>
|
|
<dd>
|
|
regla {r.padding.rule}: la cápsula sellaba {formatByteCount(r.padding.paddedLength)}, el contenido seguido de ceros,
|
|
que se comprobaron y no se entregan
|
|
</dd>
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<dt>SHA-256</dt>
|
|
<dd class="mono">{p.sha256}</dd>
|
|
</div>
|
|
{#if p.expectedSHA256 !== undefined}
|
|
<div>
|
|
<dt>Registro del fixture</dt>
|
|
<dd class={p.expectedSHA256 === p.sha256 ? 'match' : 'mismatch'}>
|
|
{p.expectedSHA256 === p.sha256 ? 'coincide' : 'no coincide'}: <span class="mono">{p.expectedSHA256}</span>
|
|
</dd>
|
|
</div>
|
|
{/if}
|
|
</dl>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if result.files}
|
|
{@const v = result.files}
|
|
{@const f = v.files}
|
|
<section class="block" aria-labelledby="files-title">
|
|
<h3 id="files-title">Contenido</h3>
|
|
<ul class="verdict-lines">
|
|
{#each f.verdicts as line, i (i)}
|
|
<li>{line}</li>
|
|
{/each}
|
|
</ul>
|
|
{#if f.head.author !== ''}
|
|
<p class="creator-label">autor declarado (texto del creador, sin comprobar):</p>
|
|
<p class="creator"><bdi dir="auto">{escapeInvisible(f.head.author, true)}</bdi></p>
|
|
{/if}
|
|
{#if f.head.comment !== ''}
|
|
<figure class="comment">
|
|
<figcaption>Comentario del creador (sin comprobar)</figcaption>
|
|
<pre><bdi dir="auto">{escapeInvisible(f.head.comment, true)}</bdi></pre>
|
|
</figure>
|
|
{/if}
|
|
{#if v.shown}
|
|
<figure class="comment">
|
|
<figcaption>Principio de <bdi dir="auto">{f.facts[0]?.shown}</bdi> (contenido del creador, sin comprobar)</figcaption>
|
|
<pre class="plaintext">{v.shown.cut ? `${v.shown.text}\n…` : v.shown.text}</pre>
|
|
</figure>
|
|
{#if v.shown.cut}
|
|
<p class="muted">Se muestra el principio del fichero, como mucho {formatInteger(SHOWN_TEXT)} caracteres.</p>
|
|
{/if}
|
|
{/if}
|
|
{#if v.primary}
|
|
{#if deleted}
|
|
<p class="muted">
|
|
{v.temporary
|
|
? 'Fichero temporal borrado. Para descargar los ficheros otra vez, abre de nuevo la cápsula.'
|
|
: 'Los ficheros ya no están en la página. Para descargarlos otra vez, abre de nuevo la cápsula.'}
|
|
</p>
|
|
{:else}
|
|
<div class="actions">
|
|
<a class="button" href={v.primary.url} download={v.primary.name}>Descargar {v.primary.name}</a>
|
|
{#if v.zip}
|
|
<a class="button quiet" href={v.zip.url} download={v.zip.name}>Descargar {v.zip.name}, con su carpeta</a>
|
|
{/if}
|
|
{#if v.temporary}
|
|
<button class="button quiet" type="button" onclick={deleteNow}>Borrar el fichero temporal</button>
|
|
{/if}
|
|
</div>
|
|
<p class="hint">
|
|
{#if v.temporary}
|
|
Están en un fichero temporal privado de este navegador. Se borra cuando lo pides, al abrir o cargar otra
|
|
cápsula y al salir de la página.
|
|
{:else}
|
|
Están en la memoria de esta página: el navegador no le deja un fichero temporal privado, o no tiene sitio
|
|
para ellos. Se liberan al abrir o cargar otra cápsula y al salir de la página.
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
{#if f.facts.length === 0}
|
|
<p class="muted">{f.head.comment === '' ? 'La cápsula no guarda ficheros ni comentario.' : 'La cápsula no guarda ficheros, solo el comentario.'}</p>
|
|
{:else}
|
|
<h4 class="files-title">Ficheros ({formatInteger(f.facts.length)})</h4>
|
|
<ul class="files">
|
|
{#each f.facts.slice(0, LISTED) as file, i (i)}
|
|
<li>
|
|
<bdi dir="auto" class="mono path">{file.shown}</bdi>
|
|
<span class="muted"
|
|
>{formatByteCount(file.size)}{file.mtimeMs === undefined ? '' : `, fecha del fichero ${formatDateTime(file.mtimeMs)}`}</span
|
|
>
|
|
{#if v.each && !deleted}
|
|
<button class="button quiet small" type="button" onclick={() => saveFile(f, i, file.name)}
|
|
>Descargar</button
|
|
>
|
|
{/if}
|
|
{#each file.warnings as warning, k (k)}
|
|
<p class="warning">aviso: {warning}</p>
|
|
{/each}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{#if f.facts.length > LISTED}
|
|
<p class="muted">Y {formatInteger(f.facts.length - LISTED)} ficheros más, que están en el ZIP.</p>
|
|
{/if}
|
|
<p class="hint">
|
|
Las rutas son texto del creador: se muestran tal cual, con los caracteres invisibles escritos como
|
|
<code>\u…</code>. La fecha de cada fichero es la que declaró el creador y no prueba nada.
|
|
</p>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
{#if r.steps.length > 0}
|
|
<section class="block" aria-labelledby="open-steps-title">
|
|
<h3 id="open-steps-title">Pasos 9 a 18</h3>
|
|
<StepList steps={r.steps} />
|
|
</section>
|
|
{/if}
|
|
|
|
{#if r.release}
|
|
<section class="block" aria-labelledby="release-title">
|
|
<h3 id="release-title">Release verificado</h3>
|
|
<dl>
|
|
<div>
|
|
<dt>Ronda</dt>
|
|
<dd class="mono">{r.release.round}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Firma</dt>
|
|
<dd class="mono">{r.release.signature}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if r.controlExtensions}
|
|
<section class="block" aria-labelledby="control-ext-title">
|
|
<h3 id="control-ext-title">Extensiones de CONTROL_CBOR</h3>
|
|
<ExtensionList extensions={r.controlExtensions} object="CONTROL_CBOR" />
|
|
</section>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.verdict-lines {
|
|
margin: 0;
|
|
padding-left: 1.2rem;
|
|
font-weight: 600;
|
|
}
|
|
.creator-label {
|
|
margin: 0;
|
|
color: var(--ink-2, inherit);
|
|
}
|
|
.creator {
|
|
margin: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.comment {
|
|
margin: 0;
|
|
padding: 0.75rem 1rem;
|
|
border: 1px solid var(--rule);
|
|
border-radius: var(--radius);
|
|
}
|
|
.comment figcaption {
|
|
font-weight: 600;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.comment pre {
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.files {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
margin: 0;
|
|
padding-left: 1.2rem;
|
|
}
|
|
.files .path {
|
|
overflow-wrap: anywhere;
|
|
margin-right: 0.5rem;
|
|
}
|
|
.files .warning {
|
|
margin: 0.2rem 0 0;
|
|
color: var(--warn, #9a5b00);
|
|
}
|
|
.files-title {
|
|
margin: 0;
|
|
}
|
|
.small {
|
|
padding: 0.1rem 0.5rem;
|
|
font-size: 0.85em;
|
|
}
|
|
.open {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: 1.5rem;
|
|
align-content: start;
|
|
}
|
|
/* minmax(0, 1fr) and min-width 0: a fieldset is as wide as its content
|
|
by default, which widens the page on a phone. */
|
|
.form {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: 1.1rem;
|
|
max-width: var(--measure);
|
|
padding: 1.1rem 1.25rem 1.25rem;
|
|
border: 1px solid var(--rule);
|
|
border-radius: var(--radius);
|
|
background: var(--paper-2);
|
|
}
|
|
fieldset {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
min-width: 0;
|
|
gap: 0.9rem;
|
|
margin: 0;
|
|
padding: 0.75rem 1rem 1rem;
|
|
border: 1px solid var(--rule);
|
|
border-radius: var(--radius);
|
|
}
|
|
legend {
|
|
padding-inline: 0.35rem;
|
|
font-weight: 650;
|
|
}
|
|
.field {
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
}
|
|
label {
|
|
font-weight: 600;
|
|
}
|
|
textarea {
|
|
width: 100%;
|
|
font-family: var(--mono);
|
|
font-size: 0.82rem;
|
|
line-height: 1.45;
|
|
padding: 0.5rem 0.6rem;
|
|
border: 1px solid var(--rule-strong);
|
|
border-radius: var(--radius);
|
|
background: var(--paper);
|
|
color: var(--ink);
|
|
resize: vertical;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
input[type='file'] {
|
|
max-width: 100%;
|
|
font: inherit;
|
|
font-size: var(--t-small);
|
|
}
|
|
.hint {
|
|
font-size: var(--t-small);
|
|
color: var(--ink-muted);
|
|
max-width: var(--measure);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.75rem 1rem;
|
|
}
|
|
/* The download link quotes the file name, which may have no break points;
|
|
break-word would not let the flex item shrink below it on a phone. */
|
|
.actions a.button {
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.button[disabled] {
|
|
opacity: 0.6;
|
|
cursor: progress;
|
|
}
|
|
.problem {
|
|
border-left: 4px solid var(--fail);
|
|
background: var(--fail-bg);
|
|
padding: 0.65rem 0.9rem;
|
|
border-radius: 0 var(--radius) var(--radius) 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.verdict {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
padding: 1.1rem 1.25rem 1.25rem;
|
|
border: 1px solid var(--rule);
|
|
border-top: 6px solid var(--state);
|
|
border-radius: var(--radius);
|
|
background: var(--paper-2);
|
|
max-width: var(--measure);
|
|
}
|
|
.verdict.valid {
|
|
--state: var(--pass);
|
|
}
|
|
.verdict.invalid {
|
|
--state: var(--fail);
|
|
}
|
|
.state-word {
|
|
font-size: var(--t-h2);
|
|
font-weight: 700;
|
|
letter-spacing: -0.015em;
|
|
line-height: 1.2;
|
|
color: var(--state);
|
|
}
|
|
.code,
|
|
.detail {
|
|
font-family: var(--mono);
|
|
font-weight: 600;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.detail {
|
|
font-size: 0.82rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.block {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: 1rem;
|
|
align-content: start;
|
|
}
|
|
dl {
|
|
display: grid;
|
|
border-top: 1px solid var(--rule);
|
|
}
|
|
dl > div {
|
|
display: grid;
|
|
gap: 0.1rem 1rem;
|
|
padding-block: 0.55rem;
|
|
border-bottom: 1px solid var(--rule);
|
|
}
|
|
@media (min-width: 560px) {
|
|
dl > div {
|
|
grid-template-columns: 11rem minmax(0, 1fr);
|
|
}
|
|
}
|
|
dt {
|
|
font-size: var(--t-small);
|
|
font-weight: 600;
|
|
color: var(--ink-muted);
|
|
}
|
|
dd {
|
|
margin: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
dd.mono,
|
|
dd .mono {
|
|
font-size: 0.85rem;
|
|
}
|
|
dd.match {
|
|
color: var(--pass);
|
|
}
|
|
dd.mismatch {
|
|
color: var(--fail);
|
|
}
|
|
.plaintext {
|
|
max-height: 24rem;
|
|
overflow: auto;
|
|
padding: 0.85rem 1rem;
|
|
background: var(--paper-2);
|
|
border: 1px solid var(--rule);
|
|
border-radius: var(--radius);
|
|
font-size: 0.82rem;
|
|
line-height: 1.5;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
</style>
|