diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/BaseHead.astro | 79 | ||||
-rw-r--r-- | src/components/BlogCard.astro | 38 | ||||
-rw-r--r-- | src/components/Citations.astro | 39 | ||||
-rw-r--r-- | src/components/Commit.astro | 49 | ||||
-rw-r--r-- | src/components/CopyrightNotice.astro | 66 | ||||
-rw-r--r-- | src/components/DateSelector.astro | 141 | ||||
-rw-r--r-- | src/components/Footer.astro | 62 | ||||
-rw-r--r-- | src/components/Header.astro | 41 | ||||
-rw-r--r-- | src/components/HeaderLink.astro | 18 | ||||
-rw-r--r-- | src/components/Keywords.astro | 52 | ||||
-rw-r--r-- | src/components/ReadingTime.astro | 26 | ||||
-rw-r--r-- | src/components/SignaturesTableRows.astro | 51 | ||||
-rw-r--r-- | src/components/Translations.astro | 107 | ||||
-rw-r--r-- | src/components/licenses/CC.astro | 120 | ||||
-rw-r--r-- | src/components/licenses/WTFPL.astro | 53 | ||||
-rw-r--r-- | src/components/signature/Authors.astro | 281 | ||||
-rw-r--r-- | src/components/signature/Commit.astro | 87 | ||||
-rw-r--r-- | src/components/signature/Downloads.astro | 63 | ||||
-rw-r--r-- | src/components/signature/Signature.astro | 44 | ||||
-rw-r--r-- | src/components/signature/Summary.astro | 279 |
20 files changed, 1696 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> |