From f9a77c5c27aede4e5978eb55d9b7af781b680a1d Mon Sep 17 00:00:00 2001 From: João Augusto Costa Branco Marado Torres Date: Tue, 24 Jun 2025 12:08:41 -0300 Subject: feat!: initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: João Augusto Costa Branco Marado Torres --- src/components/BaseHead.astro | 79 ++++ src/components/BlogCard.astro | 38 ++ src/components/Citations.astro | 39 ++ src/components/Commit.astro | 49 +++ src/components/CopyrightNotice.astro | 66 ++++ src/components/DateSelector.astro | 141 +++++++ src/components/Footer.astro | 62 ++++ src/components/Header.astro | 41 ++ src/components/HeaderLink.astro | 18 + src/components/Keywords.astro | 52 +++ src/components/ReadingTime.astro | 26 ++ src/components/SignaturesTableRows.astro | 51 +++ src/components/Translations.astro | 107 ++++++ src/components/licenses/CC.astro | 120 ++++++ src/components/licenses/WTFPL.astro | 53 +++ src/components/signature/Authors.astro | 281 ++++++++++++++ src/components/signature/Commit.astro | 87 +++++ src/components/signature/Downloads.astro | 63 ++++ src/components/signature/Signature.astro | 44 +++ src/components/signature/Summary.astro | 279 ++++++++++++++ src/consts.ts | 29 ++ src/content.config.ts | 116 ++++++ src/content/entities.toml | 85 +++++ src/layouts/Base.astro | 35 ++ src/lib/git/index.test.ts | 40 ++ src/lib/git/index.ts | 16 + src/lib/git/log.test.ts | 71 ++++ src/lib/git/log.ts | 131 +++++++ src/lib/git/types.ts | 27 ++ src/lib/pgp/create.test.ts | 130 +++++++ src/lib/pgp/create.ts | 183 +++++++++ src/lib/pgp/index.ts | 63 ++++ src/lib/pgp/sign.test.ts | 121 ++++++ src/lib/pgp/sign.ts | 82 ++++ src/lib/pgp/summary.ts | 232 ++++++++++++ src/lib/pgp/trust.ts | 19 + src/lib/pgp/verify.test.ts | 619 +++++++++++++++++++++++++++++++ src/lib/pgp/verify.ts | 349 +++++++++++++++++ src/pages/blog/[...year].astro | 165 ++++++++ src/pages/blog/keywords/[...slug].astro | 40 ++ src/pages/blog/keywords/index.astro | 21 ++ src/pages/blog/read/[...slug].astro | 333 +++++++++++++++++ src/pages/index.astro | 28 ++ src/pages/robots.txt.ts | 13 + src/pages/rss.xml.js | 16 + src/styles/global.css | 105 ++++++ src/utils/anonymous.test.ts | 130 +++++++ src/utils/anonymous.ts | 25 ++ src/utils/bases.test.ts | 32 ++ src/utils/bases.ts | 11 + src/utils/datetime.test.ts | 63 ++++ src/utils/datetime.ts | 43 +++ src/utils/index.ts | 19 + src/utils/iterator.test.ts | 122 ++++++ src/utils/iterator.ts | 52 +++ src/utils/lang.test.ts | 97 +++++ src/utils/lang.ts | 56 +++ 57 files changed, 5415 insertions(+) create mode 100644 src/components/BaseHead.astro create mode 100644 src/components/BlogCard.astro create mode 100644 src/components/Citations.astro create mode 100644 src/components/Commit.astro create mode 100644 src/components/CopyrightNotice.astro create mode 100644 src/components/DateSelector.astro create mode 100644 src/components/Footer.astro create mode 100644 src/components/Header.astro create mode 100644 src/components/HeaderLink.astro create mode 100644 src/components/Keywords.astro create mode 100644 src/components/ReadingTime.astro create mode 100644 src/components/SignaturesTableRows.astro create mode 100644 src/components/Translations.astro create mode 100644 src/components/licenses/CC.astro create mode 100644 src/components/licenses/WTFPL.astro create mode 100644 src/components/signature/Authors.astro create mode 100644 src/components/signature/Commit.astro create mode 100644 src/components/signature/Downloads.astro create mode 100644 src/components/signature/Signature.astro create mode 100644 src/components/signature/Summary.astro create mode 100644 src/consts.ts create mode 100644 src/content.config.ts create mode 100644 src/content/entities.toml create mode 100644 src/layouts/Base.astro create mode 100644 src/lib/git/index.test.ts create mode 100644 src/lib/git/index.ts create mode 100644 src/lib/git/log.test.ts create mode 100644 src/lib/git/log.ts create mode 100644 src/lib/git/types.ts create mode 100644 src/lib/pgp/create.test.ts create mode 100644 src/lib/pgp/create.ts create mode 100644 src/lib/pgp/index.ts create mode 100644 src/lib/pgp/sign.test.ts create mode 100644 src/lib/pgp/sign.ts create mode 100644 src/lib/pgp/summary.ts create mode 100644 src/lib/pgp/trust.ts create mode 100644 src/lib/pgp/verify.test.ts create mode 100644 src/lib/pgp/verify.ts create mode 100644 src/pages/blog/[...year].astro create mode 100644 src/pages/blog/keywords/[...slug].astro create mode 100644 src/pages/blog/keywords/index.astro create mode 100644 src/pages/blog/read/[...slug].astro create mode 100644 src/pages/index.astro create mode 100644 src/pages/robots.txt.ts create mode 100644 src/pages/rss.xml.js create mode 100644 src/styles/global.css create mode 100644 src/utils/anonymous.test.ts create mode 100644 src/utils/anonymous.ts create mode 100644 src/utils/bases.test.ts create mode 100644 src/utils/bases.ts create mode 100644 src/utils/datetime.test.ts create mode 100644 src/utils/datetime.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/iterator.test.ts create mode 100644 src/utils/iterator.ts create mode 100644 src/utils/lang.test.ts create mode 100644 src/utils/lang.ts (limited to 'src') 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 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' +--- + + + + + + + + + + + + + + +{title} + + + +{keywords.length > 0 && } + + + + + + + + +{image && } + + + + + + +{image && } + + + + 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}`; +--- + +
+

+ {title} +

+

{description}

+
+ +
+
+ + 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 && + ( + + ) +} + + 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; +--- +

Git commit info:

+
+
Hash
+
{hash}
+
Files
+ {files.map((file) =>
{file.pathname.replace(git, "")}
)} +
Author
+
{author.name} <{author.email}>
+ { + signature && ( +
Commit Signature
+
+
+
Type
+
{signature.type}
+
Signer
+
{signature.signerName}
+
Key fingerprint
+
{signature.keyFingerPrint}
+
+
+ ) + } +
+ + 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 &&
} + +{ + /* +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"); +--- + + + 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 @@ +--- + +--- + + 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"; +--- + +
+

<cravodeabril.pt>

+ +
+

+ +

+

+

+ Esta pesquisa é efectuada pelo Google e utiliza software + proprietário. +

+
+
+ +
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; +--- + + + + + 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; +--- + + + 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" : ""}`; +--- +

+ Tempo de leitura + estimado: ~ {estimative} + (palavras: {reading.words}) +

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]}` : ""; +--- +{firstName}{lastName} +{email} + + + {`0x${fingerprint.slice(-8)}`} + + +{trust} +{commiter} +{revoked} 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 and ? */ +} + +{ + translations.length > 0 && ( + + ) +} + + 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/`; +--- + +
+ { + publicdomain ? ( +

+ + {title} by { + website ? ( + + ) : author + } is marked CC0 1.0 + + + +

+ ) : ( +

+ + {title} © { + dateCreated.getFullYear() + } by { + website ? ( + + ) : author + } is licensed under {license.replace("CC-", "CC ")} 4.0 + + + { + nc && ( + + ) + } + { + sa && ( + <>{" "} + ) + } + { + nd && ( + <>{" "} + ) + } + +

+ ) + } +
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; +--- + +
+

+ + Copyright © { + dateCreated.getFullYear() + } + { + website ? ( + + ) : author + } + { + email && ( + <><{email}> + ) + } + +

+

+ + 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 http://www.wtfpl.net/ for more details. + +

+
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; + expectedSigners: { + entity: CollectionEntry<"entity">; + role: z.infer; + }[]; + commitSignerKey?: string; +} + +const { + verifications: verificationsPromise, + expectedSigners, + commitSignerKey, +} = Astro.props; + +const fingerprintToData = new Map< + string, + { websites: URL[]; role: z.infer } +>(); + +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(), + ]}`, + ); +} +--- + +
+ + + + + + + + + + + + + + + + + + + + + + { + 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 ( + + + + + + + + ); + }) + } + +
+ Assinaturas +

+ Para verificar uma assinatura é necessário a mensagem, a assinatura digital e as chaves públicas dos assinantes. Esta tabela mostra algumas + informações sobre os assinantes e as suas chaves públicas. +

+
AssinanteFunçãoFingerprintVálidoCommiter
+ + {role} + <>{ + key + ? "0x" + toPK(key).getKeyID().toHex() + : "0x" + keyID.toHex() + } + { + key && false && ( + + ) + } + { + key && + ( + +
+
{toPK(key).armor()}
+
+ ) + } + +
{verified ? "✅" : "❌"} + { + commitSignerKey && + key?.getFingerprint().toUpperCase()?.endsWith( + commitSignerKey.toUpperCase(), + ) && "✅" + } +
+
+ + 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", +}); +--- + +
+
+ + Informações sobre o último commit que modificou ficheiros relacionados a + este blog post: + +
+
Hash
+
0x{hash.short.toUpperCase()}
+
Ficheiros modificados
+ { + files.length > 0 + ? files.map((file) => ( +
{file.path.pathname.replace(dir.pathname, "")}
+ )) + :
Nenhum ficheiro modificado
+ } +
+ Autor () +
+
+ {author.name} <{ + author.email + }> +
+
+ Commiter () +
+
+ {committer.name} <{ + committer.email + }> +
+ { + signature && + ( +
Assinatura do commit
+
+
+
Tipo
+
{signature.type}
+
Assinante
+
{signature.signer}
+
Fingerprint da chave
+
0x{signature.key.short}
+
+
+ ) + } +
+
+
+ + 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); +--- + +
+

Ficheiros para descarregar:

+
+
Blog post
+
+ text/markdown, {sourceSize} +
+ { + sig && ( +
Assinatura digital
+
+ application/pgp-signature, {sigSize} +
+ ) + } +
+
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; +--- + + + + + 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; +} + +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 = `

+Este blog post não foi assinado. +

+

+Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito. +

+`; + break; + } + case VerificationResult.MISSING_KEY: { + title = "Chave não encontrada"; + content = `

+Este blog post está assinado digitalmente, porém a chave pública com KeyID 0x${worst.keyID} com que foi assinado não foi encontrada no chaveiro sendo impossível verificar a assinatura, quer dizer, não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito. +

+

+Procure a chave noutro sítio da internet para conseguir fazer a verificação manualmente. +

+`; + break; + } + case VerificationResult.SIGNATURE_CORRUPTED: { + title = "Assinatura corrumpida"; + content = `

+Exite um ficheiro que supostamente é a assinatura, mas ele está corrompido ou com um formato inválido. +

+

+Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito. +

+`; + break; + } + case VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED: { + title = "Erro desconhecido"; + content = `

+A assinatura foi encontrada mas ocorreu um erro inesperado durante a verificação. +

+

+Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito. +

+`; + break; + } + case VerificationResult.BAD_SIGNATURE: { + title = "Assinatura inválida"; + content = `

+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. +

+

+Pode tentar verificar a assinatura com versões antigas do blog post, mas esta versão não pode ser verificada quanto à autentacidade do autor ou à integridade do texto escrito. +

+`; + break; + } + case VerificationResult.UNTRUSTED_KEY: { + title = "Assinatura válida (chave não confiada)"; + content = `

+A assinatura digital é criptograficamente válida, porém a chave utilizada não é suficientemente confiada pelo servidor. Mas podes ter a certeza que o dono da chave pública é a mesma pessoa que assinou este blog post. +

+`; + break; + } + case VerificationResult.TRUSTED_KEY: { + title = "Assinatura válida"; + content = `

+A assinatura digital é criptograficamente válida. O dono da chave pública é a mesma pessoa que assinou este blog post exatamente como ele está, sem alterações. +

+`; + 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 && + ( +
+ {label?.toUpperCase()}: {title.toUpperCase()} + + {error &&
{error}
} +
+ ) +} + + 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; + +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; + +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 +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 +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"; +--- + + + + + + + + +
+ +