summaryrefslogtreecommitdiff
path: root/src/components/templates
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/templates')
-rw-r--r--src/components/templates/Authors.astro355
-rw-r--r--src/components/templates/CopyrightNotice.astro70
-rw-r--r--src/components/templates/licenses/CC.astro153
-rw-r--r--src/components/templates/licenses/WTFPL.astro70
-rw-r--r--src/components/templates/signature/Commit.astro86
-rw-r--r--src/components/templates/signature/Downloads.astro63
-rw-r--r--src/components/templates/signature/Signature.astro44
-rw-r--r--src/components/templates/signature/Summary.astro279
8 files changed, 1120 insertions, 0 deletions
diff --git a/src/components/templates/Authors.astro b/src/components/templates/Authors.astro
new file mode 100644
index 0000000..61ca026
--- /dev/null
+++ b/src/components/templates/Authors.astro
@@ -0,0 +1,355 @@
+---
+import { type RevocationReason, toPK } from "@lib/pgp";
+import type { Verification } from "@lib/pgp/verify";
+import { defined, get } from "@utils/anonymous";
+import type { getSigners } from "@lib/collection/helpers";
+import type { PublicKey, UserIDPacket } from "openpgp";
+import {
+ createVerificationSummary,
+ VerificationResult,
+} from "@lib/pgp/summary";
+import Date from "@components/organisms/Date.astro";
+
+interface Props {
+ verifications: NonNullable<Verification["verifications"]>;
+ expectedSigners: Map<
+ string,
+ {
+ signer: Awaited<ReturnType<typeof getSigners>>[number];
+ users: UserIDPacket[];
+ key: PublicKey;
+ }
+ >;
+ commitSignerKey?: string;
+}
+
+const {
+ verifications: verificationsPromise,
+ expectedSigners,
+ commitSignerKey,
+} = Astro.props;
+
+let verifications = await Promise.all(
+ verificationsPromise.map(async (verification) => {
+ const { key, keyID, userID, verified } = verification;
+ return {
+ key: await key,
+ keyID,
+ userID: await userID,
+ verified: await verified.catch(() => false),
+ summary: await createVerificationSummary(verification),
+ };
+ }),
+);
+
+const expectedKeys = Array.from(expectedSigners.values()).map(get("key"));
+
+const expectedFingerprints = new Set(
+ expectedKeys.map((key) => key.getFingerprint()),
+);
+
+const verifiedFingerprints = new Set(
+ verifications.map((v) => v.key).filter(defined).map(toPK).map((key) =>
+ key.getFingerprint()
+ ),
+);
+
+if (!expectedFingerprints.isSubsetOf(verifiedFingerprints)) {
+ throw new Error(
+ `Missing signature from expected signers: ${[
+ ...expectedFingerprints.difference(verifiedFingerprints).values(),
+ ]}`,
+ );
+}
+---
+
+<div>
+ <table>
+ <caption>
+ <strong>Assinaturas</strong>
+ <p>
+ Para verificar uma assinatura é necessário a <a href="#message"
+ >mensagem</a>, a <a href="#signature">assinatura digital</a> e as <em
+ >chaves públicas</em> dos assinantes. Esta tabela mostra algumas
+ informações sobre os assinantes, as suas chaves públicas e as suas
+ assinaturas.
+ </p>
+ </caption>
+ <colgroup>
+ <col />
+ <col />
+ </colgroup>
+ <colgroup>
+ <col />
+ <col />
+ <col />
+ </colgroup>
+ <colgroup>
+ <col />
+ </colgroup>
+ <thead>
+ <tr>
+ <th scope="col">Assinante</th>
+ <th scope="col">Função</th>
+ <th scope="col">Fingerprint</th>
+ <th scope="col">Válido</th>
+ <th scope="col">Commiter</th>
+ <th scope="col">Mais Informações</th>
+ </tr>
+ </thead>
+ <tbody>
+ {
+ verifications.map(
+ ({ userID, key, keyID, verified, summary }) => {
+ const fingerprint = key
+ ? toPK(key).getFingerprint()
+ : undefined;
+ const info = fingerprint
+ ? expectedSigners.get(fingerprint)
+ : undefined;
+ const primary = userID?.[0];
+ const signer = info?.signer;
+ let role = "";
+ switch (signer?.role) {
+ case "author": {
+ role = "Autor";
+ break;
+ }
+ case "co-author": {
+ role = "Co-autor";
+ break;
+ }
+ case "translator": {
+ role = "Tradutor";
+ break;
+ }
+ }
+ const {
+ reasons,
+ created,
+ expired,
+ revoked,
+ revocationReason,
+ trusted,
+ } = (summary[1].get(keyID.toHex()) ?? []).reduce(
+ (acc, x) => {
+ if (!("key" in x || "keyID" in x)) {
+ return acc;
+ }
+
+ switch (x.result) {
+ case VerificationResult.MISSING_KEY:
+ acc.reasons.push(x.reason);
+ acc.created = x.created ?? undefined;
+ break;
+ case VerificationResult.UNTRUSTED_KEY:
+ acc.created = x.created ?? undefined;
+ acc.trusted &&= false;
+ break;
+ case VerificationResult.TRUSTED_KEY:
+ acc.created = x.created ?? undefined;
+ acc.trusted = true;
+ break;
+ case VerificationResult
+ .EXPIRATION_AFTER_SIGNATURE:
+ acc.created = x.created ?? undefined;
+ acc.expired = x.expired;
+ break;
+ case VerificationResult
+ .EXPIRATION_BEFORE_SIGNATURE:
+ acc.created = x.created ?? undefined;
+ acc.expired = x.expired;
+ break;
+ case VerificationResult
+ .REVOCATION_AFTER_SIGNATURE:
+ acc.created = x.created ?? undefined;
+ acc.revoked = x.revoked;
+ acc.revocationReason = x.revocationReason;
+ break;
+ case VerificationResult
+ .REVOCATION_BEFORE_SIGNATURE:
+ acc.created = x.created ?? undefined;
+ acc.revoked = x.revoked;
+ acc.revocationReason = x.revocationReason;
+ break;
+ case VerificationResult.KEY_DOES_NOT_SIGN:
+ break;
+ }
+
+ return acc;
+ },
+ {
+ reasons: [],
+ created: undefined,
+ expired: undefined,
+ revoked: undefined,
+ revocationReason: undefined,
+ trusted: false,
+ } as {
+ reasons: Error[];
+ created?: Date;
+ expired?: Date;
+ revoked?: Date;
+ revocationReason?: RevocationReason;
+ trusted: boolean;
+ },
+ );
+ return (
+ <tr>
+ <th scope="row">
+ <address
+ itemprop="author"
+ itemscope
+ itemtype="https://schema.org/Person"
+ >
+ {
+ primary?.name
+ ? signer?.entity?.data?.websites[0]
+ ? (
+ <a
+ itemprop="url"
+ rel="author external noreferrer"
+ target="_blank"
+ href={signer?.entity?.data?.websites[0]}
+ ><span itemprop="name">{primary.name}</span></a>
+ )
+ : <span itemprop="name">{primary.name}</span>
+ : primary?.email
+ ? (
+ <>&lt;<a
+ itemprop="email"
+ rel="author external noreferrer"
+ target="_blank"
+ href={primary?.email && `mailto:${primary.email}`}
+ >{primary?.email}</a>&gt;</>
+ )
+ : ""
+ }
+ </address>
+ {signer !== undefined && <><hr /><a href="#">Ver perfil</a></>}
+ </th>
+ <td>{role}</td>
+ <td>
+ <samp title={fingerprint?.replace(/(....)/g, "$1 ")}>{
+ key
+ ? "0x" + toPK(key).getKeyID().toHex()
+ : "0x" + keyID.toHex()
+ }</samp>
+ </td>
+ <td>{verified ? "✅" : "❌"}</td>
+ <td>
+ {
+ commitSignerKey &&
+ key?.getFingerprint().toUpperCase()?.endsWith(
+ commitSignerKey.toUpperCase(),
+ ) && "✅"
+ }
+ </td>
+ <td>
+ {
+ key && (
+ <>
+ <button
+ type="button"
+ class="emoji"
+ popovertarget={`info-${fingerprint}`}
+ >
+ ➕
+ </button>
+ <section popover id={`info-${fingerprint}`}>
+ <p>Erros</p>
+ {
+ summary[0].map((x) => {
+ if (!("reason" in x)) {
+ return undefined;
+ }
+
+ let reason = x.reason;
+
+ return <pre>{reason.message}</pre>;
+ })
+ }
+ <p><strong>Informações</strong></p>
+ <dl class="divider">
+ {
+ reasons.map(({ message }) => (
+ <> <dt>Erro</dt> <dd>{message}</dd></>
+ ))
+ }
+ {
+ created && (
+ <>
+ <dt>Data da assinatura</dt>
+ <dd><Date date={created} options={{}} /></dd>
+ </>
+ )
+ }
+ {
+ expired && (
+ <>
+ <dt>Data de expiração</dt>
+ <dd><Date date={expired} options={{}} /></dd>
+ </>
+ )
+ }
+ {
+ revoked && (
+ <>
+ <dt>Data de revogação</dt>
+ <dd><Date date={revoked} options={{}} /></dd>
+ </>
+ )
+ }
+ {
+ revocationReason && (
+ <>
+ <dt>Razão para a revogação</dt>
+ <dd>
+ {revocationReason.flag}: {revocationReason.msg}
+ </dd>
+ </>
+ )
+ }
+ <dt>Chave confiável</dt>
+ <dd>{trusted ? "✅" : "❌"}</dd>
+ </dl>
+ </section></>
+ )
+ }
+ </td>
+ </tr>
+ );
+ },
+ )
+ }
+ </tbody>
+ </table>
+</div>
+
+<style>
+ div {
+ overflow-x: auto;
+ }
+
+ table {
+ table-layout: fixed;
+ border-collapse: collapse;
+ border: 3px solid;
+ margin-inline: auto;
+ max-width: 90svw;
+ }
+
+ th,
+ td {
+ padding: 1rem;
+ text-align: center;
+ }
+
+ tbody tr:nth-child(odd) {
+ background-color: oklch(from var(--color-light) l c h / calc(alpha * 0.25));
+ }
+
+ section[popover] {
+ text-align: initial;
+ }
+</style>
diff --git a/src/components/templates/CopyrightNotice.astro b/src/components/templates/CopyrightNotice.astro
new file mode 100644
index 0000000..8335293
--- /dev/null
+++ b/src/components/templates/CopyrightNotice.astro
@@ -0,0 +1,70 @@
+---
+import {
+ CREATIVE_COMMONS_LICENSES,
+ type LICENSES,
+} from "@lib/collection/schemas";
+import CC from "./licenses/CC.astro";
+import WTFPL from "./licenses/WTFPL.astro";
+import type { Person } from "@lib/collection/types";
+
+export interface Props {
+ title: string;
+ holders: Person[];
+ years: number[];
+ license?: typeof LICENSES[number];
+}
+
+let { license = "public domain" } = Astro.props;
+
+let Notice = undefined;
+if (license !== undefined) {
+ if (license === "WTFPL") {
+ Notice = WTFPL;
+ } else if (
+ CREATIVE_COMMONS_LICENSES.some((x) => license.localeCompare(x) === 0)
+ ) {
+ Notice = CC;
+ }
+}
+---
+
+{Notice && <div lang="en"><Notice {...Astro.props} /></div>}
+
+{
+ /*
+ https://spdx.org/licenses/WTFPL.html
+ https://spdx.org/licenses/GFDL-1.3-or-later.html
+ https://spdx.org/licenses/FSFAP.html
+ https://artlibre.org/licence/lal/en/
+ https://harmful.cat-v.org/software/
+
+ IPL-1.0
+ IPA
+ Intel
+ HPND
+ EUPL-1.2
+ EUPL-1.1
+ EUDatagrid
+ EPL-2.0
+ EPL-1.0
+ EFL-2.0
+ ECL-2.0
+ CPL-1.0
+ CPAL-1.0
+ CDDL-1.0
+ BSL-1.0
+ BSD-3-Clause
+ BSD-2-Clause
+ Artistic-2.0
+ APSL-2.0
+ Apache-2.0
+ Apache-1.1
+ AGPL-3.0-or-later
+ AGPL-3.0-only
+ AFL-3.0
+ AFL-2.1
+ AFL-2.0
+ AFL-1.2
+ AFL-1.1
+ */
+}
diff --git a/src/components/templates/licenses/CC.astro b/src/components/templates/licenses/CC.astro
new file mode 100644
index 0000000..2aea423
--- /dev/null
+++ b/src/components/templates/licenses/CC.astro
@@ -0,0 +1,153 @@
+---
+import type { ComponentProps } from "astro/types";
+import type CopyrightNotice from "@components/templates/CopyrightNotice.astro";
+import { listYearsWithRanges } from "@utils/datetime";
+interface Props extends ComponentProps<typeof CopyrightNotice> {}
+
+let { license, title, holders, years } = Astro.props;
+
+if (typeof license !== "string") throw new Error();
+
+const publicdomain = license === "CC0";
+const sa = /SA/.test(license);
+const nd = /ND/.test(license);
+const nc = /NC/.test(license);
+const licenseURL = `https://creativecommons.org/licenses/${
+ license.slice(3).toLowerCase()
+}/4.0/`;
+
+const firstYear = Math.min(...years);
+const lastYears = years.sort((a, b) => a - b).slice(1);
+---
+
+<footer itemprop="copyrightNotice">
+ {
+ publicdomain ? (
+ <p>
+ <small>
+ <a href={Astro.url}>{title}</a> by {
+ holders.map(({ name, url }) => {
+ if (name === undefined) return undefined;
+
+ const website = url?.[0];
+
+ return (
+ <span
+ itemprop="copyrightHolder"
+ itemscope
+ itemtype="https://schema.org/Person"
+ >{
+ website !== undefined ? (
+ <a
+ itemprop="url"
+ rel="author external noreferrer"
+ target="_blank"
+ href={website}
+ content={website}
+ ><span itemprop="name">{name}</span></a>
+ ) : <span itemprop="name">{name}</span>
+ }</span>
+ );
+ })
+ } is marked <a
+ itemprop="license"
+ rel="license noreferrer"
+ target="_blank"
+ href="https://creativecommons.org/publicdomain/zero/1.0/"
+ content="https://creativecommons.org/publicdomain/zero/1.0/"
+ >CC0 1.0</a>
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/cc.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/zero.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ </small>
+ </p>
+ ) : (
+ <p>
+ <small>
+ <a href={Astro.url}>{title}</a> © <span itemprop="copyrightYear">{
+ firstYear
+ }</span>, {
+ listYearsWithRanges(lastYears, {
+ list: { type: "unit", style: "narrow" },
+ locale: "en",
+ }).replace(/^\d+/, "")
+ } by {
+ holders.map(({ name, url }) => {
+ if (name === undefined) return undefined;
+
+ const website = url?.[0];
+
+ return (
+ <span
+ itemprop="copyrightHolder"
+ itemscope
+ itemtype="https://schema.org/Person"
+ >{
+ website !== undefined ? (
+ <a
+ itemprop="url"
+ rel="author external noreferrer"
+ target="_blank"
+ href={website}
+ content={website}
+ ><span itemprop="name">{name}</span></a>
+ ) : <span itemprop="name">{name}</span>
+ }</span>
+ );
+ })
+ } is licensed under <a
+ itemprop="license"
+ rel="license noreferrer"
+ target="_blank"
+ href={licenseURL}
+ content={licenseURL}
+ >{license.replace("CC-", "CC ")} 4.0</a>
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/cc.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/by.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ {
+ nc && (
+ <img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/nc.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ >
+ )
+ }
+ {
+ sa && (
+ <>{" "}<img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/sa.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ ></>
+ )
+ }
+ {
+ nd && (
+ <>{" "}<img
+ alt=""
+ src="https://mirrors.creativecommons.org/presskit/icons/nd.svg"
+ style="max-width: 1em; max-height: 1em; margin-left: 0.2em"
+ ></>
+ )
+ }
+ </small>
+ </p>
+ )
+ }
+</footer>
diff --git a/src/components/templates/licenses/WTFPL.astro b/src/components/templates/licenses/WTFPL.astro
new file mode 100644
index 0000000..d3546c7
--- /dev/null
+++ b/src/components/templates/licenses/WTFPL.astro
@@ -0,0 +1,70 @@
+---
+import { listYearsWithRanges } from "@utils/datetime";
+import type { Props as BaseProps } from "../CopyrightNotice.astro";
+interface Props extends BaseProps {}
+
+const { years, holders } = Astro.props;
+const firstYear = Math.min(...years);
+const lastYears = years.sort((a, b) => a - b).slice(1);
+---
+
+<footer itemprop="copyrightNotice">
+ <p>
+ <small>
+ Copyright © <span itemprop="copyrightYear">{firstYear}</span>, {
+ listYearsWithRanges(lastYears, {
+ list: { type: "unit", style: "narrow" },
+ locale: "en",
+ }).replace(/^\d+/, "")
+ }
+
+ {
+ holders.map(({ name, url, email }) => {
+ if (name === undefined) return undefined;
+
+ const website = url?.[0];
+
+ return (
+ <span
+ itemprop="copyrightHolder"
+ itemscope
+ itemtype="https://schema.org/Person"
+ >{
+ website !== undefined ? (
+ <a
+ itemprop="url"
+ rel="author external noreferrer"
+ target="_blank"
+ href={website}
+ content={website}
+ ><span itemprop="name">{name}</span></a>
+ ) : <span itemprop="name">{name}</span>
+ }
+ {
+ email !== undefined && (
+ <>&lt;<a
+ itemprop="email"
+ rel="author external noreferrer"
+ target="_blank"
+ href={`mailto:${email}`}
+ >{email}</a>&gt;</>
+ )
+ }</span>
+ );
+ })
+ }
+ </small>
+ </p>
+ <p>
+ <small>
+ This work is free. You can redistribute it and/or modify it under the
+ terms of the Do What The Fuck You Want To Public License, Version 2, as
+ published by Sam Hocevar. See <a
+ itemprop="license"
+ href="http://www.wtfpl.net/"
+ rel="license noreferrer"
+ target="_blank"
+ >http://www.wtfpl.net/</a> for more details.
+ </small>
+ </p>
+</footer>
diff --git a/src/components/templates/signature/Commit.astro b/src/components/templates/signature/Commit.astro
new file mode 100644
index 0000000..328a8f9
--- /dev/null
+++ b/src/components/templates/signature/Commit.astro
@@ -0,0 +1,86 @@
+---
+import { gitDir } from "@lib/git";
+import type { Commit } from "@lib/git/types";
+import { toIso8601Full } from "@utils/datetime";
+
+type Props = { commit: Commit; lang: string };
+
+const dir = await gitDir();
+const { hash, files, author, committer, signature } = Astro.props.commit;
+
+const formatter = new Intl.DateTimeFormat([Astro.props.lang], {
+ dateStyle: "short",
+ timeStyle: "short",
+});
+---
+
+<section>
+ <details>
+ <summary>
+ Informações sobre o último commit que modificou ficheiros relacionados a
+ este blog post:
+ </summary>
+ <dl class="divider">
+ <dt>Hash</dt>
+ <dd><samp title={hash.long}>0x{hash.short.toUpperCase()}</samp></dd>
+ <dt>Ficheiros modificados</dt>
+ {
+ files.length > 0
+ ? files.map((file) => (
+ <dd><samp>{file.path.pathname.replace(dir.pathname, "")}</samp></dd>
+ ))
+ : <dd>Nenhum ficheiro modificado</dd>
+ }
+ <dt>
+ Autor (<time datetime={toIso8601Full(author.date)}>{
+ formatter.format(author.date)
+ }</time>)
+ </dt>
+ <dd>
+ {author.name} &lt;<a href={`mailto:${author.email}`}>{
+ author.email
+ }</a>&gt;
+ </dd>
+ <dt>
+ Commiter (<time datetime={toIso8601Full(committer.date)}>{
+ formatter.format(committer.date)
+ }</time>)
+ </dt>
+ <dd>
+ {committer.name} &lt;<a href={`mailto:${committer.email}`}>{
+ committer.email
+ }</a>&gt;
+ </dd>
+ {
+ signature &&
+ (
+ <dt>Assinatura do commit</dt>
+ <dd>
+ <dl>
+ <dt>Tipo</dt>
+ <dd><samp>{signature.type}</samp></dd>
+ <dt>Assinante</dt>
+ <dd>{signature.signer}</dd>
+ <dt>Fingerprint da chave</dt>
+ <dd><samp>0x{signature.key.short}</samp></dd>
+ </dl>
+ </dd>
+ )
+ }
+ </dl>
+ </details>
+</section>
+
+<style>
+ section {
+ font-size: smaller;
+ }
+
+ dl {
+ margin-block: 0;
+ }
+
+ details {
+ padding-block: 1rem;
+ }
+</style>
diff --git a/src/components/templates/signature/Downloads.astro b/src/components/templates/signature/Downloads.astro
new file mode 100644
index 0000000..3497b37
--- /dev/null
+++ b/src/components/templates/signature/Downloads.astro
@@ -0,0 +1,63 @@
+---
+import { gitDir } from "@lib/git";
+import { get } from "@utils/anonymous";
+
+interface Props {
+ lang: string;
+}
+
+const { lang } = Astro.props;
+
+let source = new URL(
+ `${Astro.url.href.replace(/\/$/, "")}.md`,
+);
+
+const dir = await gitDir();
+
+const format: Intl.NumberFormatOptions = {
+ notation: "compact",
+ style: "unit",
+ unit: "byte",
+ unitDisplay: "narrow",
+};
+
+const formatter = new Intl.NumberFormat(lang, format);
+
+const sourceSize = formatter.format(
+ await Deno.stat(
+ new URL("public" + source.pathname, dir),
+ ).then(get("size")),
+);
+const sig = await Deno.stat(
+ new URL("public" + source.pathname + ".sig", dir),
+).then(get("size")).catch(() => undefined);
+const sigSize = formatter.format(sig);
+---
+
+<section>
+ <p>Ficheiros para descarregar:</p>
+ <dl>
+ <dt>Blog post</dt>
+ <dd>
+ <a
+ id="message"
+ href={source}
+ download
+ type="text/markdown; charset=utf-8"
+ ><samp>text/markdown</samp>, <samp>{sourceSize}</samp></a>
+ </dd>
+ {
+ sig && (
+ <dt>Assinatura digital</dt>
+ <dd>
+ <a
+ id="signature"
+ href={`${source}.sig`}
+ download
+ type="application/pgp-signature"
+ ><samp>application/pgp-signature</samp>, <samp>{sigSize}</samp></a>
+ </dd>
+ )
+ }
+ </dl>
+</section>
diff --git a/src/components/templates/signature/Signature.astro b/src/components/templates/signature/Signature.astro
new file mode 100644
index 0000000..57e9902
--- /dev/null
+++ b/src/components/templates/signature/Signature.astro
@@ -0,0 +1,44 @@
+---
+import type { Verification } from "@lib/pgp/verify";
+import Summary from "./Summary.astro";
+import Downloads from "./Downloads.astro";
+import Commit from "./Commit.astro";
+
+interface Props {
+ verification: Verification;
+ lang: string;
+}
+
+const { verification, lang } = Astro.props;
+const commit = await verification.commit;
+---
+
+<aside id="signatures">
+ <p><strong>Verificação da assinatura digital</strong></p>
+ <Summary {...verification} />
+ <Downloads {lang} />
+ {commit && <Commit {commit} {lang} />}
+</aside>
+
+<style is:global>
+ #signatures > section > p:first-child {
+ font-weight: bolder;
+ }
+</style>
+<style>
+ #signatures {
+ margin-inline: 1.5rem;
+ margin-block-end: 1.5rem;
+ box-shadow: 0 0 calc(1em) #e7e7e7;
+ border-radius: calc(1rem / 3);
+ padding: 1rem;
+ }
+
+ #signatures > p:first-child {
+ font-size: larger;
+
+ & > strong {
+ font-weight: bolder;
+ }
+ }
+</style>
diff --git a/src/components/templates/signature/Summary.astro b/src/components/templates/signature/Summary.astro
new file mode 100644
index 0000000..3469a0f
--- /dev/null
+++ b/src/components/templates/signature/Summary.astro
@@ -0,0 +1,279 @@
+---
+import {
+ createVerificationsSummary,
+ logLevel,
+ type Summary,
+ VerificationResult,
+} from "@lib/pgp/summary";
+import type { Verification } from "@lib/pgp/verify";
+import { Level } from "@utils/index";
+import type { NonEmptyArray } from "@utils/iterator";
+
+interface Props extends Verification {}
+
+let [errors, keys] = await createVerificationsSummary(Astro.props);
+const failed = errors.filter((summary) => "reason" in summary);
+
+if (failed.length > 0) {
+ errors = failed as NonEmptyArray<Summary>;
+}
+
+let worst;
+
+for (const summary of errors) {
+ if (worst === undefined) {
+ worst = summary;
+ }
+
+ const { result } = summary;
+ const a = logLevel(worst.result);
+ const b = logLevel(result);
+ if (a[0] === b[0] && !a[1] && b[1]) {
+ worst = summary;
+ } else if (b[0] === Level.ERROR) {
+ worst = summary;
+ } else if (a[0] === Level.OK && b[0] === Level.WARN) {
+ worst = summary;
+ }
+}
+
+let lvl: [Level, boolean] | undefined = undefined;
+
+let label;
+
+let title = "";
+let content;
+const error = worst && "reason" in worst ? worst.reason : undefined;
+
+if (worst) {
+ lvl = logLevel(worst.result);
+ switch (lvl[0]) {
+ case Level.OK: {
+ label = "OK";
+ break;
+ }
+ case Level.WARN: {
+ label = "Aviso";
+ break;
+ }
+ case Level.ERROR: {
+ label = "Erro";
+ break;
+ }
+ default: {
+ throw new Error("Unreachable");
+ }
+ }
+
+ switch (worst.result) {
+ case VerificationResult.NO_SIGNATURE: {
+ title = "Assinatura não encontrada";
+ content = `<p>
+Este blog post não foi assinado.
+</p>
+<p>
+<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.MISSING_KEY: {
+ title = "Chave não encontrada";
+ content = `<p>
+Este blog post está assinado digitalmente, porém a chave pública com <code>KeyID</code> <samp>0x${worst.keyID}</samp> com que foi assinado não foi encontrada no chaveiro sendo <strong>impossível verificar a assinatura, quer dizer, não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>.
+</p>
+<p>
+Procure a chave noutro sítio da internet para conseguir fazer a verificação manualmente.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.SIGNATURE_CORRUPTED: {
+ title = "Assinatura corrumpida";
+ content = `<p>
+Exite um ficheiro que supostamente é a assinatura, mas ele está corrompido ou com um formato inválido.
+</p>
+<p>
+<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED: {
+ title = "Erro desconhecido";
+ content = `<p>
+A assinatura foi encontrada mas ocorreu um erro inesperado durante a verificação.
+</p>
+<p>
+<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.BAD_SIGNATURE: {
+ title = "Assinatura inválida";
+ content = `<p>
+Existe uma assinatura digital porém o conteúdo da blog post não corresponde à assinatura. Talvez o texto tenha sido alterado sem ter sido criada uma nova assinatura.
+</p>
+<p>
+Pode tentar verificar a assinatura com versões antigas do blog post, mas esta versão <strong> não pode ser verificada quanto à autentacidade do autor ou à integridade do texto escrito</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.UNTRUSTED_KEY: {
+ title = "Assinatura válida (chave não confiada)";
+ content = `<p>
+A assinatura digital é criptograficamente válida, porém a chave utilizada não é suficientemente confiada pelo servidor. Mas podes ter a certeza que <strong>o dono da chave pública é a mesma pessoa que assinou este blog post</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.TRUSTED_KEY: {
+ title = "Assinatura válida";
+ content = `<p>
+A assinatura digital é criptograficamente válida. <strong>O dono da chave pública é a mesma pessoa que assinou este blog post exatamente como ele está, sem alterações</strong>.
+</p>
+`;
+ break;
+ }
+ case VerificationResult.EXPIRATION_AFTER_SIGNATURE: {
+ break;
+ }
+ case VerificationResult.EXPIRATION_BEFORE_SIGNATURE: {
+ break;
+ }
+ case VerificationResult.REVOCATION_AFTER_SIGNATURE: {
+ break;
+ }
+ case VerificationResult.REVOCATION_BEFORE_SIGNATURE: {
+ break;
+ }
+ case VerificationResult.KEY_DOES_NOT_SIGN: {
+ break;
+ }
+ default: {
+ throw new Error("Unreachable");
+ }
+ }
+}
+---
+
+{
+ lvl &&
+ (
+ <details
+ class:list={{
+ ok: lvl[0] === Level.OK,
+ warn: lvl[0] === Level.WARN,
+ error: lvl[0] === Level.ERROR,
+ super: lvl[1],
+ }}
+ >
+ <summary>{label?.toUpperCase()}: {title.toUpperCase()}</summary>
+ <Fragment set:html={content} />
+ {error && <pre><samp>{error}</samp></pre>}
+ </details>
+ )
+}
+
+<style>
+ pre {
+ overflow-x: auto;
+ }
+ details {
+ &.error {
+ --bg: #fff;
+ --fg: var(--color-active);
+
+ &.super {
+ --bg: var(--color-active);
+ --fg: #fff;
+ }
+ }
+
+ &.warn {
+ --bg: #fff;
+ --fg: #f46d43;
+
+ &.super {
+ --bg: #f46d43;
+ --fg: #fff;
+ }
+ }
+
+ &.ok {
+ --bg: #fff;
+ --fg: var(--color-visited);
+
+ &.super {
+ --bg: var(--color-visited);
+ --fg: #fff;
+ }
+ }
+
+ padding-inline: 0.5em;
+ padding-block: 0.5em;
+
+ & > summary {
+ background-color: var(--bg);
+ padding-inline: 0.5em;
+ padding-block: calc(1em / 3);
+ color: var(--fg);
+ border-color: var(--fg);
+ border-width: 1px;
+ border-style: solid;
+ border-radius: calc(1em / 3);
+ font-weight: bolder;
+
+ &:focus {
+ outline-color: var(--fg);
+ }
+
+ &::marker {
+ color: var(--fg);
+ }
+ }
+
+ & > :not(summary) {
+ padding-inline: 1em;
+ /* font-size: smaller; */
+ }
+
+ & > summary + * {
+ margin-block: 0.5em;
+ padding-block-start: 1em;
+ border-block-start: 1px solid var(--fg);
+ }
+ }
+
+ @media (prefers-color-scheme: dark) {
+ details {
+ &.error {
+ --bg: #000;
+
+ &.super {
+ --fg: #000;
+ }
+ }
+
+ &.warn {
+ --bg: #000;
+ --fg: #f46d43;
+
+ &.super {
+ --bg: #f46d43;
+ --fg: #000;
+ }
+ }
+
+ &.ok {
+ --bg: #000;
+
+ &.super {
+ --fg: #000;
+ }
+ }
+ }
+ }
+</style>