summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/collection/helpers.ts107
-rw-r--r--src/lib/collection/schemas.ts133
-rw-r--r--src/lib/env.ts68
-rw-r--r--src/lib/git/log.ts3
-rw-r--r--src/lib/pgp/sign.ts1
-rw-r--r--src/lib/pgp/trust.ts5
-rw-r--r--src/lib/pgp/user.ts33
-rw-r--r--src/lib/pgp/verify.ts39
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>