summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-24 12:08:41 -0300
committerJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-24 12:50:43 -0300
commitf9a77c5c27aede4e5978eb55d9b7af781b680a1d (patch)
treed545e325ba1ae756fc2eac66fac1001b6753c40d /src
feat!: initial commit
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
Diffstat (limited to 'src')
-rw-r--r--src/components/BaseHead.astro79
-rw-r--r--src/components/BlogCard.astro38
-rw-r--r--src/components/Citations.astro39
-rw-r--r--src/components/Commit.astro49
-rw-r--r--src/components/CopyrightNotice.astro66
-rw-r--r--src/components/DateSelector.astro141
-rw-r--r--src/components/Footer.astro62
-rw-r--r--src/components/Header.astro41
-rw-r--r--src/components/HeaderLink.astro18
-rw-r--r--src/components/Keywords.astro52
-rw-r--r--src/components/ReadingTime.astro26
-rw-r--r--src/components/SignaturesTableRows.astro51
-rw-r--r--src/components/Translations.astro107
-rw-r--r--src/components/licenses/CC.astro120
-rw-r--r--src/components/licenses/WTFPL.astro53
-rw-r--r--src/components/signature/Authors.astro281
-rw-r--r--src/components/signature/Commit.astro87
-rw-r--r--src/components/signature/Downloads.astro63
-rw-r--r--src/components/signature/Signature.astro44
-rw-r--r--src/components/signature/Summary.astro279
-rw-r--r--src/consts.ts29
-rw-r--r--src/content.config.ts116
-rw-r--r--src/content/entities.toml85
-rw-r--r--src/layouts/Base.astro35
-rw-r--r--src/lib/git/index.test.ts40
-rw-r--r--src/lib/git/index.ts16
-rw-r--r--src/lib/git/log.test.ts71
-rw-r--r--src/lib/git/log.ts131
-rw-r--r--src/lib/git/types.ts27
-rw-r--r--src/lib/pgp/create.test.ts130
-rw-r--r--src/lib/pgp/create.ts183
-rw-r--r--src/lib/pgp/index.ts63
-rw-r--r--src/lib/pgp/sign.test.ts121
-rw-r--r--src/lib/pgp/sign.ts82
-rw-r--r--src/lib/pgp/summary.ts232
-rw-r--r--src/lib/pgp/trust.ts19
-rw-r--r--src/lib/pgp/verify.test.ts619
-rw-r--r--src/lib/pgp/verify.ts349
-rw-r--r--src/pages/blog/[...year].astro165
-rw-r--r--src/pages/blog/keywords/[...slug].astro40
-rw-r--r--src/pages/blog/keywords/index.astro21
-rw-r--r--src/pages/blog/read/[...slug].astro333
-rw-r--r--src/pages/index.astro28
-rw-r--r--src/pages/robots.txt.ts13
-rw-r--r--src/pages/rss.xml.js16
-rw-r--r--src/styles/global.css105
-rw-r--r--src/utils/anonymous.test.ts130
-rw-r--r--src/utils/anonymous.ts25
-rw-r--r--src/utils/bases.test.ts32
-rw-r--r--src/utils/bases.ts11
-rw-r--r--src/utils/datetime.test.ts63
-rw-r--r--src/utils/datetime.ts43
-rw-r--r--src/utils/index.ts19
-rw-r--r--src/utils/iterator.test.ts122
-rw-r--r--src/utils/iterator.ts52
-rw-r--r--src/utils/lang.test.ts97
-rw-r--r--src/utils/lang.ts56
57 files changed, 5415 insertions, 0 deletions
diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro
new file mode 100644
index 0000000..5ac0410
--- /dev/null
+++ b/src/components/BaseHead.astro
@@ -0,0 +1,79 @@
+---
+// Import the global.css file here so that it is included on
+// all pages through the use of the <BaseHead /> component.
+import "../styles/global.css";
+import { SITE_AUTHOR, SITE_DESCRIPTION, SITE_TITLE } from "../consts";
+import { ClientRouter } from "astro:transitions";
+
+export interface Props {
+ title: string;
+ description?: string;
+ image?: string;
+ keywords?: string[];
+}
+
+const canonicalURL = new URL(Astro.url.pathname, Astro.site);
+
+const { title, description = SITE_DESCRIPTION, image, keywords = [] } =
+ Astro.props;
+// const socialImage = image ?? Astro.site.href + 'assets/social.png'
+---
+
+<!-- Global Metadata -->
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+
+<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+<link rel="sitemap" href="/sitemap-index.xml" />
+<link
+ rel="alternate"
+ type="application/rss+xml"
+ title={SITE_TITLE}
+ href={new URL("rss.xml", Astro.site)}
+/>
+<meta name="generator" content={Astro.generator} />
+
+<!-- Canonical URL -->
+<link rel="canonical" href={canonicalURL} />
+
+<!-- Primary Meta Tags -->
+<title>{title}</title>
+<meta name="title" content={title} />
+<meta name="description" content={description} />
+<meta name="author" content={SITE_AUTHOR} />
+{keywords.length > 0 && <meta name="keywords" content={keywords.join(",")} />}
+<meta name="theme-color" content="#a50026" />
+<meta
+ name="theme-color"
+ content="#f46d43"
+ media="(prefers-color-scheme: dark)"
+/>
+
+<!-- Open Graph / Facebook -->
+<meta property="og:type" content="website" />
+<meta property="og:url" content={Astro.url} />
+<meta property="og:title" content={title} />
+<meta property="og:description" content={description} />
+{image && <meta property="og:image" content={new URL(image, Astro.url)} />}
+
+<!-- Twitter -->
+<meta property="twitter:card" content="summary_large_image" />
+<meta property="twitter:url" content={Astro.url} />
+<meta property="twitter:title" content={title} />
+<meta property="twitter:description" content={description} />
+{image && <meta property="twitter:image" content={new URL(image, Astro.url)} />}
+
+<ClientRouter />
+
+<script is:inline>
+ const root = document.documentElement;
+ const theme = localStorage.getItem("theme");
+ if (
+ theme === "dark" ||
+ (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches)
+ ) {
+ root.classList.add("theme-dark");
+ } else {
+ root.classList.remove("theme-dark");
+ }
+</script>
diff --git a/src/components/BlogCard.astro b/src/components/BlogCard.astro
new file mode 100644
index 0000000..7ab42d7
--- /dev/null
+++ b/src/components/BlogCard.astro
@@ -0,0 +1,38 @@
+---
+import type { CollectionEntry } from "astro:content";
+
+interface Props extends CollectionEntry<"blog"> {}
+
+const { id, data } = Astro.props;
+const { title, description, dateCreated, lang } = data;
+
+const href = `/blog/read/${id}`;
+---
+
+<article>
+ <h2>
+ <a {href}>{title}</a>
+ </h2>
+ <p>{description}</p>
+ <footer>
+ <span><time datetime={(dateCreated as Date).toISOString()}>{
+ new Intl.DateTimeFormat(lang, {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZoneName: "long",
+ }).format(dateCreated)
+ }</time></span>
+ </footer>
+</article>
+
+<style>
+ article {
+ border-block-end: 1px solid #181818;
+ padding-block-end: 1rem;
+ margin-block: 0.5rem;
+ }
+</style>
diff --git a/src/components/Citations.astro b/src/components/Citations.astro
new file mode 100644
index 0000000..cc82eda
--- /dev/null
+++ b/src/components/Citations.astro
@@ -0,0 +1,39 @@
+---
+import type { CollectionEntry } from "astro:content";
+import { getEntries } from "astro:content";
+
+type Props = { citations: CollectionEntry<"blog">["data"]["relatedPosts"] };
+const citations = await getEntries(Astro.props.citations ?? []);
+---
+{
+ citations.length > 0 &&
+ (
+ <aside>
+ <p>O autor recomenda ler também:</p>
+ <ul>
+ {
+ citations.map(({ collection, id, data }) => (
+ <li
+ itemprop="citation"
+ itemscope
+ itemtype="http://schema.org/BlogPosting"
+ itemid={Astro.url.href.replace(/[^\/]*\/?$/, id)}
+ >
+ <a href={`/${collection}/read/${id}`}>
+ <cite itemprop="headline">{data.title}</cite>
+ </a>
+ </li>
+ ))
+ }
+ </ul>
+ </aside>
+ )
+}
+
+<style>
+ @media print {
+ aside {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/components/Commit.astro b/src/components/Commit.astro
new file mode 100644
index 0000000..3ee284a
--- /dev/null
+++ b/src/components/Commit.astro
@@ -0,0 +1,49 @@
+---
+import type { Commit } from "@lib/git/types";
+import { gitDir } from "@lib/git";
+
+type Props = Commit;
+
+const { hash, files, author, signature } = Astro.props;
+
+const git = await gitDir;
+---
+<p>Git commit info:</p>
+<dl>
+ <dt>Hash</dt>
+ <dd>{hash}</dd>
+ <dt>Files</dt>
+ {files.map((file) => <dd>{file.pathname.replace(git, "")}</dd>)}
+ <dt>Author</dt>
+ <dd>{author.name} &lt;{author.email}&gt;</dd>
+ {
+ signature && (
+ <dt>Commit Signature</dt>
+ <dd>
+ <dl>
+ <dt>Type</dt>
+ <dd>{signature.type}</dd>
+ <dt>Signer</dt>
+ <dd>{signature.signerName}</dd>
+ <dt>Key fingerprint</dt>
+ <dd>{signature.keyFingerPrint}</dd>
+ </dl>
+ </dd>
+ )
+ }
+</dl>
+
+<style>
+ dl {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ dl > dt, dd {
+ display: inline-block;
+ }
+
+ dt::after {
+ content: ": ";
+ }
+</style>
diff --git a/src/components/CopyrightNotice.astro b/src/components/CopyrightNotice.astro
new file mode 100644
index 0000000..2aa72ad
--- /dev/null
+++ b/src/components/CopyrightNotice.astro
@@ -0,0 +1,66 @@
+---
+import CC from "./licenses/CC.astro";
+import WTFPL from "./licenses/WTFPL.astro";
+import { CREATIVE_COMMONS_LICENSES, LICENSES } from "../consts.ts";
+
+export interface Props {
+ title: string;
+ author: string;
+ email?: string;
+ website?: string;
+ dateCreated: Date;
+ license?: typeof LICENSES[number];
+}
+
+let { license = "public domain" } = Astro.props;
+
+let Notice = 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/DateSelector.astro b/src/components/DateSelector.astro
new file mode 100644
index 0000000..324bc41
--- /dev/null
+++ b/src/components/DateSelector.astro
@@ -0,0 +1,141 @@
+---
+interface Props {
+ date: Date;
+ years: number[];
+ months: number[];
+ days?: number[];
+}
+
+const { date, years, months, days } = Astro.props;
+
+const y = date.getFullYear();
+const m = date.getMonth() + 1;
+const d = date.getDate();
+let yI = 0;
+let mI = 0;
+let dI = 0;
+
+const list = new Intl.ListFormat("pt-PT", { type: "unit", style: "narrow" });
+
+const pad = (n: number) => String(n).padStart(2, "0");
+---
+<nav>
+ <span role="list">
+ Anos:{" "}
+ {
+ list.formatToParts(years.map((y) =>
+ new Intl.DateTimeFormat("pt-PT", { year: "2-digit" }).format(
+ new Date(
+ Date.UTC(
+ y,
+ 0,
+ 1,
+ date.getTimezoneOffset() / 60,
+ date.getTimezoneOffset() % 60,
+ ),
+ ),
+ )
+ )).map(({ type, value }: { type: string; value: string }) => {
+ switch (type) {
+ case "element": {
+ const year = years[yI++];
+ return (
+ <span role="listitem"><a
+ class:list={[{ active: year === y }]}
+ href={`/blog/${year}`}
+ >{value}</a></span>
+ );
+ }
+ case "literal": {
+ return (
+ <span>{value}</span>
+ );
+ }
+ }
+ })
+ }
+ </span>
+ <br />
+ <span role="list">
+ Meses:{" "}
+ {
+ list.formatToParts(months.map((m) =>
+ new Intl.DateTimeFormat("pt-PT", { month: "short" }).format(
+ new Date(
+ Date.UTC(
+ y,
+ m - 1,
+ 1,
+ date.getTimezoneOffset() / 60,
+ date.getTimezoneOffset() % 60,
+ ),
+ ),
+ )
+ )).map(({ type, value }: { type: string; value: string }) => {
+ switch (type) {
+ case "element": {
+ const month = months[mI++];
+ return (
+ <span role="listitem"><a
+ class:list={[{ active: month === m }]}
+ href={`/blog/${y}/${pad(month)}`}
+ >{value}</a></span>
+ );
+ }
+ case "literal": {
+ return (
+ <span>{value}</span>
+ );
+ }
+ }
+ })
+ }
+ </span>
+ {
+ days &&
+ (
+ <><br /><span role="list">
+ Dias:{" "}
+ {
+ list.formatToParts(days.map((d) => {
+ return new Intl.DateTimeFormat("pt-PT", { day: "numeric" })
+ .format(
+ new Date(
+ Date.UTC(
+ y,
+ m - 1,
+ d,
+ date.getTimezoneOffset() / 60,
+ date.getTimezoneOffset() % 60,
+ ),
+ ),
+ );
+ })).map(({ type, value }: { type: string; value: string }) => {
+ switch (type) {
+ case "element": {
+ const day = days[dI++];
+ return (
+ <span role="listitem"><a
+ class:list={[{ active: day === d }]}
+ href={`/blog/${y}/${pad(m)}/${pad(d)}`}
+ >{value}</a></span>
+ );
+ }
+ case "literal": {
+ return (
+ <span>{value}</span>
+ );
+ }
+ }
+ })
+ }
+ </span></>
+ )
+ }
+</nav>
+
+<style>
+ a.active {
+ font-weight: bolder;
+ }
+</style>
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
new file mode 100644
index 0000000..11c62c4
--- /dev/null
+++ b/src/components/Footer.astro
@@ -0,0 +1,62 @@
+---
+
+---
+
+<footer>
+ <address>
+ Sítio web de <a href={Astro.site} target="_blank" rel="author"
+ >João Augusto Costa Branco Marado Torres</a>
+ </address>
+ <section id="copying">
+ <h2>Licença de <span lang="en">Software</span></h2>
+ <div lang="en">
+ <p>
+ <small>
+ &lt;<a href="/" hreflang="pt-PT">cravodeabril.pt</a>&gt; Copyright
+ &copy; 2025 João Augusto Costa Branco Marado Torres
+ </small>
+ </p>
+ <p>
+ <small>
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the <a
+ href="https://www.gnu.org/licenses/agpl-3.0.html"
+ target="_blank"
+ rel="external license"
+ >GNU Affero General Public License</a> as published by the Free
+ Software Foundation, either version 3 of the License, or (at your
+ option) any later version.
+ </small>
+ </p>
+ <p>
+ <small>
+ This program is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Affero General Public License for more details.
+ </small>
+ </p>
+ <p>
+ <small>
+ You should have received a copy of the GNU Affero General Public
+ License along with this program. If not, see <a
+ href="https://www.gnu.org/licenses/"
+ target="_blank"
+ rel="external"
+ >https://www.gnu.org/licenses</a>
+ </small>
+ </p>
+ </div>
+ </section>
+ <nav>
+ <ul>
+ <li><a>Código de Conduta</a></li>
+ <li><a>Declaração de Exoneração de Responsabilidade</a></li>
+ <li><a>Aviso sobre cookies</a></li>
+ <li><a>Declaração de acessibilidade</a></li>
+ <li><a>Apoio</a></li>
+ <li><a>Contacto</a></li>
+ <li><a>Código fonte</a></li>
+ </ul>
+ </nav>
+</footer>
diff --git a/src/components/Header.astro b/src/components/Header.astro
new file mode 100644
index 0000000..874a496
--- /dev/null
+++ b/src/components/Header.astro
@@ -0,0 +1,41 @@
+---
+import HeaderLink from "./HeaderLink.astro";
+---
+
+<header>
+ <h1>&lt;<a href="/">cravodeabril.pt</a>&gt;</h1>
+ <search>
+ <form
+ action="https://www.google.com/search"
+ target="_blank"
+ rel="external noreferrer search"
+ role="search"
+ autocomplete="on"
+ name="search"
+ >
+ <p>
+ <label>Barra de pesquisa: <input
+ name="q"
+ type="search"
+ placeholder={`site:${Astro.site} consulta de pesquisa`}
+ value={`site:${Astro.site} `}
+ required
+ title={`"site:${Astro.site} " é usado para que os resultados da pesquisa fiquem restritos a este website`}
+ pattern={`site:${Astro.site} .+`}
+ size={`site:${Astro.site} .+`.length}
+ /></label>
+ </p>
+ <p><button type="submit">Pesquisar</button></p>
+ <p>
+ <small>Esta pesquisa é efectuada pelo Google e utiliza software
+ proprietário.</small>
+ </p>
+ </form>
+ </search>
+ <nav>
+ <ul>
+ <li><HeaderLink href="/blog">Publicações</HeaderLink></li>
+ <li><HeaderLink href="/blog/keywords">Palavras-Chave</HeaderLink></li>
+ </ul>
+ </nav>
+</header>
diff --git a/src/components/HeaderLink.astro b/src/components/HeaderLink.astro
new file mode 100644
index 0000000..8c01f92
--- /dev/null
+++ b/src/components/HeaderLink.astro
@@ -0,0 +1,18 @@
+---
+import type { HTMLAttributes } from "astro/types";
+
+type Props = HTMLAttributes<"a">;
+
+const { href, class: className, ...props } = Astro.props;
+const pathname = Astro.url.pathname;
+const isActive = href === pathname;
+---
+
+<a {href} class:list={[className, { current: isActive }]} {...props}>
+ <slot />
+</a>
+<style>
+ a.current {
+ font-weight: bolder;
+ }
+</style>
diff --git a/src/components/Keywords.astro b/src/components/Keywords.astro
new file mode 100644
index 0000000..1800d5a
--- /dev/null
+++ b/src/components/Keywords.astro
@@ -0,0 +1,52 @@
+---
+import type { CollectionEntry } from "astro:content";
+
+interface Props {
+ keywords: CollectionEntry<"blog">["data"]["keywords"];
+}
+
+const { keywords } = Astro.props;
+---
+<aside>
+ <ul>
+ {
+ keywords.map((x) => (
+ <li>
+ <a rel="tag" itemprop="keywords" href={`/blog/keywords/${x}`}><b>{
+ x
+ }</b></a>
+ </li>
+ ))
+ }
+ </ul>
+</aside>
+
+<style>
+ ul {
+ list-style-type: none;
+ padding-inline-start: 0;
+ max-width: 40ch;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 1em;
+ margin-inline: auto;
+ }
+
+ ul > li {
+ font-size: smaller;
+ display: inline-block;
+ }
+
+ ul > li::before {
+ content: "#";
+ color: var(--color-active);
+ font-weight: bolder;
+ }
+
+ @media print {
+ aside {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/components/ReadingTime.astro b/src/components/ReadingTime.astro
new file mode 100644
index 0000000..2c8c676
--- /dev/null
+++ b/src/components/ReadingTime.astro
@@ -0,0 +1,26 @@
+---
+import type { CollectionEntry } from "astro:content";
+import { default as readingTime } from "reading-time";
+
+type Props = {
+ body: CollectionEntry<"blog">["body"];
+ lang: CollectionEntry<"blog">["data"]["lang"];
+};
+
+const { body, lang } = Astro.props;
+
+const reading = readingTime(body ?? "", {});
+const minutes = Math.ceil(reading.minutes);
+const estimative = new Intl.DurationFormat(lang, {
+ style: "long",
+}).format({ minutes });
+const duration = `PT${
+ Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "H" : ""
+}${minutes % 60 > 0 ? minutes % 60 + "M" : ""}`;
+---
+<p>
+ <data itemprop="timeRequired" value={duration}><bdi>Tempo de leitura
+ estimado</bdi>: ~ {estimative}</data>
+ <data itemprop="wordCount" value={reading.words}
+ >(<bdi>palavras</bdi>: {reading.words})</data>
+</p>
diff --git a/src/components/SignaturesTableRows.astro b/src/components/SignaturesTableRows.astro
new file mode 100644
index 0000000..eafd4de
--- /dev/null
+++ b/src/components/SignaturesTableRows.astro
@@ -0,0 +1,51 @@
+---
+import { type Summary, VerificationResult } from "@lib/pgp/summary";
+import { PublicKey } from "openpgp";
+
+type Props = { summary: Summary; rowspan?: number };
+
+const { summary, rowspan } = Astro.props;
+const [type, _, info] = summary;
+
+let name: string = "";
+let email: string = "";
+let fingerprint: string = "";
+let trust: number | undefined = NaN;
+let commiter: boolean | undefined = undefined;
+let revoked: boolean | undefined = undefined;
+let keyType: "primary" | "sub" | "" = "";
+
+switch (type) {
+ case VerificationResult.MISSING_KEY:
+ fingerprint = typeof info.keyID === "string"
+ ? info.keyID
+ : info.keyID.toHex();
+ break;
+ case VerificationResult.TRUSTED_KEY:
+ const match = info.userID[0].match(/^(.*?)\s*(?:\((.*?)\))?\s*<(.+?)>$/);
+
+ if (match) {
+ name = match[1];
+ email = match[3];
+ }
+
+ fingerprint = info.key.getFingerprint();
+ trust = info.trust;
+ keyType = info.key instanceof PublicKey ? "primary" : "sub";
+ break;
+}
+
+const names = name.split(/\s/);
+const firstName = names[0];
+const lastName = names.length > 1 ? ` ${names[names.length - 1]}` : "";
+---
+<td {rowspan}><span title={name}>{firstName}{lastName}</span></td>
+<td {rowspan}>{email}</td>
+<td {rowspan}>
+ <span title={fingerprint.replace(/(....)/g, "$1 ").trim()}>
+ {`0x${fingerprint.slice(-8)}`}
+ </span>
+</td>
+<td {rowspan}>{trust}</td>
+<td {rowspan}>{commiter}</td>
+<td {rowspan}>{revoked}</td>
diff --git a/src/components/Translations.astro b/src/components/Translations.astro
new file mode 100644
index 0000000..b0164bb
--- /dev/null
+++ b/src/components/Translations.astro
@@ -0,0 +1,107 @@
+---
+import type { CollectionEntry } from "astro:content";
+import {
+ getFlagEmojiFromLocale,
+ getLanguageNameFromLocale,
+} from "../utils/lang";
+import { getEntries } from "astro:content";
+
+interface Props {
+ lang: string;
+ translations?: CollectionEntry<"blog">["data"]["translations"];
+}
+
+const { lang } = Astro.props;
+
+const translations = await getEntries(Astro.props.translations ?? []).then(
+ (translations) =>
+ translations.sort((x, y) => x.data.lang.localeCompare(y.data.lang)),
+);
+---
+
+{
+ /* TODO: What about <https://schema.org/translationOfWork> and <https://schema.org/translator>? */
+}
+
+{
+ translations.length > 0 && (
+ <aside>
+ <nav>
+ <p>Traduções:</p>
+ <ul class="translations">
+ {
+ translations.map(async (
+ { data, collection, id },
+ ) => {
+ const active = lang.localeCompare(data.lang) === 0;
+ return (
+ <li
+ itemprop={active ? undefined : "workTranslation"}
+ itemscope={!active}
+ itemtype={active ? undefined : "http://schema.org/BlogPosting"}
+ itemid={active
+ ? undefined
+ : new URL(`${collection}/read/${id}`, Astro.site).href}
+ >
+ <a
+ href={`/${collection}/read/${id}`}
+ class:list={[{ active }]}
+ rel={active ? undefined : "alternate"}
+ hreflang={active ? undefined : data.lang}
+ type="text/html"
+ title={data.title}
+ ><span class="emoji">{getFlagEmojiFromLocale(data.lang)}</span>
+ {getLanguageNameFromLocale(data.lang)} (<span
+ itemprop="inLanguage"
+ >{data.lang}</span>)</a>
+ </li>
+ );
+ })
+ }
+ </ul>
+ </nav>
+ </aside>
+ )
+}
+
+<style>
+ .translations {
+ list-style-type: none;
+ padding-inline-start: 0;
+ }
+
+ .translations > li {
+ display: inline;
+ }
+
+ .translations > li > a > .emoji {
+ text-decoration: none;
+ font-family: var(--ff-icons);
+ }
+
+ .translations > li > a.active {
+ font-weight: bolder;
+ text-decoration: underline;
+ color: var(--color-active);
+ }
+
+ nav:has(.translations) {
+ display: flex;
+ gap: 1rem;
+ }
+
+ nav:has(.translations) > * {
+ font-size: smaller;
+ }
+
+ .translations > li:not(:first-child)::before {
+ content: "|";
+ margin-inline: 0.5em;
+ }
+
+ @media print {
+ aside {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/components/licenses/CC.astro b/src/components/licenses/CC.astro
new file mode 100644
index 0000000..61f9114
--- /dev/null
+++ b/src/components/licenses/CC.astro
@@ -0,0 +1,120 @@
+---
+import type { Props as BaseProps } from "../CopyRightNotice.astro";
+interface Props extends BaseProps {}
+
+let { title, website, author, dateCreated, license } = Astro.props;
+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/`;
+---
+
+<footer itemprop="copyrightNotice">
+ {
+ publicdomain ? (
+ <p>
+ <small>
+ <a href={Astro.url}>{title}</a> by <span
+ itemprop="copyrightholder"
+ itemscope
+ itemtype="https://schema.org/Person"
+ >{
+ website ? (
+ <a
+ itemprop="url"
+ rel="author external noreferrer"
+ target="_blank"
+ href={website}
+ content={website}
+ ><span itemprop="name">{author}</span></a>
+ ) : author
+ }</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">{
+ dateCreated.getFullYear()
+ }</span> by <span
+ itemprop="copyrightholder"
+ itemscope
+ itemtype="https://schema.org/Person"
+ >{
+ website ? (
+ <a
+ itemprop="url"
+ href={website}
+ target="_blank"
+ rel="author external noreferrer"
+ content={website}
+ ><span itemprop="name">{author}</span></a>
+ ) : author
+ }</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/licenses/WTFPL.astro b/src/components/licenses/WTFPL.astro
new file mode 100644
index 0000000..feab7ec
--- /dev/null
+++ b/src/components/licenses/WTFPL.astro
@@ -0,0 +1,53 @@
+---
+import type { Props as BaseProps } from "../CopyrightNotice.astro";
+interface Props extends BaseProps {}
+
+let { website, author, email, dateCreated } = Astro.props;
+---
+
+<footer itemprop="copyrightNotice">
+ <p>
+ <small>
+ Copyright © <span itemprop="copyrightYear">{
+ dateCreated.getFullYear()
+ }</span>
+ <span
+ itemprop="copyrightholder"
+ itemscope
+ itemtype="https://schema.org/Person"
+ >{
+ website ? (
+ <a
+ itemprop="url"
+ rel="author external noreferrer"
+ target="_blank"
+ href={website}
+ content={website}
+ ><span itemprop="name">{author}</span></a>
+ ) : author
+ }
+ {
+ email && (
+ <>&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/signature/Authors.astro b/src/components/signature/Authors.astro
new file mode 100644
index 0000000..43a2b36
--- /dev/null
+++ b/src/components/signature/Authors.astro
@@ -0,0 +1,281 @@
+---
+import { toPK } from "@lib/pgp";
+import { createKeyFromArmor } from "@lib/pgp/create";
+import type { Verification } from "@lib/pgp/verify";
+import { defined, get, instanciate } from "@utils/anonymous";
+import { type CollectionEntry, z } from "astro:content";
+import type { EntityTypesEnum } from "src/consts";
+import qrcode from "yaqrcode";
+
+interface Props {
+ verifications: NonNullable<Verification["verifications"]>;
+ expectedSigners: {
+ entity: CollectionEntry<"entity">;
+ role: z.infer<typeof EntityTypesEnum>;
+ }[];
+ commitSignerKey?: string;
+}
+
+const {
+ verifications: verificationsPromise,
+ expectedSigners,
+ commitSignerKey,
+} = Astro.props;
+
+const fingerprintToData = new Map<
+ string,
+ { websites: URL[]; role: z.infer<typeof EntityTypesEnum> }
+>();
+
+for (const { entity, role } of expectedSigners) {
+ const key = await createKeyFromArmor(entity.data.publickey.armor);
+ const fingerprint = key.getFingerprint();
+ fingerprintToData.set(fingerprint, {
+ websites: entity.data.websites?.map(instanciate(URL)) ?? [],
+ role,
+ });
+}
+
+let verifications = await Promise.all(
+ verificationsPromise.map(async ({ key, keyID, userID, verified }) => {
+ return {
+ key: await key,
+ keyID,
+ userID: await userID,
+ verified: await verified.catch(() => false),
+ };
+ }),
+);
+
+const expectedKeys = await Promise.all(
+ expectedSigners.map(get("entity")).map(({ data }) =>
+ createKeyFromArmor(data.publickey.armor)
+ ),
+);
+
+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 e as suas chaves públicas.
+ </p>
+ </caption>
+ <colgroup>
+ <col />
+ <col />
+ </colgroup>
+ <colgroup>
+ <col />
+ <col />
+ <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>
+ </tr>
+ </thead>
+ <tbody>
+ {
+ verifications.map(({ userID, key, keyID, verified }) => {
+ const fingerprint = key
+ ? toPK(key).getFingerprint()
+ : undefined;
+ const info = fingerprint
+ ? fingerprintToData.get(fingerprint)
+ : undefined;
+ const primary = userID?.[0];
+ let role = "";
+ switch (info?.role) {
+ case "author": {
+ role = "Autor";
+ break;
+ }
+ case "co-author": {
+ role = "Co-autor";
+ break;
+ }
+ case "translator": {
+ role = "Tradutor";
+ break;
+ }
+ }
+ return (
+ <tr>
+ <th scope="row">
+ <address
+ itemprop="author"
+ itemscope
+ itemtype="https://schema.org/Person"
+ >
+ {
+ primary?.name
+ ? info?.websites[0] ? (
+ <a
+ itemprop="url"
+ rel="author external noreferrer"
+ target="_blank"
+ href={info.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;</>
+ )
+ : ""
+ }
+ {
+ primary && (
+ <>
+ <button
+ popovertarget={`user-id-${fingerprint}`}
+ class="emoji"
+ >
+ ➕
+ </button>
+ <section
+ class="user-id"
+ popover
+ id={`user-id-${fingerprint}`}
+ >
+ {
+ userID && (
+ <><p><code>UserID</code>s</p><ul>
+ {userID.map((x) => <li>{x.userID}</li>)}
+ </ul></>
+ )
+ }
+ {
+ info?.websites && (
+ <><p>Websites</p><ul>
+ {
+ info.websites.map((
+ x,
+ ) => (
+ <li><a href={x}>{x}</a></li>
+ ))
+ }
+ </ul></>
+ )
+ }
+ </section>
+ </>
+ )
+ }
+ </address>
+ </th>
+ <td>{role}</td>
+ <td>
+ <><span title={fingerprint?.replace(/(....)/g, "$1 ")}>{
+ key
+ ? "0x" + toPK(key).getKeyID().toHex()
+ : "0x" + keyID.toHex()
+ }</span>
+ {
+ key && false && (
+ <img
+ src={qrcode(toPK(key).armor(), {
+ typeNumber: 40,
+ errorCorrectLevel: "L",
+ })}
+ />
+ )
+ }
+ {
+ key &&
+ (
+ <button popovertarget={`armor-${fingerprint}`}>
+ Armor
+ </button>
+ <section class="armor" popover id={`armor-${fingerprint}`}>
+ <pre><code>{toPK(key).armor()}</code></pre>
+ </section>
+ )
+ }
+ </>
+ </td>
+ <td>{verified ? "✅" : "❌"}</td>
+ <td>
+ {
+ commitSignerKey &&
+ key?.getFingerprint().toUpperCase()?.endsWith(
+ commitSignerKey.toUpperCase(),
+ ) && "✅"
+ }
+ </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: #e7e7e7;
+ }
+
+ section[popover] {
+ text-align: initial;
+ }
+ section[popover].armor {
+ max-height: calc(200dvh / 3);
+ }
+ @media (prefers-color-scheme: dark) {
+ tbody tr:nth-child(odd) {
+ background-color: #181818;
+ }
+ }
+</style>
diff --git a/src/components/signature/Commit.astro b/src/components/signature/Commit.astro
new file mode 100644
index 0000000..9cc997a
--- /dev/null
+++ b/src/components/signature/Commit.astro
@@ -0,0 +1,87 @@
+---
+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/signature/Downloads.astro b/src/components/signature/Downloads.astro
new file mode 100644
index 0000000..ac8215f
--- /dev/null
+++ b/src/components/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("read/", "").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/signature/Signature.astro b/src/components/signature/Signature.astro
new file mode 100644
index 0000000..57e9902
--- /dev/null
+++ b/src/components/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/signature/Summary.astro b/src/components/signature/Summary.astro
new file mode 100644
index 0000000..6ab6bf5
--- /dev/null
+++ b/src/components/signature/Summary.astro
@@ -0,0 +1,279 @@
+---
+import {
+ createVerificationSummary,
+ 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 createVerificationSummary(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>
diff --git a/src/consts.ts b/src/consts.ts
new file mode 100644
index 0000000..ee6c580
--- /dev/null
+++ b/src/consts.ts
@@ -0,0 +1,29 @@
+import { z } from "astro/zod";
+
+export const SITE_TITLE = "Cravo de Abril";
+export const SITE_DESCRIPTION = "Um domínio da liberdade!";
+export const SITE_AUTHOR = "João Augusto Costa Branco Marado Torres";
+
+export const KEYWORDS = ["Portugal", "democracy"] as const;
+export const KeywordsEnum = z.enum(KEYWORDS);
+
+export const ENTITY_TYPES = ["author", "co-author", "translator"] as const;
+export const EntityTypesEnum = z.enum(ENTITY_TYPES);
+
+export const CREATIVE_COMMONS_LICENSES = [
+ "CC0",
+ "CC-BY",
+ "CC-BY-SA",
+ "CC-BY-ND",
+ "CC-BY-NC",
+ "CC-BY-NC-SA",
+ "CC-BY-NC-ND",
+] as const;
+export const LICENSES = [
+ ...CREATIVE_COMMONS_LICENSES,
+ "WTFPL",
+ "public domain",
+] as const;
+export const LicensesEnum = z.enum(LICENSES);
+
+export const TRUSTED_KEYS_DIR = new URL(`file://${Deno.cwd()}/public/keys/`);
diff --git a/src/content.config.ts b/src/content.config.ts
new file mode 100644
index 0000000..f652cc3
--- /dev/null
+++ b/src/content.config.ts
@@ -0,0 +1,116 @@
+import { file, glob } from "astro/loaders";
+import { defineCollection, reference, z } from "astro:content";
+//import { parse } from "@std/toml";
+import { parse } from "toml";
+import { EntityTypesEnum, KeywordsEnum, LicensesEnum } from "./consts.ts";
+import { get, instanciate } from "./utils/anonymous.ts";
+import { isValidLocale } from "./utils/lang.ts";
+
+const Blog = z.object({
+ title: z.string().trim(),
+ subtitle: z.string().trim().optional(),
+ description: z.string().trim().optional(),
+ keywords: z.array(KeywordsEnum).optional().refine(
+ (keywords) => new Set(keywords).size === (keywords?.length ?? 0),
+ {
+ message: "Keywords must be unique",
+ },
+ ).transform((keywords) =>
+ keywords !== undefined ? new Set(keywords).values().toArray() : undefined
+ ),
+ dateCreated: z.coerce.date(),
+ dateUpdated: z.coerce.date().optional(),
+ locationCreated: z.string().trim().optional(),
+ relatedPosts: z.array(reference("blog")).default([]).refine(
+ (posts) => new Set(posts).size === (posts?.length ?? 0),
+ {
+ message: "Related posts referenced multiple times",
+ },
+ ).transform((x) => new Set(x)).transform((set) => set.values().toArray()),
+ lang: z.string().trim().refine(isValidLocale),
+ translationOf: reference("blog").optional(),
+ signers: z.array(
+ z.object({ entity: reference("entity"), role: EntityTypesEnum }),
+ ).optional().refine(
+ (signers) => {
+ if (signers === undefined) return true;
+ return signers.filter((s) => s.role === "author").length <= 1;
+ },
+ {
+ message: "There can only be one author",
+ },
+ ).refine(
+ (signers) => {
+ const ids = signers?.map(get("entity")) ?? [];
+ return new Set(ids).size === ids.length;
+ },
+ {
+ message: "Reusing signers",
+ },
+ //).transform((signers) =>
+ // Object.fromEntries(new Map(signers?.map(({ entity, ...rest }) => [entity, rest]) ?? []))
+ ),
+ license: LicensesEnum,
+}).refine(
+ ({ dateCreated, dateUpdated }) =>
+ dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(),
+ { message: "Update before creation" },
+).refine(
+ ({ translationOf, keywords }) =>
+ translationOf !== undefined || (keywords?.length ?? 0) > 0,
+ {
+ message: "Originals must include at least one keyword",
+ path: ["keywords"],
+ },
+).refine(
+ ({ translationOf, keywords }) =>
+ (translationOf === undefined) !== ((keywords?.length ?? 0) <= 0),
+ {
+ message: "we will use this information from the original, " +
+ "so no need to specify it for translations",
+ path: ["keywords"],
+ },
+).refine(
+ ({ translationOf, relatedPosts }) =>
+ (translationOf === undefined) || (relatedPosts.length <= 0),
+ {
+ message: "we will use this information from the original, " +
+ "so no need to specify it for translations",
+ path: ["relatedPosts"],
+ },
+).refine(
+ ({ translationOf, signers = [] }) =>
+ (translationOf === undefined) ||
+ (signers.values().every(({ role }) => role !== "translator")),
+ {
+ message: "There can't be translator signers on non translated work",
+ path: ["signers"],
+ },
+);
+
+const blog = defineCollection({
+ loader: glob({ base: "./public/blog", pattern: "+([0-9a-z-]).md" }),
+ schema: Blog,
+});
+
+export type Blog = z.infer<typeof Blog>;
+
+const Entity = z.object({
+ websites: z.array(z.string().url().trim()).default([]).transform((websites) =>
+ websites.map(instanciate(URL))
+ ),
+ publickey: z.object({
+ armor: z.string().trim(),
+ }),
+});
+
+type Entity = z.infer<typeof Entity>;
+
+const entity = defineCollection({
+ loader: file("./src/content/entities.toml", {
+ parser: (text) => parse(text).entities as Entity[],
+ }),
+ schema: Entity,
+});
+
+export const collections = { blog, entity };
diff --git a/src/content/entities.toml b/src/content/entities.toml
new file mode 100644
index 0000000..a05749a
--- /dev/null
+++ b/src/content/entities.toml
@@ -0,0 +1,85 @@
+# [[entities]]
+# id = ''
+# website = ['']
+# [entities.publickey]
+# armor = '''
+# -----BEGIN PGP PUBLIC KEY BLOCK-----
+# ...
+# -----END PGP PUBLIC KEY BLOCK-----
+# '''
+
+[[entities]]
+id = 'cravodeabril'
+website = ['https://cravodeabril.pt']
+[entities.publickey]
+armor = '''
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBGL8N9ABEAC7M2bDtOMYFzzj6CvxsD94aBilharzXYvGTw9GbZqfEl8G3xit
+ShGES3LLhOe2lKSSDGCXbYoQ9eVm+gt3riTayFmGsDWaIEmCRobtUZ5AVjl95ds+
+NMOI5AG6fx5DcG1Kg5x5ULHBdD4OFUtG1uM2WsviGVVroZl9PJLXW1jEiTbBGKjM
+KHyhydjYJ5ZEjRNWhKlxplO83P6CwREQwCX7yliSqpiGIHH1h8Lf+2pmv1AzWelL
+2RXyX7qekcaTpihpGgA5luUCCKk2C8mV9QMnRSbKFKT/r3FWmHX8X8TYnRUYaoYL
+M/FkWeUCJoYQEjQMFKgWTBnZiW29FtwYX3streO7/+abWUzsAWG0euDeSuNm1VCR
+jafMegr+ihpWqB9YL6aAYcmO7vDQ1sqMKALjt2bHrtJsGjM/qzSFH1IK38koQ9IR
+8Or6/sPStS9t5ug4968NPAC17j+I7nUqE6AAdkP8T9FTn/m6mdZOKxcMPTgrPM6a
+s3LTRKwueMZ4SA+Gs7ZfFGYsl9uG5g5v85N+/abdC3oNkS4ikVSTp0M18w5Ywgdy
+JXsnAPM1mz/XTCNu6vYT8JpRw5xsfNgc8cL/h6vY317DScABIlZ5uLTXMaqlEiZb
+L3QyPnNKyLM+D/2Vh0j7nNzzydREpaCLSPFzfi9T6bLHCFzPOMGtSThCvQARAQAB
+tFtKb8OjbyBBdWd1c3RvIENvc3RhIEJyYW5jbyBNYXJhZG8gVG9ycmVzIChodHRw
+czovL2NyYXZvZGVhYnJpbC5wdCkgPHRvcnJlcy5kZXZAZGlzcm9vdC5vcmc+iQJa
+BBMBCABEAhsDBQkGMQ/6BQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAFiEE0vD/
+OlpfIlcPSxI48oBRM0M48CEFAmgtFK8CGQEACgkQ8oBRM0M48CHvdRAAjPnDFuXY
+oGiDHEjHaLemBwL6WC6ct7e/20V70uzmyRspl24boJS8e+1YpLi7yjKiGidq3VqB
+YhZkR/8mPJ+2/zY6+4Qt1Nj9rJd4+GiWW3jd2sXlUwh7OEHZY4yS4bcrGSXnhLS2
+NHz+bQ1ICMzYbhNZTFTIaO4hEB/9IANejSCJI+OKjW8v0Ae+W47kqt4ZT/g31YiI
+0HNGnlQB6ZRV89fcHXpTR1E6Yr6XT+EBr22ziaAZlhBPdYaiP7WZUTub6fE4bkkW
+etuGCQxqLWtZ7BxLgurtbSU9z0O5HEYwp7wBNdPALV+XsOxIuL96jum0+ZsSSTQT
+Rf6ks8g/sDiMKaWlIWNqsiKjpPoL76YDq8M0yka6Hvb0Wc1ebjQigpkWP6H3eC/v
+Gq3lERuJ1bo6oyG8EkHBv3No9IF994NIf4Dq8X9jQ+aHFKfgHR8nUmZPH44GMVk0
+KsLgs0syUch3ArassRXOZlHSnNLuhy9aymP1o1pMlqCeS9K4+r/9vIbQhzTqMKOh
+UcLKj+YGaTKnZ4Iu8W4d3JiY17GentV8h+btZIPHXHxsVMhrbdr+jU/PIJeOksNz
+nQvua9lvhUueILU8Dx7Ql/lQXbnGT1Zz/jw6dKFooif9PIfRgZ+wJZx8X4/RbU/g
+JqYQSWouGyz7U/1GGgkb/1o7+um99O74a3i5Ag0EYvw30AEQAMUJ/2T2T9c0OAET
+PktIqUQn2xtfrhqvWpzSJdwkruya2zKRKHmQYLfFOZ2rUCTdQauWicmcnWb48cT4
+sWMrQFAVenZe6Ml7jB8q0BVA/i1lvraK7xLhjGTPhun1mmVys43xSwqzv+aqTUyI
+8/SerRYfF3rCClYDJHn/F0FfutbqJYDw7NHRSEp2s3ly1wGQVlKl9ZMO4KTIO6QT
+vXo0/0WzvDn5kqBPijpgujDW/zPA+rNtmD7wDaUw1AEK9fE9K/pIGlaWLNm6LbpY
+gXHCujuikTXG2oUdlwgy/4PqiA79o2D21GfVY6YWChco6CZd2cUjwYQjJUnYLLDw
+sB/N4GzWa35hURyYZH3jITfa3U1nDn9TTOJL8x6aAsKQhJ31Pxhbtnd4KeC6KhXK
+0GqnH6aMXfLZEZLy/QSH/QedqhuuwlU+aQ3DUqZj7VBFGBFrUnDt0K5UfCIv1TCf
+vTOufJgKe6G9wh2RHs2nD/sqYTdb/zQwHCnnzEEFFer5PXq32PZZlXjrB1AfXNIL
+YbASSXNybaQHBifwPIXy0HvExmcUZCSLNQ3kuJuwg2RzjUjXfurY6YDiwd0IZTLn
+gCD0DqmNgDB/sEuZYu+JuzuSX/NRYJNNXpIl8aqZ7VTLDgKesyWcrhMlg5s1fbm0
+cHiV7Lhjmof0DJr5QG11OgYoyJSTABEBAAGJAjwEGAEIACYCGwwWIQTS8P86Wl8i
+Vw9LEjjygFEzQzjwIQUCZWp9RAUJBE949AAKCRDygFEzQzjwIeu4D/95P+zuO9tC
+v109Fx9EXiFDOwsjUlMsYWRrFU8V4gL4J47gi6UrqNAxge9XCzLkF+YGxr9oc1+r
+f3yJol/unO/41BP9BAztC5Oxo58XPgOvnDmt+LDGSTr2HxTPDOkNuI3uWPjkgFVB
+6+cUqu06bZb2zcgnh7gjIJeXhtSiuxRXw0hxHY8WS9KcMx/9HqtmCFQAt2twwEFy
+Wud2XspQOdZKw3E9Yp3TxyZAJ03t3cIHVrmXQpJAEb15z8uIXHCei0R6rsVfmKFW
+6CJjZFDDGCEqRUbLvf03AjkC4CnWQcB6ItkdGSvoq8WOzglVD0MxgygNz8nlmS94
+mMvMm+62aUeM9vaBJwM2Mm9qNwNAKXMYJFX0DdJZuZwVTVXHYoTOZldLrKFPn263
+I/wwG+9iZ/3NcDoY6AY15EjMx9NK7MHLrBLcAybVK5aOZCmVlxVNP/9fS+5tKdbq
+Wfm0YkefGv87spBJUGWcqd7x4cyhYeDwipvesK5cQIJ9JYG3pWBhrJDSTTdQldED
+djUxlAshVLNF0wu5rrEblBZMMwjG3qwwtSINHu2t5DX5jlulHOkCe1ulYo0OXOf4
+Tiiw/edcZfu4SSzZwu3rI3IpzOdqk78KzWWvsCvY+z4h7Zd71wdzw4ppPnHaCzeO
+R5WLoVaRvrrEV9/bd01FW52j5kYCL75B07gzBGgtFeMWCSsGAQQB2kcPAQEHQPGB
+GyQNil22vLUIfTCJdRCvWKTYsWg7REVnZfr5aAjbiQKzBBgBCAAmFiEE0vD/Olpf
+IlcPSxI48oBRM0M48CEFAmgtFeMCGwIFCQHhM4AAgQkQ8oBRM0M48CF2IAQZFgoA
+HRYhBGi/gzHbs32zOXAWxt8BM0HJQlLHBQJoLRXjAAoJEN8BM0HJQlLHJeQBALbS
+9yGXeHMJLGC/dFU6khu8jfh58a6jxvL2jHXo3CLmAQDurWnP1I6HNNJUnFQnv1mX
+5Q+uQtxtSQfxXWS/2otmBcTTD/9EUdeyntQZG5X6cBJTmYSqgd56SmakH2AjyD57
+hPwxMOUfbalHKCv5qH6g/2YXyDoNjbwPegIVY5tpFYxyPQcHUfFONzEeFjf1/TjW
+nP2JeU5vu+inH2rrE4KvMXNlhyzzce8MpYIetbWXPslMGaFTBctP/FjkA62I0NBs
+cf/2g75yuvu8MoSbFRJK70oTQdIroVSvb+AfbQgRrthsslYLj7iBsIOuqQpeZY85
+riVBgY9pwqiZ9cytH817nkEhlHH75eXjJK9Ay4jwxh7li6zhPEGLfSFGMnHe3Z8Z
+jHcUEk5vTabu+ZBDJ6h+8BcQTufNOwcQjhjTkFYpW6B839B1bUeFFBkgrw/bnRso
+00LYbaqMCVaPlCHHFh6L6/A093jZibLTO/13wUSmoSyyQuYwBvpnZlx85s8IHh1s
+4YTnL1uZRhtFEuNWfmTFR3bONAgPX16SGzUqx9XT5XZcFcycFmnghIU5poGuJb0Z
+dM0FspCBGOupfUVT6tQ2RC9zCV898hTCgHDwuBVnK7LLl+t8Y85PNh3COh7ovyU4
+fkfaNmWmhY41xBoTexcSAtU3q8j/6TklstRRX4F0gY0KbEoVwX8wt1OKA/v79U4T
+ifGqldD4RjPQDIetyHzU7MmjxY6pmvJJSrNY6RynBRy8GR5k901TBGjTFk5dXvvF
+zUSvXg==
+=6+uu
+-----END PGP PUBLIC KEY BLOCK-----
+'''
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
new file mode 100644
index 0000000..60a9d4f
--- /dev/null
+++ b/src/layouts/Base.astro
@@ -0,0 +1,35 @@
+---
+import BaseHead, { type Props } from "@components/BaseHead.astro";
+import Footer from "@components/Footer.astro";
+import Header from "@components/Header.astro";
+---
+
+<!DOCTYPE html>
+<!--
+ <cravodeabril.pt> - Personal website
+ Copyright (C) 2024 João Augusto Costa Branco Marado Torres
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+-->
+<html dir="ltr" lang="pt-PT">
+ <head>
+ <BaseHead {...Astro.props} />
+ </head>
+ <body>
+ <Header />
+ <slot />
+ <Footer />
+ <noscript>I see, a man of culture :)</noscript>
+ </body>
+</html>
diff --git a/src/lib/git/index.test.ts b/src/lib/git/index.test.ts
new file mode 100644
index 0000000..4eedaca
--- /dev/null
+++ b/src/lib/git/index.test.ts
@@ -0,0 +1,40 @@
+import { describe, it } from "@std/testing/bdd";
+import { assertEquals } from "@std/assert";
+import {
+ assertSpyCall,
+ assertSpyCalls,
+ returnsNext,
+ stub,
+} from "@std/testing/mock";
+
+// IMPORTANT: Delay the import of `gitDir` to after the stub
+let gitDir: typeof import("./index.ts").gitDir;
+
+describe("gitDir", () => {
+ it("resolves with trimmed decoded stdout", async () => {
+ const encoded = new TextEncoder().encode(
+ " /home/user/project \n",
+ ) as Uint8Array<ArrayBuffer>;
+ const fakeOutput = Promise.resolve({
+ success: true,
+ code: 0,
+ stdout: encoded,
+ stderr: new Uint8Array(),
+ signal: null,
+ });
+
+ using outputStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext([fakeOutput]),
+ );
+
+ // Now import gitDir AFTER stubbing
+ ({ gitDir } = await import("./index.ts"));
+
+ const result = await gitDir();
+ assertEquals(result.pathname, "/home/user/project");
+ assertSpyCall(outputStub, 0, { args: [], returned: fakeOutput });
+ assertSpyCalls(outputStub, 1);
+ });
+});
diff --git a/src/lib/git/index.ts b/src/lib/git/index.ts
new file mode 100644
index 0000000..23a13eb
--- /dev/null
+++ b/src/lib/git/index.ts
@@ -0,0 +1,16 @@
+import { get, instanciate } from "../../utils/anonymous.ts";
+
+let cachedGitDir: Promise<URL> | undefined;
+
+export function gitDir(): Promise<URL> {
+ if (!cachedGitDir) {
+ cachedGitDir = new Deno.Command("git", {
+ args: ["rev-parse", "--show-toplevel"],
+ }).output()
+ .then(get("stdout"))
+ .then((x) => `file://${new TextDecoder().decode(x).trim()}/`)
+ .then(instanciate(URL));
+ }
+
+ return cachedGitDir;
+}
diff --git a/src/lib/git/log.test.ts b/src/lib/git/log.test.ts
new file mode 100644
index 0000000..09acb1c
--- /dev/null
+++ b/src/lib/git/log.test.ts
@@ -0,0 +1,71 @@
+import { describe, it } from "@std/testing/bdd";
+import { assertEquals, assertExists } from "@std/assert";
+import {
+ assertSpyCall,
+ assertSpyCalls,
+ returnsNext,
+ stub,
+} from "@std/testing/mock";
+import { getLastCommitForOneOfFiles } from "./log.ts";
+import {
+ emptyCommandOutput,
+ gitDiffTreeCommandOutput,
+ gitDir,
+ gitLogPrettyCommandOutput,
+ gitRevParseCommandOutput,
+} from "../../../tests/fixtures/test_data.ts";
+
+describe("getLastCommitForOneOfFiles", () => {
+ it("returns parsed commit with signature and file info", async () => {
+ const outputs = [
+ gitLogPrettyCommandOutput,
+ gitDiffTreeCommandOutput,
+ gitRevParseCommandOutput,
+ ];
+ using logStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext(outputs),
+ );
+
+ const file = new URL("file.ts", gitDir);
+ const result = await getLastCommitForOneOfFiles(file);
+
+ assertExists(result);
+ assertEquals(result.hash.short, "abcdef1");
+ assertEquals(result.hash.long, "abcdef1234567890abcdef1234567890abcdef12");
+
+ assertEquals(result.author.name, "Alice");
+ assertEquals(result.committer.email, "bob@example.com");
+
+ assertEquals(result.files.length, 1);
+ assertEquals(result.files[0], {
+ path: file,
+ status: "modified",
+ });
+
+ assertEquals(result.signature?.type, "gpg");
+ assertEquals(result.signature?.signer, "bob@example.com");
+
+ for (let i = 0; i < outputs.length; i++) {
+ assertSpyCall(logStub, i, { args: [], returned: outputs[i] });
+ }
+ assertSpyCalls(logStub, outputs.length);
+ });
+
+ it("returns undefined for empty commit output", async () => {
+ using logStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext([emptyCommandOutput]),
+ );
+
+ const result = await getLastCommitForOneOfFiles(
+ [new URL("nonexistent.ts", gitDir)],
+ );
+
+ assertEquals(result, undefined);
+ assertSpyCall(logStub, 0, { args: [], returned: emptyCommandOutput });
+ assertSpyCalls(logStub, 1);
+ });
+});
diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts
new file mode 100644
index 0000000..86bbe7b
--- /dev/null
+++ b/src/lib/git/log.ts
@@ -0,0 +1,131 @@
+import { defined } from "../../utils/anonymous.ts";
+import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts";
+import { gitDir } from "./index.ts";
+import type { Commit, CommitFile } from "./types.ts";
+
+const format = [
+ "H",
+ "h",
+ "aI",
+ "aN",
+ "aE",
+ "cI",
+ "cN",
+ "cE",
+ // "G?",
+ "GS",
+ "GK",
+ "GF",
+ "GG",
+];
+
+export async function getLastCommitForOneOfFiles(
+ sources: MaybeIterable<URL>,
+): Promise<Commit | undefined> {
+ const files = surelyIterable(sources);
+ const gitLog = new Deno.Command("git", {
+ args: [
+ "log",
+ "-1",
+ `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`,
+ "--",
+ ...Iterator.from(files).map((x) => x.pathname),
+ ],
+ });
+
+ const { stdout } = await gitLog.output();
+ const result = new TextDecoder().decode(stdout).trim();
+
+ if (result.length <= 0) {
+ return undefined;
+ }
+
+ const [
+ hash,
+ abbrHash,
+ authorDate,
+ authorName,
+ authorEmail,
+ committerDate,
+ committerName,
+ committerEmail,
+ // signatureValidation,
+ signer,
+ key,
+ keyFingerPrint,
+ ...rawLines
+ ] = result.split("\n");
+
+ const raw = rawLines.join("\n").trim();
+
+ const commit: Commit = {
+ files: await fileStatusFromCommit(hash, Iterator.from(files)),
+ hash: { long: hash, short: abbrHash },
+ author: {
+ date: new Date(authorDate),
+ name: authorName,
+ email: authorEmail,
+ },
+ committer: {
+ date: new Date(committerDate),
+ name: committerName,
+ email: committerEmail,
+ },
+ };
+
+ if (raw.length > 0) {
+ commit.signature = {
+ type: raw.startsWith("gpgsm:")
+ ? "x509"
+ : raw.startsWith("gpg:")
+ ? "gpg"
+ : "ssh",
+ signer,
+ key: { long: keyFingerPrint, short: key },
+ rawMessage: raw,
+ };
+ }
+
+ return commit;
+}
+
+async function fileStatusFromCommit(
+ hash: string,
+ files: Iterable<URL>,
+): Promise<CommitFile[]> {
+ const gitDiffTree = new Deno.Command("git", {
+ args: [
+ "diff-tree",
+ "--no-commit-id",
+ "--name-status",
+ "-r",
+ hash,
+ ],
+ });
+
+ const { stdout } = await gitDiffTree.output();
+ const result = new TextDecoder().decode(stdout).trim().split("\n").filter(
+ defined,
+ );
+
+ const dir = await gitDir();
+ return result.map((line) => {
+ const [status, path] = line.split("\t");
+ if (
+ Iterator.from(files).some((file) =>
+ file.pathname.replace(dir.pathname, "").includes(path)
+ )
+ ) {
+ return {
+ path: new URL(path, dir),
+ status: status === "A"
+ ? "added"
+ : status === "D"
+ ? "deleted"
+ : "modified",
+ } as const;
+ }
+
+ return undefined;
+ }).filter(defined);
+}
diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts
new file mode 100644
index 0000000..672d242
--- /dev/null
+++ b/src/lib/git/types.ts
@@ -0,0 +1,27 @@
+export type CommitFile = {
+ path: URL;
+ status: "added" | "modified" | "deleted";
+};
+
+export type Hash = { long: string; short: string };
+
+export type Contributor = {
+ name: string;
+ email: string;
+ date: Date;
+};
+
+export type SignatureType = "ssh" | "gpg" | "x509";
+
+export type Commit = {
+ files: CommitFile[];
+ hash: Hash;
+ author: Contributor;
+ committer: Contributor;
+ signature?: {
+ type: SignatureType;
+ signer: string;
+ key: Hash;
+ rawMessage: string;
+ };
+};
diff --git a/src/lib/pgp/create.test.ts b/src/lib/pgp/create.test.ts
new file mode 100644
index 0000000..e9e9f41
--- /dev/null
+++ b/src/lib/pgp/create.test.ts
@@ -0,0 +1,130 @@
+import { beforeEach, describe, it } from "@std/testing/bdd";
+import {
+ createInMemoryFile,
+ generateKeyPair,
+ startMockFs,
+} from "../../../tests/fixtures/setup.ts";
+import {
+ armored,
+ binary,
+ createKeysFromFs,
+ DEFAULT_KEY_DISCOVERY_RULES,
+} from "./create.ts";
+import { assertEquals, assertRejects } from "@std/assert";
+import { stub } from "@std/testing/mock";
+
+startMockFs();
+
+describe("createKeysFromFs", () => {
+ let keyPair: Awaited<ReturnType<typeof generateKeyPair>>;
+
+ beforeEach(async () => {
+ keyPair = await generateKeyPair("Alice");
+ });
+
+ it("loads a single armored key file", async () => {
+ const url = createInMemoryFile(
+ new URL("file:///mock/alice.asc"),
+ keyPair.privateKey.armor(),
+ );
+
+ const keys = [];
+ for await (const key of createKeysFromFs(url)) {
+ keys.push(key);
+ }
+
+ assertEquals(keys.length, 1);
+ });
+
+ it("loads a single binary key file", async () => {
+ const binaryData = keyPair.privateKey.write();
+ const url = createInMemoryFile(
+ new URL("file:///mock/alice.gpg"),
+ binaryData as Uint8Array<ArrayBuffer>,
+ );
+
+ const keys = [];
+ for await (const key of createKeysFromFs(url)) {
+ keys.push(key);
+ }
+
+ assertEquals(keys.length, 1);
+ });
+
+ it("ignores unsupported file extensions", async () => {
+ const url = createInMemoryFile(
+ new URL("file:///mock/ignored.txt"),
+ "This is not a key",
+ );
+
+ const keys = [];
+ for await (const key of createKeysFromFs(url)) {
+ keys.push(key);
+ }
+
+ assertEquals(keys.length, 0);
+ });
+
+ it("throws on overlapping discovery formats", async () => {
+ const rules = {
+ formats: {
+ [armored]: new Set(["asc", "gpg"]),
+ [binary]: new Set(["gpg"]),
+ },
+ };
+
+ const url = new URL("file:///mock/bogus.gpg");
+
+ await assertRejects(() => createKeysFromFs(url, rules).next());
+ });
+
+ it("handles recursive directory traversal", async () => {
+ const aliceURL = new URL("file:///mock/keys/alice.asc");
+ const bobURL = new URL("file:///mock/keys/sub/bob.asc");
+
+ createInMemoryFile(aliceURL, keyPair.privateKey.armor());
+ createInMemoryFile(bobURL, keyPair.privateKey.armor());
+
+ const mockedDirTree = {
+ "file:///mock/keys/": [
+ { name: "alice.asc", isFile: true, isDirectory: false },
+ { name: "sub", isFile: false, isDirectory: true },
+ ],
+ "file:///mock/keys/sub/": [
+ { name: "bob.asc", isFile: true, isDirectory: false },
+ ],
+ };
+
+ stub(Deno, "stat", (url: URL | string) => {
+ const href = new URL(url).href;
+ return Promise.resolve({
+ isDirectory: href.endsWith("/") || href.includes("/sub"),
+ isFile: href.endsWith(".asc"),
+ isSymlink: false,
+ } as Deno.FileInfo);
+ });
+
+ stub(Deno, "readDir", async function* (url: URL | string) {
+ const href = new URL(url).href;
+ for (
+ const entry of mockedDirTree[href as keyof typeof mockedDirTree] ?? []
+ ) {
+ yield entry as Deno.DirEntry;
+ }
+ });
+
+ const root = new URL("file:///mock/keys/");
+ const keys = [];
+
+ for await (
+ const key of createKeysFromFs(
+ root,
+ { ...DEFAULT_KEY_DISCOVERY_RULES, recursive: true },
+ )
+ ) {
+ keys.push(key);
+ }
+
+ assertEquals(keys.length, 2);
+ });
+});
diff --git a/src/lib/pgp/create.ts b/src/lib/pgp/create.ts
new file mode 100644
index 0000000..fb45954
--- /dev/null
+++ b/src/lib/pgp/create.ts
@@ -0,0 +1,183 @@
+import { readKey } from "openpgp";
+
+export const armored: unique symbol = Symbol();
+export const binary: unique symbol = Symbol();
+export type KeyFileFormat = typeof armored | typeof binary;
+
+export interface KeyDiscoveryRules {
+ formats?: Partial<Record<KeyFileFormat, Set<string> | undefined>>;
+ recursive?: boolean | number;
+}
+export const DEFAULT_KEY_DISCOVERY_RULES = {
+ formats: {
+ [armored]: new Set(["asc"]),
+ [binary]: new Set(["gpg"]),
+ },
+} satisfies KeyDiscoveryRules;
+
+export async function* createKeysFromFs(
+ key: string | URL,
+ rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES,
+ coders: { decoder?: TextDecoder; encoder?: TextEncoder } = {},
+): AsyncGenerator<Awaited<ReturnType<typeof readKey>>, void, void> {
+ key = new URL(key);
+
+ validateKeyDiscoveryRules(rules);
+
+ const stat = await Deno.stat(key);
+
+ if (stat.isDirectory) {
+ const generator = createKeysFromDir(key, rules, coders);
+ yield* generator;
+ } else if (stat.isFile) {
+ const period = key.pathname.lastIndexOf(".");
+ const ext = period === -1 ? "" : key.pathname.slice(period + 1);
+ if (
+ rules.formats?.[armored] !== undefined && rules.formats[armored].has(ext)
+ ) {
+ yield createKeyFromFile(
+ key,
+ armored,
+ coders?.decoder,
+ );
+ } else if (
+ rules.formats?.[binary] !== undefined && rules.formats[binary].has(ext)
+ ) {
+ yield createKeyFromFile(
+ key,
+ binary,
+ coders?.encoder,
+ );
+ }
+ }
+}
+
+export async function* createKeysFromDir(
+ key: string | URL,
+ rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES,
+ coders: { decoder?: TextDecoder; encoder?: TextEncoder } = {},
+): AsyncGenerator<Awaited<ReturnType<typeof readKey>>, void, void> {
+ key = new URL(key);
+
+ validateKeyDiscoveryRules(rules);
+
+ for await (const dirEntry of Deno.readDir(key)) {
+ const filePath = new URL(dirEntry.name, key);
+ if (dirEntry.isFile) {
+ const period = filePath.pathname.lastIndexOf(".");
+ const ext = period === -1 ? "" : filePath.pathname.slice(period + 1);
+ if (
+ rules.formats?.[armored] !== undefined &&
+ rules.formats[armored].has(ext)
+ ) {
+ yield createKeyFromFile(
+ filePath,
+ armored,
+ coders?.decoder,
+ );
+ } else if (
+ rules.formats?.[binary] !== undefined && rules.formats[binary].has(ext)
+ ) {
+ yield createKeyFromFile(
+ filePath,
+ binary,
+ coders?.encoder,
+ );
+ }
+ } else if (dirEntry.isDirectory) {
+ const depth = typeof rules.recursive === "number"
+ ? rules.recursive
+ : rules.recursive
+ ? Infinity
+ : 0;
+ if (depth > 0) {
+ yield* createKeysFromDir(filePath, {
+ ...rules,
+ recursive: depth - 1,
+ }, coders);
+ }
+ }
+ }
+}
+
+export async function createKeyFromFile(
+ key: string | URL,
+ type: typeof armored,
+ coder?: TextDecoder,
+): ReturnType<typeof readKey>;
+export async function createKeyFromFile(
+ key: string | URL,
+ type: typeof binary,
+ coder?: TextEncoder,
+): ReturnType<typeof readKey>;
+export async function createKeyFromFile(
+ key: string | URL,
+ type: typeof armored | typeof binary,
+ coder?: TextDecoder | TextEncoder,
+): ReturnType<typeof readKey> {
+ switch (type) {
+ case armored:
+ return await Deno.readTextFile(key).then((key) =>
+ createKeyFromArmor(key, coder as TextDecoder)
+ );
+ case binary:
+ return await Deno.readFile(key).then((key) =>
+ createKeyFromBinary(key, coder as TextEncoder)
+ );
+ }
+}
+
+export function createKeyFromArmor(
+ key: string | Uint8Array,
+ decoder?: TextDecoder,
+): ReturnType<typeof readKey> {
+ return readKey({
+ armoredKey: typeof key === "string"
+ ? key
+ : (decoder ?? new TextDecoder()).decode(key),
+ });
+}
+export function createKeyFromBinary(
+ key: string | Uint8Array,
+ encoder?: TextEncoder,
+): ReturnType<typeof readKey> {
+ return readKey({
+ binaryKey: typeof key === "string"
+ ? (encoder ?? new TextEncoder()).encode(key)
+ : key,
+ });
+}
+
+function validateKeyDiscoveryRules(rules: KeyDiscoveryRules) {
+ let disjoint = true;
+ let union: Set<string> | undefined = undefined;
+ const keys = rules.formats !== undefined
+ ? Object.getOwnPropertySymbols(rules.formats) as KeyFileFormat[]
+ : [];
+
+ for (const i of keys) {
+ const set = rules.formats?.[i];
+
+ if (union === undefined) {
+ union = set;
+ continue;
+ }
+
+ if (set === undefined) {
+ continue;
+ }
+
+ disjoint &&= union.isDisjointFrom(set);
+ union = union.union(set);
+
+ if (!disjoint) {
+ break;
+ }
+ }
+
+ if (!disjoint) {
+ throw new Error(
+ `\`Set\`s from \`rules.formats\` aren't disjoint`,
+ );
+ }
+}
diff --git a/src/lib/pgp/index.ts b/src/lib/pgp/index.ts
new file mode 100644
index 0000000..8142732
--- /dev/null
+++ b/src/lib/pgp/index.ts
@@ -0,0 +1,63 @@
+import { enums, PublicKey, type Subkey } from "openpgp";
+
+export async function isKeyExpired(
+ key: PublicKey | Subkey,
+): Promise<Date | null> {
+ const keyExpiration = await key.getExpirationTime();
+
+ return typeof keyExpiration === "number"
+ ? new Date(keyExpiration)
+ : keyExpiration;
+}
+
+export type RevocationReason = { flag?: string; msg?: string };
+export type Revocation = { date: Date; reason: RevocationReason };
+export function isKeyRevoked(
+ key: PublicKey | Subkey,
+): Revocation | undefined {
+ const revokes = key.revocationSignatures.map((
+ { created, reasonForRevocationFlag, reasonForRevocationString },
+ ) => ({ created, reasonForRevocationFlag, reasonForRevocationString }));
+ let keyRevocation: Revocation | undefined = undefined;
+ for (const i of revokes) {
+ const unix = i.created?.getTime();
+ if (unix === undefined) {
+ continue;
+ }
+ const date = new Date(unix);
+ if (keyRevocation === undefined || unix < keyRevocation.date.getTime()) {
+ let flag = undefined;
+ switch (i.reasonForRevocationFlag) {
+ case enums.reasonForRevocation.noReason: {
+ flag = "No reason specified (key revocations or cert revocations)";
+ break;
+ }
+ case enums.reasonForRevocation.keySuperseded: {
+ flag = "Key is superseded (key revocations)";
+ break;
+ }
+ case enums.reasonForRevocation.keyCompromised: {
+ flag = "Key material has been compromised (key revocations)";
+ break;
+ }
+ case enums.reasonForRevocation.keyRetired: {
+ flag = "Key is retired and no longer used (key revocations)";
+ break;
+ }
+ case enums.reasonForRevocation.userIDInvalid: {
+ flag = "User ID information is no longer valid (cert revocations)";
+ break;
+ }
+ }
+ keyRevocation = {
+ date,
+ reason: { msg: i.reasonForRevocationString ?? undefined, flag },
+ };
+ }
+ }
+
+ return keyRevocation;
+}
+
+export const toPK = (key: PublicKey | Subkey): PublicKey =>
+ key instanceof PublicKey ? key : key.mainKey;
diff --git a/src/lib/pgp/sign.test.ts b/src/lib/pgp/sign.test.ts
new file mode 100644
index 0000000..1f9c4db
--- /dev/null
+++ b/src/lib/pgp/sign.test.ts
@@ -0,0 +1,121 @@
+import {
+ assert,
+ assertAlmostEquals,
+ assertArrayIncludes,
+ assertEquals,
+ assertExists,
+} from "@std/assert";
+import { describe, it } from "@std/testing/bdd";
+import { createMessage, enums, readSignature, sign } from "openpgp";
+import { Signature } from "./sign.ts";
+import { get, instanciate } from "../../utils/anonymous.ts";
+import { bufferToBase } from "../../utils/bases.ts";
+import { generateKeyPair } from "../../../tests/fixtures/setup.ts";
+
+describe("Signature wrapper", () => {
+ const now = new Date();
+ const aliceKeyPair = generateKeyPair("Alice");
+ const signature = Promise.all([
+ aliceKeyPair.then(get("privateKey")),
+ createMessage({ text: "Hello world" }),
+ ]).then(([privateKey, message]) =>
+ sign({
+ message,
+ signingKeys: privateKey,
+ detached: true,
+ format: "object",
+ })
+ ).then((x) => readSignature({ armoredSignature: x.armor() })).then(
+ instanciate(Signature),
+ );
+
+ describe("Single signer", () => {
+ it("signingKeyIDs", async () => {
+ const { publicKey } = await aliceKeyPair;
+ const sig = await signature;
+
+ assertEquals(sig.signingKeyIDs.length, 1);
+ assert(sig.signingKeyIDs[0].equals(publicKey.getKeyID()));
+ });
+
+ it("getPackets", async () => {
+ const sig = await signature;
+
+ assertEquals(sig.getPackets().length, 1);
+ assertEquals(sig.getPackets(sig.signingKeyIDs[0]).length, 1);
+ });
+
+ describe("Packet wrapper", () => {
+ const packet = signature.then((x) => x.getPackets()[0]);
+
+ it("created", async () => {
+ const p = await packet;
+
+ assertExists(p.created);
+ assertAlmostEquals(p.created.getTime(), now.getTime());
+ });
+
+ it("issuerKeyID and issuerFingerprint", async () => {
+ const { privateKey } = await aliceKeyPair;
+ const p = await packet;
+
+ assertEquals(p.issuerKeyID, privateKey.getKeyID());
+ assertExists(p.issuerFingerprint);
+ assertEquals(
+ bufferToBase(p.issuerFingerprint, 16),
+ privateKey.getFingerprint(),
+ );
+ });
+
+ it("signatureType", async () => {
+ const p = await packet;
+
+ assertEquals(p.signatureType, enums.signature.text);
+ });
+ });
+ });
+
+ const bobKeyPair = generateKeyPair("Bob");
+ const multiSignature = Promise.all([
+ Promise.all([
+ aliceKeyPair.then(get("privateKey")),
+ bobKeyPair.then(get("privateKey")),
+ ]),
+ createMessage({ text: "Hello world" }),
+ ]).then(([signingKeys, message]) =>
+ sign({
+ message,
+ signingKeys,
+ detached: true,
+ format: "object",
+ })
+ ).then((x) => readSignature({ armoredSignature: x.armor() })).then(
+ instanciate(Signature),
+ );
+
+ describe("with multiple signers", () => {
+ it("signingKeyIDs", async () => {
+ const { publicKey: alice } = await aliceKeyPair;
+ const { publicKey: bob } = await bobKeyPair;
+ const sig = await multiSignature;
+
+ assertEquals(sig.signingKeyIDs.length, 2);
+ assertArrayIncludes(sig.signingKeyIDs, [
+ alice.getKeyID(),
+ bob.getKeyID(),
+ ]);
+ });
+
+ it("getPackets", async () => {
+ const sig = await multiSignature;
+ const { publicKey: alice } = await aliceKeyPair;
+ const { publicKey: bob } = await bobKeyPair;
+
+ assertEquals(sig.getPackets().length, 2);
+
+ assertEquals(sig.getPackets(alice.getKeyID()).length, 1);
+
+ assertEquals(sig.getPackets(bob.getKeyID()).length, 1);
+ });
+ });
+});
diff --git a/src/lib/pgp/sign.ts b/src/lib/pgp/sign.ts
new file mode 100644
index 0000000..5f7f5a8
--- /dev/null
+++ b/src/lib/pgp/sign.ts
@@ -0,0 +1,82 @@
+import type {
+ KeyID,
+ Signature as InnerSignature,
+ SignaturePacket,
+} from "openpgp";
+import { defined, identity } from "../../utils/anonymous.ts";
+import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts";
+
+export class Signature {
+ private signature!: InnerSignature;
+ #packets!: Map<string, Packet[]>;
+
+ constructor(signature: InnerSignature) {
+ this.signature = signature;
+ this.#packets = new Map();
+ for (const packet of this.signature.packets) {
+ const key = packet.issuerKeyID.bytes;
+ const keyPackets = this.#packets.get(key);
+ if (keyPackets !== undefined) {
+ keyPackets.push(new Packet(packet));
+ } else {
+ this.#packets.set(key, [new Packet(packet)]);
+ }
+ }
+ }
+
+ getPackets(key?: MaybeIterable<KeyID>): Packet[] {
+ key ??= this.signingKeyIDs;
+ const iterator = Iterator.from(surelyIterable(key));
+ return iterator.map((key) => this.#packets.get(key.bytes)).filter(defined)
+ .flatMap(identity).toArray();
+ }
+
+ get signingKeyIDs(): ReturnType<
+ InstanceType<typeof InnerSignature>["getSigningKeyIDs"]
+ > {
+ return this.signature.getSigningKeyIDs();
+ }
+
+ get inner(): InnerSignature {
+ return this.signature;
+ }
+}
+
+export class Packet {
+ private packet!: SignaturePacket;
+
+ constructor(packet: SignaturePacket) {
+ this.packet = packet;
+ }
+
+ get signersUserID(): SignaturePacket["signersUserID"] {
+ return this.packet.signersUserID;
+ }
+
+ get issuerKeyID(): SignaturePacket["issuerKeyID"] {
+ return this.packet.issuerKeyID;
+ }
+
+ get issuerFingerprint(): SignaturePacket["issuerFingerprint"] {
+ return this.packet.issuerFingerprint;
+ }
+
+ get created(): SignaturePacket["created"] {
+ return this.packet.created;
+ }
+
+ get signatureType(): SignaturePacket["signatureType"] {
+ return this.packet.signatureType;
+ }
+
+ get trustLevel(): SignaturePacket["trustLevel"] {
+ return this.packet.trustLevel;
+ }
+ get trustAmount(): SignaturePacket["trustAmount"] {
+ return this.packet.trustAmount;
+ }
+
+ get inner(): SignaturePacket {
+ return this.packet;
+ }
+}
diff --git a/src/lib/pgp/summary.ts b/src/lib/pgp/summary.ts
new file mode 100644
index 0000000..5c8a81c
--- /dev/null
+++ b/src/lib/pgp/summary.ts
@@ -0,0 +1,232 @@
+import type { Key, PublicKey, Subkey } from "openpgp";
+import type { Verification } from "./verify.ts";
+import { Level } from "../../utils/index.ts";
+import type { NonEmptyArray } from "../../utils/iterator.ts";
+import { keyTrust } from "./trust.ts";
+import { isKeyExpired, isKeyRevoked, type RevocationReason } from "./index.ts";
+
+export const enum VerificationResult {
+ NO_SIGNATURE,
+ MISSING_KEY,
+ SIGNATURE_CORRUPTED,
+ SIGNATURE_COULD_NOT_BE_CHECKED,
+ BAD_SIGNATURE,
+ UNTRUSTED_KEY,
+ TRUSTED_KEY,
+ EXPIRATION_AFTER_SIGNATURE,
+ EXPIRATION_BEFORE_SIGNATURE,
+ REVOCATION_AFTER_SIGNATURE,
+ REVOCATION_BEFORE_SIGNATURE,
+ KEY_DOES_NOT_SIGN,
+}
+
+export function logLevel(result: VerificationResult): [Level, boolean] {
+ switch (result) {
+ case VerificationResult.NO_SIGNATURE:
+ return [Level.ERROR, true] as const;
+ case VerificationResult.MISSING_KEY:
+ return [Level.ERROR, false] as const;
+ case VerificationResult.SIGNATURE_CORRUPTED:
+ return [Level.ERROR, true] as const;
+ case VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED:
+ return [Level.ERROR, false] as const;
+ case VerificationResult.BAD_SIGNATURE:
+ return [Level.ERROR, false] as const;
+ case VerificationResult.UNTRUSTED_KEY:
+ return [Level.OK, false] as const;
+ case VerificationResult.TRUSTED_KEY:
+ return [Level.OK, true] as const;
+ case VerificationResult.EXPIRATION_AFTER_SIGNATURE:
+ return [Level.WARN, false] as const;
+ case VerificationResult.EXPIRATION_BEFORE_SIGNATURE:
+ return [Level.ERROR, true] as const;
+ case VerificationResult.REVOCATION_AFTER_SIGNATURE:
+ return [Level.WARN, true] as const;
+ case VerificationResult.REVOCATION_BEFORE_SIGNATURE:
+ return [Level.ERROR, true] as const;
+ case VerificationResult.KEY_DOES_NOT_SIGN:
+ return [Level.ERROR, true] as const;
+ }
+
+ throw new Error("unreachable");
+}
+
+export type Summary = {
+ result: VerificationResult.NO_SIGNATURE;
+} | {
+ result: VerificationResult.MISSING_KEY;
+ reason: Error;
+ keyID: string;
+ created: Date;
+} | {
+ result:
+ | VerificationResult.SIGNATURE_CORRUPTED
+ | VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED
+ | VerificationResult.BAD_SIGNATURE;
+ reason: Error;
+} | {
+ result: VerificationResult.TRUSTED_KEY;
+ key: PublicKey | Subkey;
+ created: Date;
+} | {
+ result: VerificationResult.UNTRUSTED_KEY;
+ key: PublicKey | Subkey;
+ created: Date;
+} | {
+ result: VerificationResult.EXPIRATION_AFTER_SIGNATURE;
+ key: PublicKey | Subkey;
+ expired: Date;
+ created: Date;
+} | {
+ result: VerificationResult.REVOCATION_AFTER_SIGNATURE;
+ key: PublicKey | Subkey;
+ revoked: Date;
+ revocationReason: RevocationReason;
+ created: Date;
+} | {
+ result: VerificationResult.EXPIRATION_BEFORE_SIGNATURE;
+ key: PublicKey | Subkey;
+ expired: Date;
+ created: Date;
+} | {
+ result: VerificationResult.REVOCATION_BEFORE_SIGNATURE;
+ key: PublicKey | Subkey;
+ revoked: Date;
+ revocationReason: RevocationReason;
+ created: Date;
+} | {
+ result: VerificationResult.KEY_DOES_NOT_SIGN;
+ key: PublicKey | Subkey;
+};
+
+export async function createVerificationSummary(
+ { dataCorrupted, verifications, signature }: Verification,
+): Promise<[NonEmptyArray<Summary>, Map<string, NonEmptyArray<Summary>>]> {
+ if (signature === undefined) {
+ return [[{ result: VerificationResult.NO_SIGNATURE }], new Map()];
+ }
+
+ const corrupted = await dataCorrupted;
+ if (corrupted?.[0]) {
+ return [[{
+ result: VerificationResult.BAD_SIGNATURE,
+ reason: corrupted[1],
+ }], new Map()];
+ }
+
+ const summaries = await Promise.all<
+ Promise<[Summary[], Map<string, Summary[]>]>[]
+ >(
+ (verifications ?? []).map(
+ async ({ signatureCorrupted, verified, packet, key }) => {
+ const errors: Summary[] = [];
+ const keys: Map<string, Summary[]> = new Map();
+
+ try {
+ await verified;
+ } catch (e) {
+ if (e instanceof Error) {
+ if (
+ e.message.startsWith("Could not find signing key with key ID")
+ ) {
+ const keyID = e.message.slice(e.message.lastIndexOf(" "));
+ const key = keys.get(keyID) ?? [];
+ key.push({
+ result: VerificationResult.MISSING_KEY,
+ keyID,
+ reason: e,
+ });
+ keys.set(keyID, key);
+ } else {
+ errors.push({
+ result: VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED,
+ reason: e,
+ });
+ }
+ } else {
+ throw e;
+ }
+ }
+
+ const corrupted = await signatureCorrupted;
+ if (corrupted[0]) {
+ errors.push({
+ result: VerificationResult.SIGNATURE_CORRUPTED,
+ reason: corrupted[1],
+ });
+ }
+
+ const sig = await packet;
+ const keyID = sig.issuerKeyID;
+
+ sig.created;
+
+ const keyAwaited = await key;
+
+ if (keyAwaited === undefined) {
+ const key = keys.get(keyID.toHex()) ?? [];
+ key.push({
+ result: VerificationResult.MISSING_KEY,
+ keyID: keyID.toHex(),
+ reason: new Error(
+ `Could not find signing key with key ID ${keyID.toHex()}`,
+ ),
+ });
+ keys.set(keyID.toHex(), key);
+
+ return [errors, keys] as [Summary[], Map<string, Summary[]>];
+ }
+
+ const keySummaries = keys.get(keyAwaited.getKeyID().toHex()) ?? [];
+ const expired = await isKeyExpired(keyAwaited);
+
+ if (expired !== null && sig.created !== null) {
+ keySummaries.push({
+ result: expired <= sig.created
+ ? VerificationResult.EXPIRATION_BEFORE_SIGNATURE
+ : VerificationResult.EXPIRATION_AFTER_SIGNATURE,
+ key: keyAwaited,
+ date: expired,
+ });
+ }
+
+ const revoked = isKeyRevoked(keyAwaited);
+ if (revoked?.date !== undefined && sig.created !== null) {
+ keySummaries.push({
+ result: revoked?.date <= sig.created
+ ? VerificationResult.REVOCATION_BEFORE_SIGNATURE
+ : VerificationResult.REVOCATION_AFTER_SIGNATURE,
+ key: keyAwaited,
+ date: revoked.date,
+ revocationReason: revoked.reason,
+ });
+ }
+
+ const trust = sig.trustAmount ?? await keyTrust(keyAwaited as Key);
+
+ keySummaries.push({
+ result: trust > 0
+ ? VerificationResult.TRUSTED_KEY
+ : VerificationResult.UNTRUSTED_KEY,
+ key: keyAwaited,
+ });
+
+ keys.set(keyAwaited.getKeyID().toHex(), keySummaries);
+
+ return [errors, keys] as [Summary[], Map<string, Summary[]>];
+ },
+ ),
+ );
+
+ const errors = summaries.flatMap(([x]) => x);
+ const keys = new Map(summaries.flatMap(([, x]) => x.entries().toArray()));
+
+ if (errors.length > 0 || keys.size > 0) {
+ return [errors, keys] as [
+ NonEmptyArray<Summary>,
+ Map<string, NonEmptyArray<Summary>>,
+ ];
+ }
+
+ throw new Error("unreachable");
+}
diff --git a/src/lib/pgp/trust.ts b/src/lib/pgp/trust.ts
new file mode 100644
index 0000000..cf022b4
--- /dev/null
+++ b/src/lib/pgp/trust.ts
@@ -0,0 +1,19 @@
+import type { Key } from "npm:openpgp@^6.1.1";
+import { TRUSTED_KEYS_DIR } from "../../consts.ts";
+import { createKeysFromDir } from "./create.ts";
+import type { AsyncYieldType } from "../../utils/iterator.ts";
+import { equal, getCall } from "../../utils/anonymous.ts";
+
+let trusted:
+ | Iterable<AsyncYieldType<ReturnType<typeof createKeysFromDir>>>
+ | undefined = undefined;
+
+const fingerprints = () =>
+ Iterator.from(trusted ?? []).map(getCall("getFingerprint"));
+
+export async function keyTrust(key: Key): Promise<number> {
+ if (trusted === undefined) {
+ trusted = await Array.fromAsync(createKeysFromDir(TRUSTED_KEYS_DIR));
+ }
+ return fingerprints().some(equal(key.getFingerprint())) ? 255 : 0;
+}
diff --git a/src/lib/pgp/verify.test.ts b/src/lib/pgp/verify.test.ts
new file mode 100644
index 0000000..9c8ae9c
--- /dev/null
+++ b/src/lib/pgp/verify.test.ts
@@ -0,0 +1,619 @@
+/*
+import {
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ it,
+} from "@std/testing/bdd";
+import { type Stub, stub } from "@std/testing/mock";
+import { FakeTime } from "@std/testing/time";
+import { get } from "../../utils/anonymous.ts";
+import { SignatureVerifier } from "./verify.ts";
+import { assertEquals } from "@std/assert/equals";
+import { assert, assertExists, assertFalse, assertRejects } from "@std/assert";
+import {
+ corruptData,
+ corruptSignatureFormat,
+ createDetachedSignature,
+ createInMemoryFile,
+ generateKeyPair,
+ generateKeyPairWithSubkey,
+ startMockFs,
+} from "../../../tests/fixtures/setup.ts";
+import { emptyCommandOutput } from "../../../tests/fixtures/test_data.ts";
+
+startMockFs();
+
+describe("SignatureVerifier", () => {
+ let verifier: SignatureVerifier;
+ let aliceKeyPair: Awaited<ReturnType<typeof generateKeyPair>>;
+ let bobKeyPair: Awaited<ReturnType<typeof generateKeyPair>>;
+ let aliceWithSubkeyKeyPair: Awaited<ReturnType<typeof generateKeyPair>>;
+
+ beforeAll(async () => {
+ aliceKeyPair = await generateKeyPair("Alice");
+ bobKeyPair = await generateKeyPair("Bob");
+ aliceWithSubkeyKeyPair = await generateKeyPairWithSubkey("AliceWithSubkey");
+ });
+
+ beforeEach(() => {
+ verifier = new SignatureVerifier();
+ Deno.Command.prototype.output = stub(
+ Deno.Command.prototype,
+ "output",
+ () => emptyCommandOutput,
+ );
+ });
+
+ afterEach(() => {
+ (Deno.Command.prototype.output as Stub).restore();
+ });
+
+ describe("when verifying a file with a single signature", () => {
+ const originalData = new TextEncoder().encode(
+ "This is the original file content for single signature tests.",
+ ) as Uint8Array<ArrayBuffer>;
+ let originalDataUrl: URL;
+
+ beforeEach(() => {
+ // Create the data file in memory for each single signature test
+ originalDataUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt"),
+ originalData,
+ );
+ });
+
+ it("Scenario: No signature found", async () => {
+ const verification = await verifier.verify([originalDataUrl]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertFalse(
+ await verification.dataCorrupted,
+ "Data is not corrupted in the absence of a signature to check against",
+ );
+ assertEquals(
+ verification.verifications,
+ undefined,
+ "Should not find any signatures to verify",
+ );
+ // commit is stubbed, so it will be undefined
+ });
+
+ it("Scenario: Signature cannot be checked (missing key - 'E')", async () => {
+ // Create a valid signature, but don't add the signing key to the verifier
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ signature,
+ );
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertEquals(await verification.dataCorrupted, [false]);
+
+ assertEquals(verification.signatureCorrupted, [false]);
+
+ assertExists(verification.verifications, "Should find the signature");
+ assertEquals(verification.verifications.length, 1); // One signature found
+
+ const sigVerification = verification.verifications[0];
+ assertExists(sigVerification.packet);
+ assertFalse(await sigVerification.signatureCorrupted.then(get(0)));
+
+ assertRejects(
+ () => sigVerification.verified,
+ "Verification should fail due to missing key",
+ );
+ // assertEquals(await sigVerification.status, "E", "Status should be 'E'");
+
+ // The keys promise might resolve with an empty array or throw depending on implementation
+ // assert(?) sigVerification.keys resolves as expected
+ });
+
+ it("Scenario: Signature cannot be checked (Signature corrupted/malformed - 'E')", async () => {
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const corruptedSignature = corruptSignatureFormat(signature);
+ const corruptedSignatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ corruptedSignature,
+ );
+
+ verifier.addKey(aliceKeyPair.publicKey);
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ corruptedSignatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertEquals(await verification.dataCorrupted, undefined);
+
+ assertEquals(verification.verifications, undefined);
+ // assertEquals(await sigVerification.status, "E", "Status should be 'E'");
+ });
+
+ it("Scenario: Bad signature ('B')", async () => {
+ // Create a valid signature for the original data
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ signature,
+ );
+ // Create corrupted data
+ const corruptedData = corruptData(originalData);
+ const corruptedDataUrl = createInMemoryFile(
+ new URL("file:///test/corrupted_single_sig_data.txt"),
+ corruptedData,
+ );
+
+ verifier.addKey(aliceKeyPair.publicKey); // Key is available
+
+ // Verify the signature (of original data) against the corrupted data
+ const verification = await verifier.verify([
+ corruptedDataUrl,
+ signatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), corruptedData); // The verifier processed the corrupted data
+ assert(
+ await verification.dataCorrupted,
+ "Data should be marked as corrupted because signature does not match",
+ ); // Assuming implementation detects this
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications, "Should find the signature");
+ assertEquals(verification.verifications.length, 1); // One signature found
+
+ const sigVerification = verification.verifications[0];
+ assertExists(sigVerification.key); // Key should be found
+
+ assertExists(sigVerification.packet);
+ assertFalse(await sigVerification.signatureCorrupted.then(get(0))); // Signature data itself is not corrupted
+
+ // Expect verification to fail and report 'B'
+ assertRejects(
+ () => sigVerification.verified,
+ "Verification should fail due to data mismatch",
+ );
+ // assertEquals(await sigVerification.status, "B", "Status should be 'B'");
+ });
+
+ it("Scenario: Good signature ('G')", async () => {
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ signature,
+ );
+
+ // Add the key and assume it's ultimately trusted for this scenario
+ // In a real test, you might explicitly set trust levels if openpgp.js supports it easily
+ verifier.addKey(aliceKeyPair.publicKey);
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertFalse(
+ await verification.dataCorrupted?.then((x) => x[0]),
+ "Data should not be marked corrupted for a good signature",
+ );
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications, "Should find the signature");
+ assertEquals(verification.verifications.length, 1);
+
+ const sigVerification = verification.verifications[0];
+ assertExists(sigVerification.key, "Should find the signing key");
+ const signingKey = await sigVerification.key; // Assuming one key found
+ assertExists(signingKey, "Should find the signing key");
+ assertEquals(signingKey.getKeyID(), aliceKeyPair.publicKey.getKeyID());
+
+ assertExists(sigVerification.packet);
+ assertFalse(await sigVerification.signatureCorrupted.then((x) => x[0]));
+
+ // Expect verification to succeed and report 'G'
+ assert(
+ await sigVerification.verified,
+ "Verification should succeed for a good signature",
+ );
+ // assertEquals(await sigVerification.status, "G", "Status should be 'G'");
+ });
+
+ it("Scenario: Good signature, unknown validity ('U')", async () => {
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ signature,
+ );
+
+ // Add the key but do *not* establish ultimate trust for this key in the verifier's context
+ // This scenario relies on your verifier or OpenPGP.js handling the 'unknown trust' case.
+ verifier.addKey(aliceKeyPair.publicKey); // Key is available, but trust level is not set
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertFalse(await verification.dataCorrupted?.then((x) => x[0]));
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications, "Should find the signature");
+ assertEquals(verification.verifications.length, 1);
+
+ const sigVerification = verification.verifications[0];
+ assertExists(sigVerification.key);
+
+ assertExists(sigVerification.packet);
+ assertFalse(await sigVerification.signatureCorrupted.then((x) => x[0]));
+
+ // Expect cryptographic verification to succeed, but status to be 'U'
+ assert(
+ await sigVerification.verified,
+ "Cryptographic verification should succeed",
+ );
+ // assertEquals(
+ // await sigVerification.status,
+ // "U",
+ // "Status should be 'U' due to unknown validity",
+ // );
+ });
+
+ // TODO(#): Add tests for Scenarios involving Key Expiration ('X', 'Y')
+ // This requires creating keys with specific expiration dates and mocking the system clock
+ it("Scenario: Good signature, key expired *after* signature time ('X')", async () => {
+ // Use fake time to control the 'now'
+ const time = new FakeTime();
+
+ const keyExpirationTime = time.now + 30 * 1000;
+ const keyPairWithExpiry = await generateKeyPair("AliceWithExpiry", {
+ keyExpirationTime,
+ });
+
+ const signature = await createDetachedSignature(
+ originalData,
+ keyPairWithExpiry.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/sig_expired_after.sig"),
+ signature,
+ );
+
+ time.tick(60 * 1000);
+
+ verifier.addKey(keyPairWithExpiry.publicKey);
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ time.restore();
+
+ assertFalse(await verification.dataCorrupted?.then((x) => x[0]));
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications);
+ // const expirationDate = await verification.verifications[0].keys[0].then((
+ // x,
+ // ) => x.getExpirationTime());
+ // assertEquals(
+ // expirationDate?.valueOf(),
+ // new Date(keyExpirationTime).valueOf(),
+ // );
+ assertExists(await verification.verifications[0].packet);
+ assertFalse(
+ await verification.verifications[0].signatureCorrupted.then((x) =>
+ x[0]
+ ),
+ );
+ assert(await verification.verifications[0].verified);
+
+ // assertEquals(
+ // await verification.verifications![0].status,
+ // "X",
+ // "Status should be 'X' due to key expired after signature",
+ // );
+ });
+
+ it("Scenario: Good signature, key expired *before* signature time ('Y')", async () => {
+ // Use fake time to control the 'now' when creating the key (for expiration)
+ const time = new FakeTime();
+
+ const keyExpirationTime = time.now + 30 * 1000;
+ const keyPairExpiredBefore = await generateKeyPair("AliceExpiredBefore", {
+ keyExpirationTime,
+ });
+
+ time.tick(60 * 1000);
+
+ const signature = await createDetachedSignature(
+ originalData,
+ keyPairExpiredBefore.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/sig_expired_before.sig"),
+ signature,
+ );
+
+ verifier.addKey(keyPairExpiredBefore.publicKey);
+
+ time.tick(60 * 1000);
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ time.restore();
+
+ assertFalse(await verification.dataCorrupted?.then((x) => x[0]));
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications);
+ // const expirationDate = await verification.verifications[0].keys[0].then((
+ // x,
+ // ) => x.getExpirationTime());
+ // assertEquals(
+ // expirationDate?.valueOf(),
+ // new Date(keyExpirationTime).valueOf(),
+ // );
+ assertExists(await verification.verifications[0].packet);
+ assertFalse(
+ await verification.verifications[0].signatureCorrupted.then((x) =>
+ x[0]
+ ),
+ );
+ assert(await verification.verifications[0].verified);
+
+ //assertEquals(
+ // await verification.verifications![0].status,
+ // "Y",
+ // "Status should be 'Y' due to key expired before signature",
+ //);
+ });
+
+ // // TODO: Add tests for Scenarios involving Key Revocation ('R', 'Y')
+ // // This requires creating and distributing key revocation certificates. Simulating this is complex and might need mocking OpenPGP.js internal behavior or relying on its revocation handling.
+
+ // it("Scenario: Good signature, key revoked *after* signature time ('R')", async () => {
+ // // This requires creating a revocation certificate for the key *after* signing.
+ // assert(
+ // false,
+ // "Test not implemented: Simulating key revocation requires revocation certs.",
+ // );
+ // });
+
+ // it("Scenario: Good signature, key revoked *before* signature time ('Y')", async () => {
+ // // This requires creating a revocation certificate for the key *before* signing.
+ // assert(
+ // false,
+ // "Test not implemented: Simulating key revocation requires revocation certs.",
+ // );
+ // });
+
+ // it("Scenario: Signature cannot be checked (Public key available but not signing)", async () => {
+ // // Generate a key with only encryption or certification usage flags
+ // const nonSigningKeyPair = await generateKeyPair("AliceNonSigning", {
+ // usage: ["encrypt"],
+ // }); // Or ["certify"]
+
+ // const signature = await createDetachedSignature(
+ // originalData,
+ // aliceKeyPair.privateKey,
+ // ); // Signed with a signing key
+ // const signatureUrl = createInMemoryFile(
+ // new URL("file:///test/sig_non_signing_key.sig"),
+ // signature,
+ // );
+
+ // // Add the non-signing key to the verifier instead of the actual signing key
+ // await verifier.addKey(nonSigningKeyPair.publicKey);
+
+ // const verification: Verification = await verifier.verify([
+ // originalDataUrl,
+ // signatureUrl,
+ // ]);
+
+ // assertExists(verification.verifications, "Should find the signature");
+ // assertEquals(verification.verifications.length, 1);
+
+ // const sigVerification = verification.verifications[0];
+ // // Key is found, but it's the wrong type of key for verification
+ // assertExists(sigVerification.keys, "Should find a key");
+
+ // // Expect verification to fail and report 'E' or potentially 'B' depending on how openpgp.js handles this
+ // // OpenPGP.js often reports 'E' if the key's capabilities don't match the packet type.
+ // assertEquals(
+ // await sigVerification.verified,
+ // false,
+ // "Verification should fail with a non-signing key",
+ // );
+ // // We expect 'E' as the most likely status
+ // assertEquals(
+ // await sigVerification.status,
+ // "E",
+ // "Status should be 'E' with a non-signing key",
+ // );
+ // });
+
+ // TODO: Add scenarios involving signing subkeys if your verifier needs to distinguish them
+ // These would require more complex key generation and potentially inspecting the packet details.
+ });
+
+ // // --- Scenarios for multiple signatures ---
+ // describe("when verifying a file with multiple signatures", () => {
+ // const originalData = new TextEncoder().encode("This file has multiple signatures.");
+ // let originalDataUrl: URL;
+ //
+ // beforeEach(() => {
+ // originalDataUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt"), originalData);
+ // });
+ //
+ //
+ // it("Scenario: All signatures are Good ('G')", async () => {
+ // // Create signatures by Alice and Bob
+ // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey);
+ // const bobSignature = await createDetachedSignature(originalData, bobKeyPair.privateKey);
+ //
+ // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature);
+ // const bobSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobSignature);
+ //
+ // // Add both signing keys (assume trusted for this scenario)
+ // await verifier.addKey(aliceKeyPair.publicKey);
+ // await verifier.addKey(bobKeyPair.publicKey);
+ //
+ // // Verify with multiple signature files
+ // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobSignatureUrl]);
+ //
+ // assertEquals(new Uint8Array(verification.data), originalData);
+ // assertEquals(verification.dataCorrupted, false);
+ // assertExists(verification.verifications);
+ // assertEquals(verification.verifications.length, 2); // Two signatures found
+ //
+ // // Check the status of each verification result
+ // const statuses = await Promise.all(verification.verifications.map(v => v.status));
+ // assertArrayIncludes(statuses, ['G', 'G'], "Both signatures should have 'G' status");
+ //
+ // // Check the key IDs found for each verification
+ // const keyIDs = await Promise.all(verification.verifications.map(async v => (await v.keys)[0]?.getKeyID()));
+ // assertArrayIncludes(keyIDs.filter(defined), [aliceKeyPair.publicKey.getKeyID(), bobKeyPair.publicKey.getKeyID()]);
+ // });
+ //
+ // it("Scenario: Some signatures are Good ('G'), others are Bad ('B')", async () => {
+ // // Create a good signature by Alice
+ // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey);
+ // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature);
+ //
+ // // Create a bad signature by attempting to sign corrupted data with Bob's key
+ // const corruptedDataForBadSig = corruptData(originalData);
+ // const bobBadSignature = await createDetachedSignature(corruptedDataForBadSig, bobKeyPair.privateKey);
+ // const bobBadSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobBadSignature);
+ //
+ //
+ // // Add both signing keys
+ // await verifier.addKey(aliceKeyPair.publicKey);
+ // await verifier.addKey(bobKeyPair.publicKey);
+ //
+ // // Verify against the original data, but provide one good and one bad signature file
+ // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobBadSignatureUrl]);
+ //
+ // assertEquals(new Uint8Array(verification.data), originalData); // Verifier should use the original data if found and matching a good sig
+ // assertEquals(verification.dataCorrupted, false, "Data should not be marked corrupted if at least one good signature matches");
+ //
+ // assertExists(verification.verifications);
+ // assertEquals(verification.verifications.length, 2);
+ //
+ // // Check the status of each verification result
+ // const statuses = await Promise.all(verification.verifications.map(v => v.status));
+ // // Expect one 'G' and one 'B' status
+ // assertEquals(statuses.filter(s => s === 'G').length, 1);
+ // assertEquals(statuses.filter(s => s === 'B').length, 1);
+ //
+ // // You would also need to check which key corresponded to the 'G' and 'B' status
+ // // This requires correlating the verification result with the key ID/fingerprint.
+ // const verifications = await Promise.all(verification.verifications.map(async v => ({ status: await v.status, keyID: (await Promise.all(v.keys))[0]?.getKeyID() })));
+ //
+ // assert(verifications.some(v => v.status === 'G' && v.keyID === aliceKeyPair.publicKey.getKeyID()), "Alice's signature should be Good");
+ // assert(verifications.some(v => v.status === 'B' && v.keyID === bobKeyPair.publicKey.getKeyID()), "Bob's signature should be Bad");
+ // });
+ //
+ // it("Scenario: Some signatures cannot be checked ('E'), others are Good ('G')", async () => {
+ // // Create a good signature by Alice
+ // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey);
+ // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature);
+ //
+ // // Create a signature by Bob but don't add Bob's key to the verifier (will result in 'E')
+ // const bobSignature = await createDetachedSignature(originalData, bobKeyPair.privateKey);
+ // const bobSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobSignature);
+ //
+ //
+ // // Add only Alice's key
+ // await verifier.addKey(aliceKeyPair.publicKey);
+ //
+ // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobSignatureUrl]);
+ //
+ // assertEquals(new Uint8Array(verification.data), originalData);
+ // assertEquals(verification.dataCorrupted, false);
+ // assertExists(verification.verifications);
+ // assertEquals(verification.verifications.length, 2);
+ //
+ // const statuses = await Promise.all(verification.verifications.map(v => v.status));
+ //
+ // assertEquals(statuses.filter(s => s === 'G').length, 1, "One signature should be Good (Alice)");
+ // assertEquals(statuses.filter(s => s === 'E').length, 1, "One signature should be 'E' (Bob - missing key)");
+ //
+ // const verifications = await Promise.all(verification.verifications.map(async v => ({ status: await v.status, keyID: (await Promise.all(v.keys))[0]?.getKeyID() })));
+ //
+ // assert(verifications.some(v => v.status === 'G' && v.keyID === aliceKeyPair.publicKey.getKeyID()), "Alice's signature should be Good");
+ // // For the 'E' status (missing key), the keyID might be undefined or the partial KeyID from the packet.
+ // // We'll just check that one status is 'E'.
+ // assert(verifications.some(v => v.status === 'E'), "One signature should be 'E'");
+ // });
+ //
+ //
+ // // TODO: Continue adding tests for all combinations from the multiple signatures table
+ // // This requires combining different key states (expired, revoked, untrusted) for different signers
+ // // within the same verification process. This is the most complex part.
+ //
+ // it("Scenario: All signatures Unknown Validity ('U')", async () => {
+ // // Requires generating signatures with keys that are valid but not ultimately trusted for all signers.
+ // // Then verifying without establishing a trust path for any key.
+ // assert(false, "Test not implemented: Simulating unknown trust for all signatures.");
+ // });
+ //
+ // it("Scenario: At least one Good signature, with others having Key Status issues (e.g., 'X', 'Y', 'R')", async () => {
+ // // Requires creating signatures with a mix of good keys and expired/revoked keys for different signers.
+ // assert(false, "Test not implemented: Combining different key states for multiple signers.");
+ // });
+ //
+ // it("Scenario: All signatures have Key Status issues ('X', 'Y', 'R')", async () => {
+ // // Requires creating signatures with only expired or revoked keys for all signers.
+ // assert(false, "Test not implemented: Simulating all signatures with key status issues.");
+ // });
+ //
+ // it("Scenario: Combination of Bad, Unknown, and Key Status issues", async () => {
+ // // This is a very complex scenario combining multiple failure types across different signatures.
+ // assert(false, "Test not implemented: Simulating a complex mix of failure types.");
+ // });
+ //
+ // it("Scenario: At least one signature is valid, but some Public Keys not available", async () => {
+ // // Requires providing multiple signature files, but only providing some of the signing keys to the verifier.
+ // assert(false, "Test not implemented: Simulating missing keys for some signatures in a multi-signature scenario.");
+ // });
+ //
+ // it("Scenario: At least one signature is valid, but some Public Keys available but not Signing Keys", async () => {
+ // // Requires providing multiple signature files, and providing a key that is NOT a signing key for one of them.
+ // assert(false, "Test not implemented: Simulating non-signing keys for some signatures in a multi-signature scenario.");
+ // });
+ // });
+});
+*/
diff --git a/src/lib/pgp/verify.ts b/src/lib/pgp/verify.ts
new file mode 100644
index 0000000..da2de7f
--- /dev/null
+++ b/src/lib/pgp/verify.ts
@@ -0,0 +1,349 @@
+import {
+ createMessage,
+ PublicKey,
+ readSignature,
+ type Subkey,
+ UserIDPacket,
+ verify,
+} from "openpgp";
+import {
+ armored,
+ binary,
+ createKeyFromArmor,
+ createKeyFromBinary,
+ createKeyFromFile,
+ createKeysFromDir,
+ DEFAULT_KEY_DISCOVERY_RULES,
+ type KeyDiscoveryRules,
+ type KeyFileFormat,
+} from "./create.ts";
+import { getLastCommitForOneOfFiles } from "../git/log.ts";
+import { defined, get, instanciate } from "../../utils/anonymous.ts";
+import { Packet, Signature } from "./sign.ts";
+import type { Commit } from "../git/types.ts";
+import { TRUSTED_KEYS_DIR } from "../../consts.ts";
+import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts";
+
+type DataURL = [URL, URL?];
+type Corrupted = [false] | [true, Error];
+
+export interface Verification {
+ data: Uint8Array<ArrayBufferLike>;
+ dataCorrupted?: Promise<Corrupted>;
+ signatureCorrupted?: Corrupted;
+ signature?: Signature;
+ verifications?: {
+ key: Promise<PublicKey | Subkey | undefined>;
+ keyID: Awaited<ReturnType<typeof verify>>["signatures"][number]["keyID"];
+ userID: Promise<UserIDPacket[] | undefined>;
+ packet: Promise<Packet>;
+ signatureCorrupted: Promise<Corrupted>;
+ verified: Promise<boolean>;
+ }[];
+ commit: Promise<Commit | undefined>;
+}
+
+export class SignatureVerifier {
+ static #instance: SignatureVerifier;
+ private keys!: PublicKey[];
+ #encoder!: TextEncoder;
+ #decoder!: TextDecoder;
+
+ constructor() {
+ this.keys = [];
+ this.#encoder = new TextEncoder();
+ this.#decoder = new TextDecoder();
+ }
+
+ /**
+ * Let's test all the possible outcome situations that can happened when
+ * verifying a signature of a file. A signature verification needs the message,
+ * the signature (detached) and the public keys.
+ *
+ * **Possible verification outcomes**
+ *
+ * Legend:
+ *
+ * - "X" → This condition is definitely true for the outcome.
+ * - "-" → This condition is not applicable or irrelevant.
+ * - "?" → This condition may or may not be true; the outcome doesn't guarantee it.
+ *
+ * | Outcome Description | Data Exists | Data Corrupted | Signature Exists | Signature Corrupted/Malformed | Public Key Available | Public Key is Signing Key | Public Key Expired Before Signature | Public Key Expired After Signature | Public Key Revoked Before Signature | Public Key Revoked After Signature | Public Key Ultimately Trusted | GPG/OpenPGP Status Output | Notes |
+ * | ------------------------------------------------------------------------------- | :---------: | :------------: | :--------------: | :---------------------------: | :------------------: | :-----------------------: | :---------------------------------: | :--------------------------------: | :---------------------------------: | :--------------------------------: | :---------------------------: | :------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
+ * | **No signature found** | X | ? | | | | | - | - | - | - | | (No status) | No signature file provided or found. Data state is independent of this. |
+ * | **Signature cannot be checked (e.g., missing key, GPG error)** | X | ? | X | ? | | | - | - | - | - | ? | `E` | Verification failed before key or validity checks could be performed. Can be missing key, corrupted signature *format*, or GPG issue. |
+ * | **Bad signature** | X | X | X | | X | X | ? | ? | ? | ? | ? | `B` | The signature does not match the data, usually due to data corruption or a manipulated signature. Key status is irrelevant to the mismatch itself. |
+ * | **Good signature, unknown validity** | X | | X | | X | X | | | | | | `U` | Signature is cryptographically valid, key is available and is a signing key, but OpenPGP.js/GPG cannot determine the trust or validity of the key or signature attributes. |
+ * | **Good signature** | X | | X | | X | X | | | | | X | `G` | The signature is cryptographically valid, the key is available, is a signing key, and is ultimately trusted in the local keyring. |
+ * | **Good signature by an untrusted key** | X | | X | | X | X | | | | | | `G` (often with trust warning) | The signature is cryptographically valid, key is available and signing key, but not ultimately trusted. GPG might still report `G`. |
+ * | **Good signature, key expired *after* signature time** | X | | X | | X | X | | X | | | ? | `X` | The signature was valid at the time of signing, but the key's validity period has since passed. |
+ * | **Good signature, key expired *before* signature time** | X | | X | | X | X | X | | | | ? | `Y` | The signature was created *after* the key's validity period had passed. This signature is typically considered invalid. |
+ * | **Good signature, key revoked *after* signature time** | X | | X | | X | X | ? | ? | | X | ? | `R` | The signature was valid at the time of signing, but the key has since been revoked. |
+ * | **Good signature, key revoked *before* signature time** | X | | X | | X | X | ? | ? | X | | ? | `Y` (often, similar to expired before) | The signature was created *after* the key had been revoked. This signature is typically considered invalid. |
+ * | **Signature cannot be checked (Public key available but not signing)** | X | ? | X | | X | | ? | ? | ? | ? | ? | `E` (or possibly `B`) | The key required for verification is found, but it does not have the 'sign' usage flag, making verification impossible with this key. |
+ * | **Good signature, made by an expired signing subkey (primary key not expired)** | X | | X | | X | X | | X | | | ? | `X` | The signature was made by a subkey that expired *after* the signature time. The primary key might still be valid. |
+ * | **Good signature, made by a revoked signing subkey (primary key not revoked)** | X | | X | | X | X | ? | ? | | X | ? | `R` | The signature was made by a subkey that was revoked *after* the signature time. The primary key might still be valid. |
+ * | **Good signature, made by a signing subkey expired *before* signature** | X | | X | | X | X | X | | | | ? | `Y` | The signature was made by a subkey that was expired *before* the signature time. |
+ * | **Good signature, made by a signing subkey revoked *before* signature** | X | | X | | X | X | ? | ? | X | | ? | `Y` | The signature was made by a subkey that was revoked *before* the signature time. |
+ *
+ * | Outcome Description (Combined Statuses) | Data Exists | Data Corrupted | Signature(s) Exist | At least one Signature Corrupted/Malformed | At least one Public Key Available | At least one Public Key is Signing Key | All Keys Good/Trusted? | Notes |
+ * |---------------------------------------------------------------------------|-------------|----------------|--------------------|--------------------------------------------|-----------------------------------|----------------------------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+ * | **No signature found** | X | ? | | | | | - | No signature file(s) provided or found. |
+ * | **At least one signature cannot be checked (`E`), others unknown/not checked** | X | ? | X | ? | ? | ? | ? | One or more signatures failed verification before a status could be determined (missing key, GPG issue, etc.). Other signatures' statuses might be pending or unknown. |
+ * | **All signatures are Bad (`B`)** | X | X | X | | X | X | ? | All provided signatures failed to match the data. Often due to data corruption or tampered signatures. |
+ * | **Some signatures are Good (`G`), others are Bad (`B`)** | X | ? | X | | X | X | ? | At least one valid signature found, but also invalid ones. Indicates the file was signed correctly by some, but perhaps tampered with later or signed incorrectly. |
+ * | **All signatures are Good (`G`)** | X | | X | | X | X | X | All provided signatures are cryptographically valid and from ultimately trusted keys. This is a strong indicator of data integrity and origin. |
+ * | **Some signatures are Good (`G`), others Unknown Validity (`U`)** | X | ? | X | | X | X | ? | Some valid signatures, others valid but trust/validity could not be fully determined. |
+ * | **All signatures Unknown Validity (`U`)** | X | ? | X | | X | X | | All provided signatures are cryptographically valid, but the validity or trust of the signing keys could not be determined for any of them. |
+ * | **At least one Good signature (`G`), with others having Key Status issues (`X`, `Y`, `R`)** | X | ? | X | | X | X | ? | At least one valid and potentially trusted signature exists, but others are from expired or revoked keys. Indicates multiple signers, some with key lifecycle issues. |
+ * | **All signatures have Key Status issues (`X`, `Y`, `R`)** | X | ? | X | | X | X | | All provided signatures are from keys that are expired or revoked. The data integrity might be verifiable for the time of signing, but the signers' keys are compromised or outdated. |
+ * | **Combination of Bad (`B`), Unknown (`U`), and Key Status issues (`X`, `Y`, `R`)** | X | ? | X | ? | X | X | ? | A complex mix of verification outcomes for multiple signatures. Requires examining each individual signature's status to understand the situation fully. |
+ * | **At least one signature is valid (`G`, `U`, `X`, `R`), but some Public Keys not available** | X | ? | X | | ? | ? | ? | Some signatures could be verified because their keys were available, but others could not be fully checked because their corresponding keys were missing. |
+ * | **At least one signature is valid (`G`, `U`, `X`, `R`), but some Public Keys available but not Signing Keys** | X | ? | X | | X | ? | ? | Keys were found for some signatures, allowing some level of verification, but for others, the found key did not have the signing capability. |
+ */
+ async verify(
+ data: DataURL,
+ type: KeyFileFormat = binary,
+ ): Promise<Verification> {
+ // will throw if the file doesn't exist, not a file, ...
+ // we need data.
+ const dataBinary = await Deno.readFile(data[0], {});
+
+ const signatureURL = new URL(
+ data[1] ?? `${data[0].href}.${type === binary ? "sig" : "asc"}`,
+ );
+ const signatureData =
+ await (type === binary
+ ? Deno.readFile(signatureURL)
+ : Deno.readTextFile(signatureURL)).catch(() => undefined);
+
+ let signature: Signature | undefined;
+ let signatureCorrupted: Corrupted | undefined = undefined;
+ if (signatureData !== undefined) {
+ try {
+ signature = new Signature(
+ await (typeof signatureData === "string"
+ ? readSignature({ armoredSignature: signatureData })
+ : readSignature({ binarySignature: signatureData })),
+ );
+ signatureCorrupted = [false];
+ } catch (e) {
+ if (
+ !(e instanceof Error &&
+ [
+ "Error during parsing",
+ "Packet not allowed in this context",
+ "Unexpected end of packet",
+ ].some(
+ (x) => e.message.startsWith(x),
+ ))
+ ) {
+ throw e;
+ }
+ signatureCorrupted = [true, e];
+ }
+ }
+
+ const commit = signature !== undefined
+ ? getLastCommitForOneOfFiles([data[0], signatureURL])
+ : Promise.resolve(undefined);
+
+ const verification: Verification = {
+ data: dataBinary,
+ signature,
+ signatureCorrupted,
+ commit,
+ };
+
+ if (dataBinary === undefined || signature === undefined) {
+ return verification;
+ }
+
+ const message = await createMessage({ binary: dataBinary });
+
+ const verificationResult = await verify({
+ message,
+ signature: signature?.inner,
+ verificationKeys: this.keys,
+ format: "binary",
+ });
+
+ verification.verifications = verificationResult.signatures.map(
+ ({ verified, keyID, signature: sig }) => {
+ const key = findMapAsync(this.keys, (x) => x.getSigningKey(keyID));
+ const packet = sig.then((x) => x.packets[0]).then(instanciate(Packet));
+ const userID = key.then((key) =>
+ key ? getUserIDsFromKey(signature, key) : undefined
+ );
+ const signatureCorrupted = isSignatureCorrupted(verified);
+ return { key, keyID, userID, packet, signatureCorrupted, verified };
+ },
+ );
+
+ verification.dataCorrupted = isDataCorrupted(verification.verifications);
+
+ return verification;
+ }
+
+ async *verifyMultiple(
+ data: Iterable<DataURL>,
+ type: KeyFileFormat = binary,
+ ): AsyncGenerator<Verification, void, void> {
+ for (const i of data) {
+ yield this.verify(i, type);
+ }
+ }
+
+ addKey(key: MaybeIterable<PublicKey>): void {
+ if (key instanceof PublicKey) {
+ this.keys.push(key);
+ } else {
+ this.keys.push(...key);
+ }
+ }
+
+ async addKeysFromDir(
+ key: string | URL,
+ rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES,
+ ): Promise<void> {
+ for await (
+ const i of createKeysFromDir(key, rules, {
+ encoder: this.#encoder,
+ decoder: this.#decoder,
+ })
+ ) {
+ this.keys.push(i);
+ }
+ }
+
+ async addKeyFromFile(
+ key: string | URL,
+ type: KeyFileFormat,
+ ): Promise<void> {
+ switch (type) {
+ case armored: {
+ this.keys.push(await createKeyFromFile(key, type, this.#decoder));
+ break;
+ }
+ case binary: {
+ this.keys.push(await createKeyFromFile(key, type, this.#encoder));
+ break;
+ }
+ }
+ }
+
+ async addKeyFromArmor(
+ key: string | Uint8Array,
+ ): Promise<void> {
+ this.keys.push(
+ await createKeyFromArmor(key, this.#decoder).then((x) => x.toPublic()),
+ );
+ }
+
+ async addKeyFromBinary(
+ key: string | Uint8Array,
+ ): Promise<void> {
+ this.keys.push(
+ await createKeyFromBinary(key, this.#encoder).then((x) => x.toPublic()),
+ );
+ }
+
+ public static async instance(): Promise<SignatureVerifier> {
+ if (!SignatureVerifier.#instance) {
+ SignatureVerifier.#instance = new SignatureVerifier();
+ await SignatureVerifier.#instance.addKeysFromDir(TRUSTED_KEYS_DIR);
+ }
+
+ return SignatureVerifier.#instance;
+ }
+
+ public clone(): this {
+ const clone = new SignatureVerifier();
+
+ clone.keys = Object.create(this.keys);
+ // clone.#decoder = Object.create(this.#decoder);
+ // clone.#encoder = Object.create(this.#encoder);
+
+ return clone as this;
+ }
+}
+
+export const verifier = SignatureVerifier.instance();
+
+function getUserIDsFromKey(
+ signature: Signature,
+ key: PublicKey | Subkey,
+): UserIDPacket[] {
+ const packet = signature.getPackets()[0];
+ const userID = packet.signersUserID;
+
+ if (userID) {
+ return [UserIDPacket.fromObject(parseUserID(userID))];
+ }
+
+ key = key instanceof PublicKey ? key : key.mainKey;
+ return key.users.map(get("userID")).filter(defined);
+}
+
+function parseUserID(input: string) {
+ const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/;
+ const match = input.match(regex);
+
+ if (!match) return {};
+
+ const [, name, comment, email] = match;
+
+ return {
+ name: name?.trim() || undefined,
+ comment: comment?.trim() || undefined,
+ email: email?.trim() || undefined,
+ };
+}
+
+async function isSignatureCorrupted(
+ verified: Awaited<
+ ReturnType<typeof verify>
+ >["signatures"][number]["verified"],
+): Promise<Corrupted> {
+ return await verified.then(() => [false] as Corrupted).catch(
+ (e) => {
+ if (e instanceof Error) {
+ if (
+ [
+ "Could not find signing key with key ID",
+ "Signed digest did not match",
+ ].some((x) => e.message.startsWith(x))
+ ) {
+ return [false];
+ }
+
+ return [true, e];
+ }
+ throw e;
+ },
+ );
+}
+
+function isDataCorrupted(
+ verifications: Verification["verifications"],
+): Promise<Corrupted> {
+ return new Promise<Corrupted>((resolve) => {
+ if (verifications === undefined) {
+ resolve([false]);
+ } else {
+ Promise.all(verifications.map(get("verified"))).then(
+ () => resolve([false]),
+ ).catch((e) => {
+ if (e instanceof Error) {
+ if (
+ e.message.startsWith("Signed digest did not match")
+ ) {
+ resolve([true, e]);
+ }
+ }
+
+ resolve([false]);
+ });
+ }
+ });
+}
diff --git a/src/pages/blog/[...year].astro b/src/pages/blog/[...year].astro
new file mode 100644
index 0000000..f148a76
--- /dev/null
+++ b/src/pages/blog/[...year].astro
@@ -0,0 +1,165 @@
+---
+import { getCollection } from "astro:content";
+import type { CollectionEntry } from "astro:content";
+import Base from "@layouts/Base.astro";
+import DateSelector from "@components/DateSelector.astro";
+import BlogCard from "@components/BlogCard.astro";
+
+type Props = {
+ posts: CollectionEntry<"blog">[];
+ next: string;
+ previous: string;
+ years: number[];
+ months: number[];
+ days?: number[];
+};
+
+export async function getStaticPaths() {
+ const posts = await getCollection("blog");
+
+ const archive = {
+ years: new Set<number>(),
+ monthsByYear: new Map<string, Set<number>>(),
+ daysByMonth: new Map<string, Set<number>>(),
+ postsByDate: new Map<string, typeof posts>(),
+ sortedDates: [] as string[],
+ };
+
+ const getYMD = (date: Date) => {
+ const y = date.getFullYear();
+ const m = date.getMonth() + 1;
+ const d = date.getDate();
+ return { y, m, d };
+ };
+
+ for (const post of posts) {
+ const { y, m, d } = getYMD(post.data.dateCreated);
+
+ archive.years.add(y);
+
+ if (!archive.monthsByYear.has(y.toString())) {
+ archive.monthsByYear.set(y.toString(), new Set());
+ }
+ archive.monthsByYear.get(y.toString())!.add(m);
+
+ const ym = `${y}/${String(m).padStart(2, "0")}`;
+ if (!archive.daysByMonth.has(ym)) archive.daysByMonth.set(ym, new Set());
+ archive.daysByMonth.get(ym)!.add(d);
+
+ const ymd = `${ym}/${String(d).padStart(2, "0")}`;
+ if (!archive.postsByDate.has(ymd)) archive.postsByDate.set(ymd, []);
+ archive.postsByDate.get(ymd)!.push(post);
+ }
+
+ archive.sortedDates = Array.from(archive.postsByDate.keys()).sort();
+
+ const paths = [];
+
+ const sortedYears = Array.from(archive.years).sort();
+
+ const lastYear = Math.max(...sortedYears.map(Number));
+ paths.push({
+ params: { year: undefined },
+ props: {
+ posts: posts.filter((p) =>
+ p.data.dateCreated.getFullYear() === lastYear
+ ),
+ next: undefined,
+ previous: sortedYears?.[sortedYears.length - 2],
+ years: sortedYears,
+ months: Array.from(archive.monthsByYear.get(lastYear.toString()) ?? []),
+ },
+ });
+
+ for (const y of sortedYears) {
+ const yearPosts = posts.filter((p) =>
+ p.data.dateCreated.getFullYear() === Number(y)
+ );
+ const idx = sortedYears.indexOf(y);
+ paths.push({
+ params: { year: y },
+ props: {
+ posts: yearPosts,
+ next: sortedYears?.[idx + 1],
+ previous: sortedYears?.[idx - 1],
+ years: sortedYears,
+ months: Array.from(archive.monthsByYear.get(y.toString()) ?? []),
+ },
+ });
+ }
+
+ const allMonths = Array.from(archive.monthsByYear.entries())
+ .flatMap(([year, mset]) =>
+ Array.from(mset).map((m) => `${year}/${String(m).padStart(2, "0")}`)
+ )
+ .sort();
+
+ for (const [y, months] of archive.monthsByYear) {
+ const sortedMonths = Array.from(months).sort();
+ for (const m of sortedMonths) {
+ const monthPosts = posts.filter((p) => {
+ const d = p.data.dateCreated;
+ return (
+ d.getFullYear() === Number(y) &&
+ d.getMonth() + 1 === m
+ );
+ });
+
+ const ym = `${y}/${String(m).padStart(2, "0")}`;
+ const idx = allMonths.indexOf(ym);
+
+ paths.push({
+ params: { year: ym },
+ props: {
+ posts: monthPosts,
+ next: allMonths?.[idx + 1],
+ previous: allMonths?.[idx - 1],
+ years: sortedYears,
+ months: Array.from(months).sort(),
+ days: Array.from(archive.daysByMonth.get(ym) ?? []).sort(),
+ },
+ });
+ }
+ }
+
+ for (let i = 0; i < archive.sortedDates.length; i++) {
+ const ymd = archive.sortedDates[i];
+ const [y, m] = ymd.split("/");
+ paths.push({
+ params: { year: ymd },
+ props: {
+ posts: archive.postsByDate.get(ymd),
+ next: archive.sortedDates?.[i + 1],
+ previous: archive.sortedDates?.[i - 1],
+ years: sortedYears,
+ months: Array.from(archive.monthsByYear.get(y) ?? []).sort(),
+ days: Array.from(archive.daysByMonth.get(`${y}/${m}`) ?? []).sort(),
+ },
+ });
+ }
+
+ return paths;
+}
+
+const title = "Blog";
+const description = "Latest articles.";
+
+let { posts, previous, next, years, months, days } = Astro.props;
+posts = posts.sort((a, b) =>
+ new Date(b.data.dateCreated).valueOf() -
+ new Date(a.data.dateCreated).valueOf()
+);
+const date = posts[0].data.dateCreated as Date;
+---
+
+<Base {title} {description}>
+ <main>
+ <h2>Blogue</h2>
+ {date && <DateSelector {date} {years} {months} {days} />}
+ {posts.map((post) => <BlogCard {...post} />)}
+ <div>
+ {previous && <a href={`/blog/${Astro.props.previous}`}>Previous</a>}
+ {next && <a href={`/blog/${Astro.props.next}`}>Next</a>}
+ </div>
+ </main>
+</Base>
diff --git a/src/pages/blog/keywords/[...slug].astro b/src/pages/blog/keywords/[...slug].astro
new file mode 100644
index 0000000..724e8b7
--- /dev/null
+++ b/src/pages/blog/keywords/[...slug].astro
@@ -0,0 +1,40 @@
+---
+import { type CollectionEntry, getCollection } from "astro:content";
+import Base from "@layouts/Base.astro";
+import BlogCard from "@components/BlogCard.astro";
+
+type Props = { posts: CollectionEntry<"blog">[] };
+
+export async function getStaticPaths() {
+ const posts = await getCollection("blog");
+ const keywords = [
+ ...new Set(
+ await getCollection("blog").then((x) =>
+ x.flatMap((x) => x.data.keywords)
+ ),
+ ).values(),
+ ];
+ return keywords.map((k) => ({
+ params: { slug: k },
+ props: {
+ posts: posts.filter((post) =>
+ post.data.keywords.some((i) => i.localeCompare(k) === 0)
+ ),
+ },
+ }));
+}
+
+const title = "Blog";
+const description = "Latest articles.";
+
+const posts = Astro.props.posts.sort((a, b) =>
+ new Date(b.data.dateCreated).valueOf() -
+ new Date(a.data.dateCreated).valueOf()
+);
+---
+
+<Base {title} {description}>
+ <main>
+ <h2>Blogue</h2> {posts.map((post) => <BlogCard {...post} />)}
+ </main>
+</Base>
diff --git a/src/pages/blog/keywords/index.astro b/src/pages/blog/keywords/index.astro
new file mode 100644
index 0000000..255fbf4
--- /dev/null
+++ b/src/pages/blog/keywords/index.astro
@@ -0,0 +1,21 @@
+---
+import { getCollection } from "astro:content";
+import Base from "@layouts/Base.astro";
+
+const title = "Keywords";
+const description = "Keywords";
+
+const blogs = await getCollection("blog");
+let keywords = [
+ ...new Set([
+ ...blogs.flatMap(({ data }) => [...(data.keywords ?? [])]),
+ ]),
+];
+---
+
+<Base {title} {description} {keywords}>
+ <h1>Keywords</h1>
+ <ul>
+ {keywords.map((k) => <li><a href={`/blog/keywords/${k}`}>{k}</a></li>)}
+ </ul>
+</Base>
diff --git a/src/pages/blog/read/[...slug].astro b/src/pages/blog/read/[...slug].astro
new file mode 100644
index 0000000..05d68e8
--- /dev/null
+++ b/src/pages/blog/read/[...slug].astro
@@ -0,0 +1,333 @@
+---
+import { type CollectionEntry, getCollection } from "astro:content";
+import { render } from "astro:content";
+import BaseHead from "@components/BaseHead.astro";
+import Translations from "@components/Translations.astro";
+import { toIso8601Full } from "@utils/datetime";
+import ReadingTime from "@components/ReadingTime.astro";
+import Keywords from "@components/Keywords.astro";
+import Citations from "@components/Citations.astro";
+import Signature from "@components/signature/Signature.astro";
+import CopyrightNotice from "@components/CopyrightNotice.astro";
+import { getEntries } from "astro:content";
+import { verifier as verifierPrototype } from "@lib/pgp/verify";
+import { defined, get } from "@utils/anonymous";
+import Authors from "@components/signature/Authors.astro";
+import { getEntry } from "astro:content";
+
+export async function getStaticPaths() {
+ const posts = await getCollection("blog");
+ return posts.map((post) => ({
+ params: { slug: post.id },
+ props: post,
+ }));
+}
+
+type Props = CollectionEntry<"blog">;
+
+const post = Astro.props;
+
+if (defined(post.data.translationOf)) {
+ const original = await getEntry(
+ post.data.translationOf as CollectionEntry<"blog">,
+ );
+
+ if (!original) {
+ throw new Error(`Original post not found for ${post.id}`);
+ }
+
+ const originalAuthor = (original.data.signers ?? []).filter(
+ (s) => s.role === "author",
+ ).map((s) => s.entity.id)?.[0];
+ const originalCoAuthors = new Set(
+ (original.data.signer ?? []).filter(
+ (s) => s.role === "co-author",
+ ).map((s) => s.entity.id),
+ );
+ const translationAuthor = (post.data.signer ?? []).filter(
+ (s) => s.role === "author",
+ ).map((s) => s.entity.id)?.[0];
+ const translationCoAuthors = new Set(
+ (post.data.signer ?? []).filter(
+ (s) => s.role === "co-author",
+ ).map((s) => s.entity.id),
+ );
+
+ if (
+ (translationAuthor !== undefined &&
+ translationAuthor !== originalAuthor) ||
+ !translationCoAuthors.isSubsetOf(originalCoAuthors)
+ ) {
+ throw new Error(
+ `Post ${post.id} has mismatched (co-)authors from original post ${original.id}`,
+ );
+ }
+
+ const translators = (post.data.signer ?? []).filter(
+ (s) => s.role === "translator",
+ ).map((s) => s.entity.id);
+
+ for (const translator of translators) {
+ if (
+ originalAuthor === translator || originalCoAuthors.has(translator)
+ ) {
+ throw new Error(
+ `Translator ${translator} in ${post.id} is already a (co-)author in original post`,
+ );
+ }
+ }
+} else {
+ if (post.data.signer?.some((x) => x.role === "translator")) {
+ throw new Error(
+ `Post ${post.id} is not a translation but has translators defined`,
+ );
+ }
+}
+
+// Add own post as a translation
+const translationsSet = new Set(
+ (await getCollection(
+ "blog",
+ (x) =>
+ x.data.translationOf?.id ===
+ (post.data.translationOf !== undefined
+ ? post.data.translationOf.id
+ : post.id),
+ ) ?? []).map(({ id }) => id),
+);
+
+translationsSet.add(post.id);
+const translations = [...translationsSet.values()].map((id) => ({
+ collection: post.collection,
+ id,
+}));
+
+const signers = await getEntries(
+ post.data.signer?.map(get("entity")) ?? [],
+).then((x) => x.filter(defined))
+ .then((x) =>
+ x.map((x) => ({
+ entity: x,
+ role: post.data.signer?.find((y) => y.entity.id === x.id)?.role,
+ }))
+ )
+ .then((x) => x.filter((x) => x.role !== undefined));
+
+const verifier = await verifierPrototype.then((x) => x.clone());
+
+// Add signers public keys to keyring
+for (const { data } of signers.map(get("entity"))) {
+ if (data.publickey.armor !== undefined) {
+ verifier.addKeyFromArmor(data.publickey.armor);
+ }
+}
+
+const verification = post.filePath !== undefined
+ ? await verifier.verify([
+ new URL(`file://${Deno.cwd()}/${post.filePath}`),
+ ])
+ : undefined;
+
+const { Content } = await render(post);
+
+const { lang } = post.data;
+
+const commit = await verification?.commit;
+---
+
+<html lang="pt-PT">
+ <head>
+ <BaseHead title={post.data.title} description={post.data.description} />
+ </head>
+
+ <body>
+ <main>
+ <article
+ itemscope
+ itemtype="http://schema.org/BlogPosting"
+ itemid={Astro.url.href}
+ >
+ <Translations {translations} {lang} />
+ <hgroup>
+ <h1 itemprop="headline">{post.data.title}</h1>
+ {
+ post.data.subtitle && (
+ <p itemprop="alternativeHeadline" class="subtitle">
+ {post.data.subtitle}
+ </p>
+ )
+ }
+ </hgroup>
+ {
+ post.data.description && (
+ <section itemprop="abstract">
+ <h2>Resumo</h2>
+ {
+ post.data.description.split(new RegExp("\\s{2,}"))
+ .map((
+ x,
+ ) => (
+ <p>{x}</p>
+ ))
+ }
+ </section>
+ )
+ }
+ {verification && <Signature {lang} {verification} />}
+ <footer>
+ {
+ verification?.verifications &&
+ (
+ <Authors
+ verifications={verification.verifications}
+ expectedSigners={signers}
+ commitSignerKey={commit?.signature?.keyFingerPrint}
+ />
+ )
+ }
+ <dl>
+ <dt>Data de criação</dt>
+ <dd>
+ <time
+ itemprop="dateCreated"
+ datetime={toIso8601Full(post.data.dateCreated)}
+ >{
+ new Intl.DateTimeFormat([lang], {}).format(
+ post.data.dateCreated,
+ )
+ }</time>
+ </dd>
+ {
+ post.data.dateUpdated && (
+ <dt>Última atualização</dt><dd>
+ <time
+ itemprop="dateUpdated"
+ datetime={toIso8601Full(post.data.dateUpdated)}
+ >{
+ new Intl.DateTimeFormat([lang], {}).format(
+ post.data.dateUpdated,
+ )
+ }</time>
+ </dd>
+ )
+ }
+ {
+ post.data.locationCreated && (
+ <dt
+ itemprop="locationCreated"
+ itemscope
+ itemtype="https://schema.org/Place"
+ >
+ Local de criação
+ </dt><dd>
+ <span itemprop="name">{post.data.locationCreated}</span>
+ </dd>
+ )
+ }
+ </dl>
+ <ReadingTime body={post.body} {lang} />
+ </footer>
+ <hr />
+ <div itemprop="articleBody text"><Content /></div>
+ <hr />
+ <Keywords keywords={post.data.keywords} />
+ <Citations citations={post.data.relatedPosts} />
+ <CopyrightNotice
+ author={signers[0]?.entity.data.website?.[0] ?? "Anonymous"}
+ website={signers[0]?.entity.data.website?.[0]}
+ email={signers[0]?.entity.data.website?.[0]}
+ title={post.data.title}
+ dateCreated={post.data.dateCreated}
+ license={post.data.license as License}
+ />
+ </article>
+ </main>
+ </body>
+</html>
+
+<script type="module" is:inline>
+ hashchange();
+
+ window.addEventListener("hashchange", hashchange);
+
+ document.addEventListener(
+ "click",
+ function (event) {
+ if (
+ event.target &&
+ event.target instanceof HTMLAnchorElement &&
+ event.target.href === location.href &&
+ location.hash.length > 1
+ ) {
+ requestIdleCallback(function () {
+ if (!event.defaultPrevented) {
+ hashchange();
+ }
+ });
+ }
+ },
+ false,
+ );
+
+ function hashchange() {
+ let hash;
+
+ try {
+ hash = decodeURIComponent(location.hash.slice(1)).toLowerCase();
+ } catch (e) {
+ return;
+ }
+
+ const name = "user-content-" + hash;
+ const target = document.getElementById(name) ||
+ document.getElementsByName(name)[0];
+
+ if (target) {
+ requestIdleCallback(function () {
+ target.scrollIntoView();
+ });
+ }
+ }
+</script>
+
+<style is:inline>
+ section[data-footnotes].footnotes {
+ word-wrap: break-word;
+ }
+</style>
+
+<style>
+ hgroup {
+ text-align: center;
+ }
+
+ .subtitle {
+ font-weight: lighter;
+ }
+
+ [itemprop~="articleBody"] {
+ line-height: 1.4;
+ font-size: 1.2em;
+ text-align: justify;
+
+ & h1,
+ & h2,
+ & h3 {
+ line-height: 1.2;
+ }
+ }
+
+ [itemprop="abstract"] {
+ margin-inline: 1em;
+ padding-block: 1em;
+ font-style: italic;
+ }
+
+ @media print {
+ body {
+ font-size: 1rem;
+ font-family: var(--ff-serif);
+ line-height: 1.62;
+ }
+ }
+</style>
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..e1e97ef
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,28 @@
+---
+import Base from "@layouts/Base.astro";
+import { SITE_TITLE } from "src/consts";
+---
+
+<Base title={SITE_TITLE}>
+ <main>
+ <article>
+ <h2>Viva abril!</h2>
+ <figure>
+ <blockquote lang="es-VE" translate="no">
+ &laquo;Los que le cierran el camino a la revoluci&oacute;n
+ pac&iacute;fica le abren al mismo tiempo el camino a la
+ revoluci&oacute;n violenta&raquo;.
+ </blockquote>
+ <figcaption>
+ &mdash; Hugo Ch&aacute;vez.
+ <p>
+ Tradu&ccedil;&atilde;o: &ldquo;Aqueles que fecham o caminho para a
+ revolu&ccedil;&atilde;o pac&iacute;fica abrem, ao mesmo tempo, o
+ caminho para a revolu&ccedil;&atilde;o violenta.&rdquo;
+ </p>
+ </figcaption>
+ </figure>
+ <p><em>Portugal <em>fez</em> diferente!</em></p>
+ </article>
+ </main>
+</Base>
diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts
new file mode 100644
index 0000000..4edef8b
--- /dev/null
+++ b/src/pages/robots.txt.ts
@@ -0,0 +1,13 @@
+import type { APIRoute } from "astro";
+
+const getRobotsTxt = (sitemapURL: URL) => `
+User-agent: *
+Allow: /
+
+Sitemap: ${sitemapURL.href}
+`;
+
+export const GET: APIRoute = ({ site }) => {
+ const sitemapURL = new URL("sitemap-index.xml", site);
+ return new Response(getRobotsTxt(sitemapURL));
+};
diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js
new file mode 100644
index 0000000..de5685b
--- /dev/null
+++ b/src/pages/rss.xml.js
@@ -0,0 +1,16 @@
+import rss from "@astrojs/rss";
+import { getCollection } from "astro:content";
+import { SITE_DESCRIPTION, SITE_TITLE } from "../consts";
+
+export async function GET(context) {
+ const posts = await getCollection("blog");
+ return rss({
+ title: SITE_TITLE,
+ description: SITE_DESCRIPTION,
+ site: context.site,
+ items: posts.map((post) => ({
+ ...post.data,
+ link: `/blog/${post.id}/`,
+ })),
+ });
+}
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..11d9e5b
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,105 @@
+:root {
+ --ff-serif: ui-serif, serif;
+ --ff-sans: ui-sans-serif, sans-serif;
+ --ff-mono: ui-monospace, monospace;
+ --ff-icons: "glyphicons", emoji;
+ --color-link: #1a9850;
+ --color-visited: #006837;
+ --color-active: #a50026;
+}
+
+body {
+ margin: 1rem auto;
+ max-width: 80ch;
+ font-family: var(--ff-sans);
+ padding: 0 0.62em 3.24em;
+}
+
+a:link {
+ color: var(--color-link);
+}
+
+a:visited {
+ color: var(--color-visited);
+}
+
+a:active {
+ color: var(--color-active);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-link: #a6d96a;
+ --color-visited: #d9ef8b;
+ --color-active: #f46d43;
+ }
+
+ body {
+ background: #000;
+ color: #fff;
+ }
+}
+
+body.theme-dark {
+ --color-link: #a6d96a;
+ --color-visited: #d9ef8b;
+ --color-active: #f46d43;
+ background: #000;
+ color: #fff;
+}
+
+@media print {
+ body {
+ max-width: none;
+ }
+}
+
+.emoji {
+ font-family: var(--ff-icons);
+}
+
+[title] {
+ border-bottom: thin dashed;
+}
+
+dt::after {
+ content: ":";
+}
+
+dl {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ grid-auto-rows: auto;
+ gap: 0.25rem 1rem;
+ align-items: start;
+}
+
+dl dt,
+dl dd {
+ margin: 0;
+ word-break: break-word;
+}
+
+dl dt {
+ grid-column: 1;
+}
+
+dl dd {
+ grid-column: 2;
+}
+
+dl.divider {
+ gap: 0;
+}
+dl.divider dl {
+ gap: 0;
+}
+dl.divider dt {
+ padding-inline-end: 1em;
+}
+dl.divider dt + dd:not(:first-of-type) {
+ border-block-start: 1px solid #181818;
+}
+dl.divide dd + dt {
+ border-block-start: 1px solid #181818;
+}
diff --git a/src/utils/anonymous.test.ts b/src/utils/anonymous.test.ts
new file mode 100644
index 0000000..2da613f
--- /dev/null
+++ b/src/utils/anonymous.test.ts
@@ -0,0 +1,130 @@
+import { assert, assertEquals, assertFalse } from "@std/assert";
+import { describe, it } from "@std/testing/bdd";
+import {
+ defined,
+ equal,
+ extremeBy,
+ get,
+ getCall,
+ identity,
+ instanciate,
+ pass,
+} from "./anonymous.ts";
+import { assertSpyCalls, spy } from "@std/testing/mock";
+import { FALSE, TRUE } from "../../tests/fixtures/test_data.ts";
+
+describe("identity", () => {
+ it("returns the same value", () => {
+ assertEquals(identity(42), 42);
+ assertEquals(identity("hello"), "hello");
+ const obj = { a: 1 };
+ assertEquals(identity(obj), obj);
+ });
+});
+
+describe("defined", () => {
+ it("returns true for non-null/undefined values", () => {
+ assert(defined(0));
+ assert(defined(""));
+ const FALSE = false;
+ assert(defined(FALSE));
+ });
+
+ it("returns false for null and undefined", () => {
+ assertFalse(defined(undefined));
+ assertFalse(defined(null));
+ });
+});
+
+describe("instanciate", () => {
+ class MyClass {
+ constructor(public value: number) {}
+ }
+
+ it("creates a new instance with the given argument", () => {
+ const create = instanciate(MyClass);
+ const instance = create(10);
+ assert(instance instanceof MyClass);
+ assertEquals(instance.value, 10);
+ });
+});
+
+describe("get", () => {
+ it("returns the value at the specified key", () => {
+ const obj = { a: 123, b: "hello" };
+ const getA = get("a");
+ const getB = get("b");
+
+ assertEquals(getA(obj), 123);
+ assertEquals(getB(obj), "hello");
+ });
+});
+
+describe("getCall", () => {
+ it("returns the return value at the specified key", () => {
+ const obj = { a: () => "a", b: (c: unknown) => c };
+ const getA = getCall("a");
+ const getB = getCall("b", "d");
+
+ assertEquals(getA(obj), "a");
+ assertEquals(getB(obj), "d");
+ });
+});
+
+describe("pass", () => {
+ it("calls the given function and returns the input", () => {
+ let a: number | null = null;
+ const f = spy((x: number) => a = x);
+
+ const result = pass(f)(5);
+ assertSpyCalls(f, 1);
+ assertEquals(f.calls[0].args[0], 5);
+ assertEquals(result, 5);
+ assertEquals(a, 5);
+ });
+});
+
+describe("equal", () => {
+ it("returns true when primitive values are strictly equal", () => {
+ const isFive = equal(5);
+ assert(isFive(5));
+ assertFalse(isFive(6));
+
+ const isHello = equal("hello");
+ assert(isHello("hello"));
+ assertFalse(isHello("world"));
+ });
+
+ it("returns true only for same object reference", () => {
+ const obj = { a: 1 };
+ const isObj = equal(obj);
+ assert(isObj(obj));
+ assertFalse(isObj({ a: 1 }));
+ });
+
+ it("handles boolean values correctly", () => {
+ const isTrue = equal(TRUE);
+ assert(isTrue(TRUE));
+ assertFalse(isTrue(FALSE));
+ });
+});
+
+describe("extremeBy", () => {
+ it("returns the maximum value from projected numbers", () => {
+ const data = [1, 3, 2];
+ const result = extremeBy(data, "max");
+ assertEquals(result, 3);
+ });
+
+ it("returns the minimum value from projected numbers", () => {
+ const data = [10, 4, 7];
+ const result = extremeBy(data, "min");
+ assertEquals(result, 4);
+ });
+
+ it("returns -Infinity/Infinity for empty array", () => {
+ const data: number[] = [];
+ assertEquals(extremeBy(data, "max"), -Infinity);
+ assertEquals(extremeBy(data, "min"), Infinity);
+ });
+});
diff --git a/src/utils/anonymous.ts b/src/utils/anonymous.ts
new file mode 100644
index 0000000..ddd28bd
--- /dev/null
+++ b/src/utils/anonymous.ts
@@ -0,0 +1,25 @@
+export const identity = <T>(x: T): T => x;
+export const defined = <T>(x: T | undefined | null): x is T =>
+ x !== undefined && x !== null;
+export const instanciate = <T, A>(C: new (arg: A) => T): (arg: A) => T => {
+ return (arg: A): T => new C(arg);
+};
+export const get = <T extends Record<K, unknown>, K extends PropertyKey>(
+ key: K,
+): (obj: T) => T[K] =>
+(obj: T): T[K] => obj[key];
+export const getCall = <
+ T extends Record<K, (...args: unknown[]) => unknown>,
+ K extends PropertyKey,
+>(
+ key: K,
+ ...args: Parameters<T[K]>
+): (obj: T) => ReturnType<T[K]> =>
+(obj: T): ReturnType<T[K]> => obj[key](...args) as ReturnType<T[K]>;
+export const pass = <T>(fn: (x: T) => void): (x: T) => T => (x: T): T => {
+ fn(x);
+ return x;
+};
+export const equal = <T>(x: T): (y: T) => boolean => (y: T): boolean => x === y;
+export const extremeBy = (arr: number[], mode: "max" | "min"): number =>
+ Math[mode](...arr);
diff --git a/src/utils/bases.test.ts b/src/utils/bases.test.ts
new file mode 100644
index 0000000..9341b18
--- /dev/null
+++ b/src/utils/bases.test.ts
@@ -0,0 +1,32 @@
+import { assertEquals, assertThrows } from "@std/assert";
+import { describe, it } from "@std/testing/bdd";
+import { bufferToBase } from "./bases.ts";
+
+describe("bufferToBase", () => {
+ it("returns an empty string for an empty Uint8Array", () => {
+ assertEquals(bufferToBase(new Uint8Array([]), 16), "");
+ });
+
+ it("converts bytes to hexadecimal (base 16)", () => {
+ const input = new Uint8Array([0, 1, 15, 16, 255]);
+ const expected = "00010f10ff";
+ assertEquals(bufferToBase(input, 16), expected);
+ });
+
+ it("converts bytes to binary (base 2)", () => {
+ const input = new Uint8Array([255, 0, 1]);
+ const expected = "111111110000000000000001";
+ assertEquals(bufferToBase(input, 2), expected);
+ });
+
+ it("converts bytes to octal (base 8)", () => {
+ const input = new Uint8Array([8, 64, 255]);
+ const expected = "010100377";
+ assertEquals(bufferToBase(input, 8), expected);
+ });
+
+ it("throws on invalid base", () => {
+ assertThrows(() => bufferToBase(new Uint8Array([1, 2]), 1), RangeError);
+ assertThrows(() => bufferToBase(new Uint8Array([1, 2]), 37), RangeError);
+ });
+});
diff --git a/src/utils/bases.ts b/src/utils/bases.ts
new file mode 100644
index 0000000..a610d13
--- /dev/null
+++ b/src/utils/bases.ts
@@ -0,0 +1,11 @@
+export const bufferToBase = (buf: Uint8Array, base = 10): string => {
+ if (base < 2 || base > 36) {
+ throw new RangeError("Base must be between 2 and 36.");
+ }
+
+ const max = Math.ceil(8 / Math.log2(base)); // Math.log2(1 << 8) = 8
+
+ return Array.from(buf, (byte) => byte.toString(base).padStart(max, "0")).join(
+ "",
+ );
+};
diff --git a/src/utils/datetime.test.ts b/src/utils/datetime.test.ts
new file mode 100644
index 0000000..dd239b2
--- /dev/null
+++ b/src/utils/datetime.test.ts
@@ -0,0 +1,63 @@
+import { assertEquals, assertMatch } from "@std/assert";
+import { describe, it } from "@std/testing/bdd";
+import { toIso8601Full, toIso8601FullUTC } from "./datetime.ts";
+import { FakeTime } from "@std/testing/time";
+
+describe("toIso8601Full", () => {
+ it("formats current local time with offset", () => {
+ const date = new Date();
+ const result = toIso8601Full(date);
+
+ assertMatch(
+ result,
+ /^[+-]\d{6}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/,
+ );
+ });
+
+ it("handles dates before year 0 (BC)", () => {
+ const date = new Date(-2000, 0, 1, 0, 0, 0, 0);
+ const result = toIso8601Full(date);
+
+ assertMatch(result, /^-\d{6}-01-01T00:00:00\.000(Z|[+-]\d{2}:\d{2})$/);
+ });
+
+ it("pads components correctly", () => {
+ const date = new Date(7, 0, 2, 3, 4, 5, 6);
+ const result = toIso8601Full(date);
+
+ assertMatch(result, /^\+001907-01-02T03:04:05\.006(Z|[+-]\d{2}:\d{2})$/);
+ });
+
+ it("handles positive and negative timezone offsets", () => {
+ const date = new Date("2025-06-17T12:00:00Z");
+ const result = toIso8601Full(date);
+
+ assertMatch(
+ result,
+ /^[+-]\d{6}-06-17T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/,
+ );
+ });
+});
+
+describe("toIso8601FullUTC", () => {
+ it("always formats in UTC with 'Z'", () => {
+ const date = new Date(Date.UTC(2025, 11, 31, 23, 59, 59, 999));
+ const result = toIso8601FullUTC(date);
+
+ assertEquals(result, "+002025-12-31T23:59:59.999Z");
+ });
+
+ it("pads milliseconds and components correctly", () => {
+ const date = new Date(Date.UTC(7, 0, 2, 3, 4, 5, 6));
+ const result = toIso8601FullUTC(date);
+
+ assertEquals(result, "+001907-01-02T03:04:05.006Z");
+ });
+
+ it("handles BC dates (negative years)", () => {
+ const date = new Date(Date.UTC(-44, 2, 15, 12, 0, 0, 0));
+ const result = toIso8601FullUTC(date);
+
+ assertMatch(result, /^-\d{6}-03-15T12:00:00\.000Z$/);
+ });
+});
diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts
new file mode 100644
index 0000000..3a2cd25
--- /dev/null
+++ b/src/utils/datetime.ts
@@ -0,0 +1,43 @@
+export function toIso8601Full(date: Date): string {
+ const yearN = date.getFullYear();
+ const isNegativeYear = yearN <= 0;
+ const year = isNegativeYear ? pad(1 - yearN, 6) : pad(yearN, 6);
+ const signedYear = (isNegativeYear ? "-" : "+") + year;
+
+ const month = pad(date.getMonth() + 1);
+ const day = pad(date.getDate());
+ const hour = pad(date.getHours());
+ const minute = pad(date.getMinutes());
+ const second = pad(date.getSeconds());
+ const ms = pad(date.getMilliseconds(), 3);
+
+ const dateString =
+ `${signedYear}-${month}-${day}T${hour}:${minute}:${second}.${ms}`;
+ const tzOffset = -date.getTimezoneOffset();
+ if (tzOffset === 0) {
+ return `${dateString}Z`;
+ } else {
+ const offsetSign = tzOffset > 0 ? "+" : "-";
+ const offsetHours = pad(Math.floor(Math.abs(tzOffset) / 60));
+ const offsetMinutes = pad(Math.abs(tzOffset) % 60);
+ return `${dateString}${offsetSign}${offsetHours}:${offsetMinutes}`;
+ }
+}
+
+export function toIso8601FullUTC(date: Date): string {
+ const yearN = date.getUTCFullYear();
+ const isNegativeYear = yearN <= 0;
+ const year = isNegativeYear ? pad(1 - yearN, 6) : pad(yearN, 6);
+ const signedYear = (isNegativeYear ? "-" : "+") + year;
+
+ const month = pad(date.getUTCMonth() + 1);
+ const day = pad(date.getUTCDate());
+ const hour = pad(date.getUTCHours());
+ const minute = pad(date.getUTCMinutes());
+ const second = pad(date.getUTCSeconds());
+ const ms = pad(date.getUTCMilliseconds(), 3);
+
+ return `${signedYear}-${month}-${day}T${hour}:${minute}:${second}.${ms}Z`;
+}
+
+const pad = (num: number, len = 2) => String(Math.abs(num)).padStart(len, "0");
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 0000000..5a083d5
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,19 @@
+import { trailingSlash } from "astro:config/client";
+
+export function addForwardSlash(path: string): string {
+ if (trailingSlash === "always") {
+ return path.endsWith("/") ? path : path + "/";
+ } else {
+ return path;
+ }
+}
+
+export const enum Level {
+ OK,
+ INFO,
+ WARN,
+ DEBUG,
+ ERROR,
+}
+
+export type MaybePromise<T> = Promise<T> | T;
diff --git a/src/utils/iterator.test.ts b/src/utils/iterator.test.ts
new file mode 100644
index 0000000..dda0e0a
--- /dev/null
+++ b/src/utils/iterator.test.ts
@@ -0,0 +1,122 @@
+import { describe, it } from "@std/testing/bdd";
+import {
+ createAsyncIterator,
+ filterDuplicate,
+ findMapAsync,
+ surelyIterable,
+} from "./iterator.ts";
+import { assertEquals } from "@std/assert";
+
+describe("surelyIterable", () => {
+ it("returns the iterable as-is if input is already iterable", () => {
+ const input = [1, 2, 3];
+ const result = surelyIterable(input);
+ assertEquals([...result], [1, 2, 3]);
+ });
+
+ it("wraps a non-iterable value in an array", () => {
+ const input = 42;
+ const result = surelyIterable(input);
+ assertEquals([...result], [42]);
+ });
+
+ it("wraps null in an array", () => {
+ const input = null;
+ const result = surelyIterable(input);
+ assertEquals([...result], [null]);
+ });
+
+ it("wraps undefined in an array", () => {
+ const input = undefined;
+ const result = surelyIterable(input);
+ assertEquals([...result], [undefined]);
+ });
+
+ it("wraps an object that is not iterable", () => {
+ const input = { a: 1 };
+ const result = surelyIterable(input);
+ assertEquals([...result], [{ a: 1 }]);
+ });
+
+ it("handles a Set correctly", () => {
+ const input = new Set([1, 2, 3]);
+ const result = surelyIterable(input);
+ assertEquals([...result], [1, 2, 3]);
+ });
+});
+
+describe("createAsyncIterator", () => {
+ it("yields resolved values in order", async () => {
+ const values = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)];
+ const results: number[] = [];
+ for await (const value of createAsyncIterator(values)) {
+ results.push(value);
+ }
+ assertEquals(results, [1, 2, 3]);
+ });
+
+ it("handles empty array", async () => {
+ const results: unknown[] = [];
+ for await (const value of createAsyncIterator([])) {
+ results.push(value);
+ }
+ assertEquals(results, []);
+ });
+});
+
+describe("filterDuplicate", () => {
+ it("filters duplicate objects by key", () => {
+ const items = [
+ { id: 1, name: "a" },
+ { id: 2, name: "b" },
+ { id: 1, name: "c" },
+ ];
+ const result = filterDuplicate(items, (i) => i.id);
+ assertEquals(result.length, 2);
+ assertEquals(result[0].name, "a");
+ assertEquals(result[1].name, "b");
+ });
+
+ it("handles empty iterable", () => {
+ const result = filterDuplicate([], (x) => x);
+ assertEquals(result, []);
+ });
+
+ it("keeps first occurrence only", () => {
+ const input = [1, 2, 3, 1, 2, 4];
+ const result = filterDuplicate(input, (x) => x);
+ assertEquals(result, [1, 2, 3, 4]);
+ });
+});
+
+describe("findMapAsync", () => {
+ it("returns first successful result", async () => {
+ const arr = [1, 2, 3];
+ const i = 2;
+ const result = await findMapAsync(arr, (x) => {
+ if (x === i) return Promise.resolve(x);
+ throw new Error("not found");
+ });
+ assertEquals(result, i);
+ });
+
+ it("returns undefined if all reject", async () => {
+ const arr = [1, 2];
+ const result = await findMapAsync(arr, () => {
+ throw new Error("fail");
+ });
+ assertEquals(result, undefined);
+ });
+
+ it("short-circuits after first success", async () => {
+ const calls: number[] = [];
+ const arr = [1, 2, 3];
+ const i = arr.length - 1;
+ await findMapAsync(arr, (x) => {
+ calls.push(x);
+ if (x === i) return Promise.resolve("ok");
+ throw new Error("fail");
+ });
+ assertEquals(calls, arr.slice(0, i));
+ });
+});
diff --git a/src/utils/iterator.ts b/src/utils/iterator.ts
new file mode 100644
index 0000000..fa58fc9
--- /dev/null
+++ b/src/utils/iterator.ts
@@ -0,0 +1,52 @@
+export type MaybeIterable<T> = T | Iterable<T>;
+export type NonEmptyArray<T> = [T, ...T[]];
+export type AsyncYieldType<T> = T extends AsyncGenerator<infer U> ? U : never;
+
+export function surelyIterable<T>(maybe: MaybeIterable<T>): Iterable<T> {
+ return typeof maybe === "object" && maybe !== null && Symbol.iterator in maybe
+ ? maybe
+ : [maybe];
+}
+
+export async function* createAsyncIterator<T>(
+ promises: Promise<T>[],
+): AsyncGenerator<T, void, void> {
+ for (const promise of promises) {
+ yield promise;
+ }
+}
+
+export function filterDuplicate<T, K>(
+ array: Iterable<T>,
+ key: (i: T) => K,
+): T[] {
+ const seen = new Map<K, T>();
+ for (const i of array) {
+ const id = key(i);
+ if (!seen.has(id)) {
+ seen.set(id, i);
+ }
+ }
+ return Array.from(seen.values());
+}
+
+export async function findMapAsync<T, R>(
+ iter: Iterable<T>,
+ predicate: (value: T) => Promise<R>,
+): Promise<R | undefined> {
+ const arr = Array.from(iter);
+
+ async function tryNext(index: number): Promise<R | undefined> {
+ if (index >= arr.length) {
+ return await Promise.resolve(undefined);
+ }
+
+ try {
+ return await predicate(arr[index]);
+ } catch {
+ return tryNext(index + 1);
+ }
+ }
+
+ return await tryNext(0);
+}
diff --git a/src/utils/lang.test.ts b/src/utils/lang.test.ts
new file mode 100644
index 0000000..eac5948
--- /dev/null
+++ b/src/utils/lang.test.ts
@@ -0,0 +1,97 @@
+import { assert, assertEquals, assertFalse } from "@std/assert";
+import { describe, it } from "@std/testing/bdd";
+import {
+ getFlagEmojiFromLocale,
+ getLanguageNameFromLocale,
+ isValidLocale,
+ LANGUAGE_DEFAULTS,
+} from "./lang.ts";
+
+describe("getFlagEmojiFromLocale", () => {
+ it("returns 🇺🇸 for 'en-US'", () => {
+ assertEquals(getFlagEmojiFromLocale("en-US"), "🇺🇸");
+ });
+
+ it("returns 🇧🇷 for 'pt-BR'", () => {
+ assertEquals(getFlagEmojiFromLocale("pt-BR"), "🇧🇷");
+ });
+
+ it("returns 🇫🇷 for 'fr-FR'", () => {
+ assertEquals(getFlagEmojiFromLocale("fr-FR"), "🇫🇷");
+ });
+
+ it("uses fallback country from LANGUAGE_DEFAULTS when no region", () => {
+ for (const i in LANGUAGE_DEFAULTS) {
+ if (i in LANGUAGE_DEFAULTS) {
+ assertEquals(
+ getFlagEmojiFromLocale(i),
+ getFlagEmojiFromLocale(
+ `${i}-${LANGUAGE_DEFAULTS[i as keyof typeof LANGUAGE_DEFAULTS]}`,
+ ),
+ );
+ }
+ }
+ });
+
+ it("returns empty string for unsupported languages", () => {
+ assertEquals(getFlagEmojiFromLocale("xx"), "");
+ assertEquals(getFlagEmojiFromLocale("de"), "");
+ });
+
+ it("is case-insensitive", () => {
+ assertEquals(getFlagEmojiFromLocale("EN-us"), "🇺🇸");
+ assertEquals(getFlagEmojiFromLocale("Pt"), "🇵🇹");
+ });
+});
+
+describe("getLanguageNameFromLocale", () => {
+ it("returns 'English' for 'en'", () => {
+ const result = getLanguageNameFromLocale("en");
+ assertEquals(typeof result, "string");
+ assert(result.length > 0);
+ });
+
+ it("returns '' for invalid locale", () => {
+ assertEquals(getLanguageNameFromLocale(new Date().toLocaleString()), "");
+ });
+
+ it("returns name in the correct locale", () => {
+ const fr = getLanguageNameFromLocale("fr");
+ const pt = getLanguageNameFromLocale("pt");
+
+ assertEquals(typeof fr, "string");
+ assertEquals(typeof pt, "string");
+ assert(fr.length > 0);
+ assert(pt.length > 0);
+ });
+});
+
+describe("isValidLocale", () => {
+ it("returns true for valid simple language tags", () => {
+ assert(isValidLocale("en"));
+ assert(isValidLocale("fr"));
+ assert(isValidLocale("pt"));
+ });
+
+ it("returns true for valid language-region tags", () => {
+ assert(isValidLocale("en-US"));
+ assert(isValidLocale("pt-BR"));
+ assert(isValidLocale("fr-FR"));
+ });
+
+ it("returns true for valid locale with script", () => {
+ assert(isValidLocale("zh-Hant"));
+ assert(isValidLocale("sr-Cyrl"));
+ });
+
+ it("returns false for invalid formats", () => {
+ assertFalse(isValidLocale("EN_us"));
+ assertFalse(isValidLocale("xx-YY-ZZ"));
+ assertFalse(isValidLocale("123"));
+ assertFalse(isValidLocale(""));
+ });
+
+ it("is case-insensitive and accepts well-formed mixed cases", () => {
+ assert(isValidLocale("eN-uS"));
+ });
+});
diff --git a/src/utils/lang.ts b/src/utils/lang.ts
new file mode 100644
index 0000000..2ce8fe4
--- /dev/null
+++ b/src/utils/lang.ts
@@ -0,0 +1,56 @@
+export const LANGUAGE_DEFAULTS = Object.freeze({
+ pt: "PT",
+ en: "GB",
+ fr: "FR",
+});
+
+/**
+ * AI thought me this.
+ *
+ * Explanation:
+ * * Each letter in a 2-letter country code is converted to a Regional
+ * Indicator Symbol, which together form the emoji flag.
+ * * 'A'.charCodeAt(0) is 65, and '🇦' starts at 0x1F1E6 → offset of 127397
+ * (0x1F1A5).
+ * * So 'A' → '🇦', 'B' → '🇧', etc.
+ *
+ * The flags are the combination of those emojis making the country code like
+ * Portugal -> PT.
+ */
+export function getFlagEmojiFromLocale(locale: string): string {
+ let countryCode: string | undefined;
+
+ const parts = locale.split("-");
+ const lang = parts[0].toLowerCase();
+ if (parts.length === 2) {
+ countryCode = parts[1].toUpperCase();
+ } else if (lang in LANGUAGE_DEFAULTS) {
+ countryCode = LANGUAGE_DEFAULTS[lang as keyof typeof LANGUAGE_DEFAULTS];
+ }
+
+ if (!countryCode) return "";
+
+ return [...countryCode]
+ .map((c) => String.fromCodePoint(c.charCodeAt(0) + 127397))
+ .join("");
+}
+
+export function getLanguageNameFromLocale(locale: string): string {
+ try {
+ return new Intl.DisplayNames([locale], {
+ type: "language",
+ fallback: "code",
+ }).of(locale) ?? "";
+ } catch {
+ return "";
+ }
+}
+
+export function isValidLocale(locale: string): boolean {
+ try {
+ Intl.getCanonicalLocales(locale);
+ return true;
+ } catch {
+ return false;
+ }
+}