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/lib/components/OpenPanel.svelte

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>

Powered by TurnKey Linux.