From 0af094770c4ebabc56ff761a8bd215bc397c0f7e Mon Sep 17 00:00:00 2001 From: João Augusto Costa Branco Marado Torres Date: Tue, 5 Aug 2025 18:50:37 +0100 Subject: refactor: reading page review 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/lib/collection/helpers.ts | 115 +++++++++++++++++++--- src/lib/collection/schemas.ts | 5 + src/lib/collection/types.ts | 54 +++++++++++ src/lib/git/log.ts | 162 +++++++++++++++++++------------ src/lib/pgp/summary.ts | 216 ++++++++++++++++++++++-------------------- src/lib/pgp/verify.ts | 38 +++++--- 6 files changed, 395 insertions(+), 195 deletions(-) create mode 100644 src/lib/collection/types.ts (limited to 'src/lib') diff --git a/src/lib/collection/helpers.ts b/src/lib/collection/helpers.ts index f65e7c0..4dfafff 100644 --- a/src/lib/collection/helpers.ts +++ b/src/lib/collection/helpers.ts @@ -2,18 +2,26 @@ import type { CollectionEntry } from "astro:content"; import { Blog, Entity, + ENTITY_TYPES, + type EntityTypesEnum, type Entry, + type LICENSES, type MicroEntry, + Original, type OriginalEntry, + Translation, type TranslationEntry, } from "./schemas.ts"; -import { getEntries, type z } from "astro:content"; -import { defined, get, identity } from "../../utils/anonymous.ts"; +import type { z } from "astro:content"; +import { defined, get, identity, transform } from "../../utils/anonymous.ts"; import { createKeyFromArmor } from "../pgp/create.ts"; import { getUserIDsFromKey } from "../pgp/user.ts"; import type { UserIDPacket } from "openpgp"; +import readingTime from "reading-time"; import { getCollection } from "astro:content"; import { getEntry } from "astro:content"; +import { getEntries } from "astro:content"; +import { listYearsWithRanges } from "../../utils/datetime.ts"; export function getLastUpdate({ data }: CollectionEntry<"blog">): Date { return data.dateUpdated ?? data.dateCreated; @@ -36,20 +44,28 @@ export const sortFirstUpdated = ( b: CollectionEntry<"blog">, ): number => sortLastUpdated(b, a); +export function getSignersIDs( + { data }: CollectionEntry<"blog">, +): Record, string[]> { + const { signers } = Blog.parse(data); + const id = ({ entity }: typeof signers[number]) => entity.id; + return Object.fromEntries( + ENTITY_TYPES.map((x) => [x, signers.filter((s) => s.role === x).map(id)]), + ) as ReturnType; +} + export async function getSigners( { data }: CollectionEntry<"blog">, ): Promise<{ - id: string; entity: CollectionEntry<"entity">; - role: z.infer["signers"][number]["role"] | undefined; + role?: z.infer["signers"][number]["role"]; }[]> { const post = Blog.parse(data); - return await getEntries(post.signers.map(get("entity"))).then((x) => - x.map((x) => ({ - id: x.id, - entity: x, - role: post.signers?.find((y) => y.entity.id === x.id)?.role, - })).filter(({ role }) => defined(role)) + return await Promise.all( + post.signers.map(async ({ entity, role }) => ({ + entity: await getEntry(entity), + role, + })), ); } @@ -76,13 +92,13 @@ export async function getFirstUserID( const signers = await getSigners(blog); const userIDs = await Promise.all( signers.filter(({ role }) => role === "author").map( - async ({ id, entity }) => { + async ({ entity }) => { const { publickey, websites } = Entity.parse(entity.data); const website = websites?.[0]; const key = await createKeyFromArmor(publickey.armor); const users = getUserIDsFromKey(undefined, key); return users.map((user) => { - return { ...user, entity: id, website }; + return { ...user, entity: entity.id, website }; })?.[0]; }, ), @@ -118,3 +134,78 @@ export async function getTranslationOriginal( } return await getEntry(translation.data.translationOf); } + +export function licenseNotice( + license: typeof LICENSES[number], + { title, holders, years }: { + title: string; + holders: { name: string; email?: string }[]; + years: number[]; + }, + locale?: Intl.LocalesArgument, +): string | undefined { + const list = new Intl.ListFormat(locale, { + style: "narrow", + type: "unit", + }); + switch (license) { + case "CC0": + return `${title} by ${ + list.format(holders.map(get("name"))) + } is marked CC0 1.0 Universal. To view a copy of this mark, visit https://creativecommons.org/publicdomain/zero/1.0/`; + case "CC-BY": + case "CC-BY-SA": + case "CC-BY-ND": + case "CC-BY-NC": + case "CC-BY-NC-SA": + case "CC-BY-NC-ND": + return `${title} © ${ + listYearsWithRanges(years, { + locale, + list: { type: "unit", style: "narrow" }, + }) + } by ${ + list.format(holders.map(get("name"))) + } is licensed under Creative Commons ${ + license.slice(3).replace("BY", "Attribution").replace( + "SA", + "ShareAlike", + ).replace("ND", "NoDerivatives").replace("NC", "NonCommercial") + } 4.0 International. To view a copy of this license, visit https://creativecommons.org/licenses/${ + license.slice(3).toLowerCase() + }/4.0/`; + case "WTFPL": + return `Copyright (C) ${ + listYearsWithRanges(years, { + locale, + list: { type: "unit", style: "narrow" }, + }) + } ${ + list.format(holders.map(({ name, email }) => + name + (email !== undefined ? ` ${email}` : "") + )) + }`; + case "public domain": + undefined; + } +} +export function licenseURL(license: typeof LICENSES[number]): URL | undefined { + switch (license) { + case "CC0": + case "CC-BY": + case "CC-BY-SA": + case "CC-BY-ND": + case "CC-BY-NC": + case "CC-BY-NC-SA": + case "CC-BY-NC-ND": + return new URL( + `https://creativecommons.org/licenses/${ + license.slice(3).toLowerCase() + }/4.0/`, + ); + case "WTFPL": + return new URL("http://www.wtfpl.net/"); + case "public domain": + return undefined; + } +} diff --git a/src/lib/collection/schemas.ts b/src/lib/collection/schemas.ts index f8a021d..720b9f2 100644 --- a/src/lib/collection/schemas.ts +++ b/src/lib/collection/schemas.ts @@ -119,6 +119,11 @@ export const Blog = z.discriminatedUnion("kind", [ ({ dateCreated, dateUpdated }) => dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(), { message: "Update before creation" }, +).refine( + ({ signers, license }) => + !(signers.filter(({ role }) => role === "author").length <= 0 && + license !== undefined), + { message: "License cannot be defined if there are no signers." }, ); export type OriginalEntry = CollectionEntry<"blog"> & { diff --git a/src/lib/collection/types.ts b/src/lib/collection/types.ts new file mode 100644 index 0000000..67f1110 --- /dev/null +++ b/src/lib/collection/types.ts @@ -0,0 +1,54 @@ +export type Person = { + "@type": "Person"; + "@id"?: string; + email?: string; + knows?: Person[]; + knowsLanguage?: string[]; + nationality?: { + "@type": "Country"; + }; + description?: string; + name?: string; + url?: string[]; +}; + +export type BlogPosting = { + "@type": "BlogPosting"; + "@id": string; + url: string; + headline: string; + name: string; + alternativeHeadline?: string; + inLanguage: string; + abstract?: string; + description?: string; + author?: Person; + contributor?: Person[]; + translator?: Person[]; + dateCreated: string; + dateModified?: string; + datePublished?: string; + keywords?: string[]; + wordCount?: number; + timeRequired?: string; + articleBody?: string; + text?: string; + copyrightHolder?: Person[]; + copyrightNotice?: string; + copyrightYear?: number; + creativeWorkStatus?: "Published"; + encodingFormat?: "text/html"; + isAccessibleForFree?: true; + license: string | undefined; + citation?: BlogPosting[]; + mentions?: BlogPosting[]; + translationOfWork?: BlogPosting; + workTranslations?: BlogPosting[]; + isBasedOn?: BlogPosting; + locationCreated?: { + "@type": "Place"; + name: string; + }; + publisher?: Person; + version?: string | number; +}; diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts index bcf6888..4545f17 100644 --- a/src/lib/git/log.ts +++ b/src/lib/git/log.ts @@ -1,4 +1,4 @@ -import { defined } from "../../utils/anonymous.ts"; +import { defined, get } from "../../utils/anonymous.ts"; import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts"; import { gitDir } from "./index.ts"; import type { Commit, CommitFile } from "./types.ts"; @@ -23,72 +23,86 @@ export async function getLastCommitForOneOfFiles( sources: MaybeIterable, ): Promise { const files = surelyIterable(sources); - const gitLog = new Deno.Command("git", { - args: [ - "log", - "-1", - `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`, - "--", - // deno-lint-ignore no-undef - ...Iterator.from(files).map((x) => x.pathname), - ], - }); + const gitLogs = (await Promise.all( + Iterator.from(files).map(async ({ pathname }) => { + const gitLog = new Deno.Command("git", { + args: [ + "log", + "--follow", + "-1", + `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`, + "--", + pathname, + ], + }); + const { stdout } = await gitLog.output(); + const result = new TextDecoder().decode(stdout).trim(); - const { stdout } = await gitLog.output(); - const result = new TextDecoder().decode(stdout).trim(); + if (result.length <= 0) { + return undefined; + } - if (result.length <= 0) { - return undefined; - } + const [ + hash, + abbrHash, + authorDate, + authorName, + authorEmail, + committerDate, + committerName, + committerEmail, + // signatureValidation, + signer, + key, + keyFingerPrint, + ...rawLines + ] = result.split("\n"); - 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 = { - // deno-lint-ignore no-undef - 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, - }; - } + const raw = rawLines.join("\n").trim(); + + const commit: Commit = { + // deno-lint-ignore no-undef + 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; + }), + )).filter(defined); + + const last = gitLogs.sort(({ committer: a }, { committer: b }) => + b.date.getTime() - a.date.getTime() + )?.[0]; + + if (last === undefined) return undefined; - return commit; + const final = gitLogs.filter(({ hash }) => hash.long === last.hash.long); + + last.files = final.flatMap(get("files")); + return last; } async function fileStatusFromCommit( @@ -132,3 +146,25 @@ async function fileStatusFromCommit( return undefined; }).filter(defined); } + +export async function fileCreationCommitDate( + file: URL, +): Promise { + const gitDiffTree = new Deno.Command("git", { + args: [ + "log", + "--follow", + "--diff-filter=A", + "--format=%cI", + "--", + file.pathname, + ], + }); + + const { stdout } = await gitDiffTree.output(); + try { + return new Date(new TextDecoder().decode(stdout).trim()); + } catch { + return undefined; + } +} diff --git a/src/lib/pgp/summary.ts b/src/lib/pgp/summary.ts index 5c8a81c..bcd9bc8 100644 --- a/src/lib/pgp/summary.ts +++ b/src/lib/pgp/summary.ts @@ -57,7 +57,7 @@ export type Summary = { result: VerificationResult.MISSING_KEY; reason: Error; keyID: string; - created: Date; + created: Date | null; } | { result: | VerificationResult.SIGNATURE_CORRUPTED @@ -67,11 +67,11 @@ export type Summary = { } | { result: VerificationResult.TRUSTED_KEY; key: PublicKey | Subkey; - created: Date; + created: Date | null; } | { result: VerificationResult.UNTRUSTED_KEY; key: PublicKey | Subkey; - created: Date; + created: Date | null; } | { result: VerificationResult.EXPIRATION_AFTER_SIGNATURE; key: PublicKey | Subkey; @@ -99,7 +99,7 @@ export type Summary = { key: PublicKey | Subkey; }; -export async function createVerificationSummary( +export async function createVerificationsSummary( { dataCorrupted, verifications, signature }: Verification, ): Promise<[NonEmptyArray, Map>]> { if (signature === undefined) { @@ -116,107 +116,7 @@ export async function createVerificationSummary( const summaries = await Promise.all< Promise<[Summary[], Map]>[] - >( - (verifications ?? []).map( - async ({ signatureCorrupted, verified, packet, key }) => { - const errors: Summary[] = []; - const keys: Map = 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]; - } - - 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]; - }, - ), - ); + >((verifications ?? []).map(createVerificationSummary)); const errors = summaries.flatMap(([x]) => x); const keys = new Map(summaries.flatMap(([, x]) => x.entries().toArray())); @@ -230,3 +130,109 @@ export async function createVerificationSummary( throw new Error("unreachable"); } + +export const createVerificationSummary = async ( + { signatureCorrupted, verified, packet, key }: NonNullable< + Verification["verifications"] + >[number], +): Promise<[Summary[], Map]> => { + const errors: Summary[] = []; + const keys: Map = new Map(); + + const sig = await packet; + + 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, + created: sig.created, + }); + 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 keyID = sig.issuerKeyID; + + 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()}`, + ), + created: sig.created, + }); + keys.set(keyID.toHex(), key); + + return [errors, keys] as [Summary[], Map]; + } + + 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, + created: sig.created, + 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, + created: sig.created, + revoked: 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, + created: sig.created, + }); + + keys.set(keyAwaited.getKeyID().toHex(), keySummaries); + + return [errors, keys] as [Summary[], Map]; +}; diff --git a/src/lib/pgp/verify.ts b/src/lib/pgp/verify.ts index 026b6df..1003147 100644 --- a/src/lib/pgp/verify.ts +++ b/src/lib/pgp/verify.ts @@ -24,6 +24,7 @@ import type { Commit } from "../git/types.ts"; import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts"; import { getUserIDsFromKey } from "./user.ts"; import { env } from "../environment.ts"; +import { toPK } from "./index.ts"; type DataURL = [URL, URL?]; type Corrupted = [false] | [true, Error]; @@ -195,18 +196,21 @@ export class SignatureVerifier { } } - addKey(key: MaybeIterable): void { + addKey(key: MaybeIterable): Iterable { if (key instanceof PublicKey) { this.keys.push(key); + return [key]; } else { this.keys.push(...key); + return key; } } async addKeysFromDir( key: string | URL, rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES, - ): Promise { + ): Promise> { + const keys: PublicKey[] = []; for await ( const i of createKeysFromDir(key, rules, { encoder: this.#encoder, @@ -214,39 +218,43 @@ export class SignatureVerifier { }) ) { this.keys.push(i); + keys.push(i); } + return keys; } async addKeyFromFile( key: string | URL, type: KeyFileFormat, - ): Promise { + ): Promise { switch (type) { case armored: { - this.keys.push(await createKeyFromFile(key, type, this.#decoder)); - break; + const k = await createKeyFromFile(key, type, this.#decoder); + this.keys.push(k); + return k; } case binary: { - this.keys.push(await createKeyFromFile(key, type, this.#encoder)); - break; + const k = await createKeyFromFile(key, type, this.#encoder); + this.keys.push(k); + return k; } } } async addKeyFromArmor( key: string | Uint8Array, - ): Promise { - this.keys.push( - await createKeyFromArmor(key, this.#decoder).then((x) => x.toPublic()), - ); + ): Promise { + const k = await createKeyFromArmor(key, this.#decoder).then(toPK); + this.keys.push(k); + return k; } async addKeyFromBinary( key: string | Uint8Array, - ): Promise { - this.keys.push( - await createKeyFromBinary(key, this.#encoder).then((x) => x.toPublic()), - ); + ): Promise { + const k = await createKeyFromBinary(key, this.#encoder).then(toPK); + this.keys.push(k); + return k; } public static async instance(): Promise { -- cgit v1.2.3