summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/collection/helpers.ts115
-rw-r--r--src/lib/collection/schemas.ts5
-rw-r--r--src/lib/collection/types.ts54
-rw-r--r--src/lib/git/log.ts162
-rw-r--r--src/lib/pgp/summary.ts216
-rw-r--r--src/lib/pgp/verify.ts38
6 files changed, 395 insertions, 195 deletions
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<z.infer<typeof EntityTypesEnum>, 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<typeof getSignersIDs>;
+}
+
export async function getSigners(
{ data }: CollectionEntry<"blog">,
): Promise<{
- id: string;
entity: CollectionEntry<"entity">;
- role: z.infer<typeof Blog>["signers"][number]["role"] | undefined;
+ role?: z.infer<typeof Blog>["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<URL>,
): Promise<Commit | undefined> {
const files = surelyIterable(sources);
- const gitLog = new Deno.Command("git", {
- args: [
- "log",
- "-1",
- `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`,
- "--",
- // 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<Date | undefined> {
+ 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<Summary>, Map<string, NonEmptyArray<Summary>>]> {
if (signature === undefined) {
@@ -116,107 +116,7 @@ export async function createVerificationSummary(
const summaries = await Promise.all<
Promise<[Summary[], Map<string, Summary[]>]>[]
- >(
- (verifications ?? []).map(
- async ({ signatureCorrupted, verified, packet, key }) => {
- const errors: Summary[] = [];
- const keys: Map<string, Summary[]> = new Map();
-
- try {
- await verified;
- } catch (e) {
- if (e instanceof Error) {
- if (
- e.message.startsWith("Could not find signing key with key ID")
- ) {
- const keyID = e.message.slice(e.message.lastIndexOf(" "));
- const key = keys.get(keyID) ?? [];
- key.push({
- result: VerificationResult.MISSING_KEY,
- keyID,
- reason: e,
- });
- keys.set(keyID, key);
- } else {
- errors.push({
- result: VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED,
- reason: e,
- });
- }
- } else {
- throw e;
- }
- }
-
- const corrupted = await signatureCorrupted;
- if (corrupted[0]) {
- errors.push({
- result: VerificationResult.SIGNATURE_CORRUPTED,
- reason: corrupted[1],
- });
- }
-
- const sig = await packet;
- const keyID = sig.issuerKeyID;
-
- sig.created;
-
- const keyAwaited = await key;
-
- if (keyAwaited === undefined) {
- const key = keys.get(keyID.toHex()) ?? [];
- key.push({
- result: VerificationResult.MISSING_KEY,
- keyID: keyID.toHex(),
- reason: new Error(
- `Could not find signing key with key ID ${keyID.toHex()}`,
- ),
- });
- keys.set(keyID.toHex(), key);
-
- return [errors, keys] as [Summary[], Map<string, Summary[]>];
- }
-
- const keySummaries = keys.get(keyAwaited.getKeyID().toHex()) ?? [];
- const expired = await isKeyExpired(keyAwaited);
-
- if (expired !== null && sig.created !== null) {
- keySummaries.push({
- result: expired <= sig.created
- ? VerificationResult.EXPIRATION_BEFORE_SIGNATURE
- : VerificationResult.EXPIRATION_AFTER_SIGNATURE,
- key: keyAwaited,
- date: expired,
- });
- }
-
- const revoked = isKeyRevoked(keyAwaited);
- if (revoked?.date !== undefined && sig.created !== null) {
- keySummaries.push({
- result: revoked?.date <= sig.created
- ? VerificationResult.REVOCATION_BEFORE_SIGNATURE
- : VerificationResult.REVOCATION_AFTER_SIGNATURE,
- key: keyAwaited,
- date: revoked.date,
- revocationReason: revoked.reason,
- });
- }
-
- const trust = sig.trustAmount ?? await keyTrust(keyAwaited as Key);
-
- keySummaries.push({
- result: trust > 0
- ? VerificationResult.TRUSTED_KEY
- : VerificationResult.UNTRUSTED_KEY,
- key: keyAwaited,
- });
-
- keys.set(keyAwaited.getKeyID().toHex(), keySummaries);
-
- return [errors, keys] as [Summary[], Map<string, Summary[]>];
- },
- ),
- );
+ >((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<string, Summary[]>]> => {
+ const errors: Summary[] = [];
+ const keys: Map<string, Summary[]> = 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<string, Summary[]>];
+ }
+
+ const keySummaries = keys.get(keyAwaited.getKeyID().toHex()) ?? [];
+ const expired = await isKeyExpired(keyAwaited);
+
+ if (expired !== null && sig.created !== null) {
+ keySummaries.push({
+ result: expired <= sig.created
+ ? VerificationResult.EXPIRATION_BEFORE_SIGNATURE
+ : VerificationResult.EXPIRATION_AFTER_SIGNATURE,
+ key: keyAwaited,
+ 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<string, Summary[]>];
+};
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<PublicKey>): void {
+ addKey(key: MaybeIterable<PublicKey>): Iterable<PublicKey> {
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<void> {
+ ): Promise<Iterable<PublicKey>> {
+ 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<void> {
+ ): Promise<PublicKey> {
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<void> {
- this.keys.push(
- await createKeyFromArmor(key, this.#decoder).then((x) => x.toPublic()),
- );
+ ): Promise<PublicKey> {
+ const k = await createKeyFromArmor(key, this.#decoder).then(toPK);
+ this.keys.push(k);
+ return k;
}
async addKeyFromBinary(
key: string | Uint8Array,
- ): Promise<void> {
- this.keys.push(
- await createKeyFromBinary(key, this.#encoder).then((x) => x.toPublic()),
- );
+ ): Promise<PublicKey> {
+ const k = await createKeyFromBinary(key, this.#encoder).then(toPK);
+ this.keys.push(k);
+ return k;
}
public static async instance(): Promise<SignatureVerifier> {