diff options
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/collection/helpers.ts | 107 | ||||
-rw-r--r-- | src/lib/collection/schemas.ts | 133 | ||||
-rw-r--r-- | src/lib/env.ts | 68 | ||||
-rw-r--r-- | src/lib/git/log.ts | 3 | ||||
-rw-r--r-- | src/lib/pgp/sign.ts | 1 | ||||
-rw-r--r-- | src/lib/pgp/trust.ts | 5 | ||||
-rw-r--r-- | src/lib/pgp/user.ts | 33 | ||||
-rw-r--r-- | src/lib/pgp/verify.ts | 39 |
8 files changed, 353 insertions, 36 deletions
diff --git a/src/lib/collection/helpers.ts b/src/lib/collection/helpers.ts new file mode 100644 index 0000000..83eb21d --- /dev/null +++ b/src/lib/collection/helpers.ts @@ -0,0 +1,107 @@ +import type { CollectionEntry } from "astro:content"; +import { + Blog, + Entity, + type Entry, + type MicroEntry, + type OriginalEntry, + type TranslationEntry, +} from "./schemas.ts"; +import { getEntries, type z } from "astro:content"; +import { defined, get, identity } from "../../utils/anonymous.ts"; +import { createKeyFromArmor } from "../pgp/create.ts"; +import { getUserIDsFromKey } from "../pgp/user.ts"; +import type { UserIDPacket } from "openpgp"; +import { getCollection } from "astro:content"; + +export function getLastUpdate({ data }: CollectionEntry<"blog">): Date { + return data.dateUpdated ?? data.dateCreated; +} +export const sortLastCreated = ( + { data: a }: CollectionEntry<"blog">, + { data: b }: CollectionEntry<"blog">, +): number => b.dateCreated - a.dateCreated; +export const sortFirstCreated = ( + a: CollectionEntry<"blog">, + b: CollectionEntry<"blog">, +): number => sortLastCreated(b, a); +export const sortLastUpdated = ( + { data: a }: CollectionEntry<"blog">, + { data: b }: CollectionEntry<"blog">, +): number => + (b.dateUpdated ?? b.dateCreated) - (a.dateUpdated ?? a.dateCreated); +export const sortFirstUpdated = ( + a: CollectionEntry<"blog">, + b: CollectionEntry<"blog">, +): number => sortLastUpdated(b, a); + +export async function getSigners( + { data }: CollectionEntry<"blog">, +): Promise<{ + id: string; + entity: CollectionEntry<"entity">; + role: z.infer<typeof Blog>["signers"][number]["role"] | undefined; +}[]> { + 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)) + ); +} + +export async function getFirstAuthorEmail( + blog: CollectionEntry<"blog">, +): Promise<string | undefined> { + const signers = await getSigners(blog); + const emails = await Promise.all( + signers.filter(({ role }) => role === "author").map(async ({ entity }) => { + const { publickey } = Entity.parse(entity.data); + const key = await createKeyFromArmor(publickey.armor); + const users = getUserIDsFromKey(undefined, key); + return users.map(get("email")).filter(Boolean)?.[0]; + }), + ); + return emails.filter(defined)?.[0]; +} + +export async function getFirstUserID( + blog: CollectionEntry<"blog">, +): Promise< + (Partial<UserIDPacket> & { entity: string; website: string | undefined }) +> { + const signers = await getSigners(blog); + const userIDs = await Promise.all( + signers.filter(({ role }) => role === "author").map( + async ({ id, 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 }; + })?.[0]; + }, + ), + ); + return userIDs.filter(defined)?.[0]; +} + +export async function fromPosts<T extends Entry, U>( + filter: (entry: CollectionEntry<"blog">) => entry is T, + predicate: (entries: T[]) => U = identity as (entries: T[]) => U, +): Promise<U> { + const entries = await getCollection<"blog", T>("blog", filter); + return predicate(entries); +} +export const isOriginal = ( + entry: CollectionEntry<"blog">, +): entry is OriginalEntry => entry.data.kind === "original"; +export const isTranslation = ( + entry: CollectionEntry<"blog">, +): entry is TranslationEntry => entry.data.kind === "translation"; +export const isMicro = ( + entry: CollectionEntry<"blog">, +): entry is MicroEntry => entry.data.kind === "micro"; diff --git a/src/lib/collection/schemas.ts b/src/lib/collection/schemas.ts new file mode 100644 index 0000000..eca996f --- /dev/null +++ b/src/lib/collection/schemas.ts @@ -0,0 +1,133 @@ +import { reference, z } from "astro:content"; +import { isValidLocale } from "../../utils/lang.ts"; +import { get } from "../../utils/anonymous.ts"; +import type { CollectionEntry } from "astro:content"; + +export const KEYWORDS = ["Portugal", "democracy", "test"] 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 Original = z.object({ + kind: z.literal("original"), + title: z.string().trim(), + subtitle: z.string().trim().optional(), + description: z.string().trim().optional(), + keywords: z.array(KeywordsEnum).refine( + (keywords) => new Set(keywords).size === keywords.length, + { message: "Keywords must be unique" }, + ), + 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, + { message: "Related posts referenced multiple times" }, + ), + lang: z.string().trim().refine(isValidLocale), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => 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" }).refine( + (signers) => signers.every(({ role }) => role !== "translator"), + { message: "There can't be translator signers on non translated work" }, + ), + license: LicensesEnum.default("public domain"), +}); + +export const Translation = z.object({ + kind: z.literal("translation"), + title: z.string().trim(), + subtitle: z.string().trim().optional(), + description: z.string().trim().optional(), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + lang: z.string().trim().refine(isValidLocale), + translationOf: reference("blog"), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => 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" }), + license: LicensesEnum.default("public domain"), +}); + +export const Micro = z.object({ + kind: z.literal("micro"), + title: z.string().trim(), + keywords: z.array(KeywordsEnum).refine( + (keywords) => new Set(keywords).size === keywords.length, + { message: "Keywords must be unique" }, + ), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + locationCreated: z.string().trim().optional(), + lang: z.string().trim().refine(isValidLocale), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => 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" }).refine( + (signers) => signers.every(({ role }) => role !== "translator"), + { message: "There can't be translator signers on non translated work" }, + ), + license: LicensesEnum.default("public domain"), +}); + +export const Blog = z.discriminatedUnion("kind", [ + Original, + Translation, + Micro, +]).refine( + ({ dateCreated, dateUpdated }) => + dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(), + { message: "Update before creation" }, +); + +export type OriginalEntry = CollectionEntry<"blog"> & { + data: z.infer<typeof Original>; +}; +export type TranslationEntry = CollectionEntry<"blog"> & { + data: z.infer<typeof Translation>; +}; +export type MicroEntry = CollectionEntry<"blog"> & { + data: z.infer<typeof Micro>; +}; +export type Entry = OriginalEntry | TranslationEntry | MicroEntry; + +export const Entity = z.object({ + websites: z.array(z.string().url().trim()).default([]).transform((websites) => + websites.map((x) => new URL(x).href) + ), + publickey: z.object({ armor: z.string().trim() }), +}); diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..679c76f --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,68 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "astro:content"; + +export const env = createEnv({ + server: { + TRUSTED_KEYS_DIR: z.string().superRefine((val, ctx) => { + let url: URL; + const cwd = new URL(`file://${Deno.cwd()}/`); + try { + url = new URL(val, cwd); + } catch { + ctx.addIssue({ + code: "custom", + message: `${cwd}${val} doesn't exist`, + fatal: true, + }); + return; + } + + const { isDirectory } = Deno.statSync(url); + + if (isDirectory) return; + + ctx.addIssue({ + code: "custom", + message: `${url} it's not a directory`, + fatal: true, + }); + }).transform((val) => new URL(val, new URL(`file://${Deno.cwd()}/`))), + }, + + /** + * The prefix that client-side variables must have. This is enforced both at + * a type-level and at runtime. + */ + clientPrefix: "PUBLIC_", + client: { + PUBLIC_SITE_URL: z.string().url(), + PUBLIC_SITE_TITLE: z.string().trim().min(1), + PUBLIC_SITE_DESCRIPTION: z.string().trim().min(1), + PUBLIC_SITE_AUTHOR: z.string().trim().min(1), + PUBLIC_GIT_URL: z.string().url(), + PUBLIC_TOR_URL: z.string().url().optional(), + PUBLIC_GIT_TOR_URL: z.string().url().optional(), + PUBLIC_SIMPLE_X_ADDRESS: z.string().url().optional(), + }, + + /** + * What object holds the environment variables at runtime. This is usually + * `process.env` or `import.meta.env`. + */ + runtimeEnv: import.meta.env ?? Deno.env.toObject(), + + /** + * By default, this library will feed the environment variables directly to + * the Zod validator. + * + * This means that if you have an empty string for a value that is supposed + * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag + * it as a type mismatch violation. Additionally, if you have an empty string + * for a value that is supposed to be a string with a default value (e.g. + * `DOMAIN=` in an ".env" file), the default value will never be applied. + * + * In order to solve these issues, we recommend that all new projects + * explicitly specify this option as true. + */ + emptyStringAsUndefined: true, +}); diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts index 86bbe7b..bcf6888 100644 --- a/src/lib/git/log.ts +++ b/src/lib/git/log.ts @@ -29,6 +29,7 @@ export async function getLastCommitForOneOfFiles( "-1", `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`, "--", + // deno-lint-ignore no-undef ...Iterator.from(files).map((x) => x.pathname), ], }); @@ -59,6 +60,7 @@ export async function getLastCommitForOneOfFiles( 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: { @@ -112,6 +114,7 @@ async function fileStatusFromCommit( return result.map((line) => { const [status, path] = line.split("\t"); if ( + // deno-lint-ignore no-undef Iterator.from(files).some((file) => file.pathname.replace(dir.pathname, "").includes(path) ) diff --git a/src/lib/pgp/sign.ts b/src/lib/pgp/sign.ts index 5f7f5a8..6d1e78c 100644 --- a/src/lib/pgp/sign.ts +++ b/src/lib/pgp/sign.ts @@ -26,6 +26,7 @@ export class Signature { getPackets(key?: MaybeIterable<KeyID>): Packet[] { key ??= this.signingKeyIDs; + // deno-lint-ignore no-undef const iterator = Iterator.from(surelyIterable(key)); return iterator.map((key) => this.#packets.get(key.bytes)).filter(defined) .flatMap(identity).toArray(); diff --git a/src/lib/pgp/trust.ts b/src/lib/pgp/trust.ts index cf022b4..34d454b 100644 --- a/src/lib/pgp/trust.ts +++ b/src/lib/pgp/trust.ts @@ -1,19 +1,20 @@ import type { Key } from "npm:openpgp@^6.1.1"; -import { TRUSTED_KEYS_DIR } from "../../consts.ts"; import { createKeysFromDir } from "./create.ts"; import type { AsyncYieldType } from "../../utils/iterator.ts"; import { equal, getCall } from "../../utils/anonymous.ts"; +import { env } from "../env.ts"; let trusted: | Iterable<AsyncYieldType<ReturnType<typeof createKeysFromDir>>> | undefined = undefined; const fingerprints = () => + // deno-lint-ignore no-undef Iterator.from(trusted ?? []).map(getCall("getFingerprint")); export async function keyTrust(key: Key): Promise<number> { if (trusted === undefined) { - trusted = await Array.fromAsync(createKeysFromDir(TRUSTED_KEYS_DIR)); + trusted = await Array.fromAsync(createKeysFromDir(env.TRUSTED_KEYS_DIR)); } return fingerprints().some(equal(key.getFingerprint())) ? 255 : 0; } diff --git a/src/lib/pgp/user.ts b/src/lib/pgp/user.ts new file mode 100644 index 0000000..334fbde --- /dev/null +++ b/src/lib/pgp/user.ts @@ -0,0 +1,33 @@ +import { PublicKey, type Subkey, UserIDPacket } from "openpgp"; +import type { Signature } from "./sign.ts"; +import { defined, get } from "../../utils/anonymous.ts"; + +export function getUserIDsFromKey( + signature: Signature | undefined, + key: PublicKey | Subkey, +): UserIDPacket[] { + const packet = signature?.getPackets?.()?.[0]; + const userID = packet?.signersUserID; + + if (userID) { + return [UserIDPacket.fromObject(parseUserID(userID))]; + } + + key = key instanceof PublicKey ? key : key.mainKey; + return key.users.map(get("userID")).filter(defined); +} + +function parseUserID(input: string) { + const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/; + const match = input.match(regex); + + if (!match) return {}; + + const [, name, comment, email] = match; + + return { + name: name?.trim() || undefined, + comment: comment?.trim() || undefined, + email: email?.trim() || undefined, + }; +} diff --git a/src/lib/pgp/verify.ts b/src/lib/pgp/verify.ts index da2de7f..f37c0bb 100644 --- a/src/lib/pgp/verify.ts +++ b/src/lib/pgp/verify.ts @@ -3,7 +3,7 @@ import { PublicKey, readSignature, type Subkey, - UserIDPacket, + type UserIDPacket, verify, } from "openpgp"; import { @@ -18,11 +18,12 @@ import { type KeyFileFormat, } from "./create.ts"; import { getLastCommitForOneOfFiles } from "../git/log.ts"; -import { defined, get, instanciate } from "../../utils/anonymous.ts"; +import { get, instanciate } from "../../utils/anonymous.ts"; import { Packet, Signature } from "./sign.ts"; import type { Commit } from "../git/types.ts"; -import { TRUSTED_KEYS_DIR } from "../../consts.ts"; import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts"; +import { getUserIDsFromKey } from "./user.ts"; +import { env } from "../env.ts"; type DataURL = [URL, URL?]; type Corrupted = [false] | [true, Error]; @@ -251,7 +252,7 @@ export class SignatureVerifier { public static async instance(): Promise<SignatureVerifier> { if (!SignatureVerifier.#instance) { SignatureVerifier.#instance = new SignatureVerifier(); - await SignatureVerifier.#instance.addKeysFromDir(TRUSTED_KEYS_DIR); + await SignatureVerifier.#instance.addKeysFromDir(env.TRUSTED_KEYS_DIR); } return SignatureVerifier.#instance; @@ -270,36 +271,6 @@ export class SignatureVerifier { export const verifier = SignatureVerifier.instance(); -function getUserIDsFromKey( - signature: Signature, - key: PublicKey | Subkey, -): UserIDPacket[] { - const packet = signature.getPackets()[0]; - const userID = packet.signersUserID; - - if (userID) { - return [UserIDPacket.fromObject(parseUserID(userID))]; - } - - key = key instanceof PublicKey ? key : key.mainKey; - return key.users.map(get("userID")).filter(defined); -} - -function parseUserID(input: string) { - const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/; - const match = input.match(regex); - - if (!match) return {}; - - const [, name, comment, email] = match; - - return { - name: name?.trim() || undefined, - comment: comment?.trim() || undefined, - email: email?.trim() || undefined, - }; -} - async function isSignatureCorrupted( verified: Awaited< ReturnType<typeof verify> |