diff options
Diffstat (limited to 'src')
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} <{author.email}></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> + <<a href="/" hreflang="pt-PT">cravodeabril.pt</a>> Copyright + © 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><<a href="/">cravodeabril.pt</a>></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 && ( + <><<a + itemprop="email" + rel="author external noreferrer" + target="_blank" + href={`mailto:${email}`} + >{email}</a>></> + ) + }</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 + ? ( + <><<a + itemprop="email" + rel="author external noreferrer" + target="_blank" + href={primary?.email && `mailto:${primary.email}`} + >{primary?.email}</a>></> + ) + : "" + } + { + 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} <<a href={`mailto:${author.email}`}>{ + author.email + }</a>> + </dd> + <dt> + Commiter (<time datetime={toIso8601Full(committer.date)}>{ + formatter.format(committer.date) + }</time>) + </dt> + <dd> + {committer.name} <<a href={`mailto:${committer.email}`}>{ + committer.email + }</a>> + </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"> + «Los que le cierran el camino a la revolución + pacífica le abren al mismo tiempo el camino a la + revolución violenta». + </blockquote> + <figcaption> + — Hugo Chávez. + <p> + Tradução: “Aqueles que fecham o caminho para a + revolução pacífica abrem, ao mesmo tempo, o + caminho para a revolução violenta.” + </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; + } +} |