initial commit.

This commit is contained in:
Moon Man 2023-12-25 13:49:16 -05:00
commit b19074b994
16 changed files with 7092 additions and 0 deletions

12
.eslintrc.cjs Normal file
View File

@ -0,0 +1,12 @@
/* eslint-env node */
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'plugin:prettier/recommended'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
root: true
};

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
dist/*
node_modules/*
pages/*
*.sqlite3
.env*

19
README.md Normal file
View File

@ -0,0 +1,19 @@
# How to use it
0. create a dotenv .env
1. Create a directory "pages"
2. npm install
3. npx tsc
4. node dist/command.js new-user nickname "fullname"
5. create a file pages/whatever-page.md
6. node dist/command.js new-article nickname whatever-page "Whatever Page Title"
7. node --env-file=.env dist/index.js
8. curl --verbose -H 'Accept: application/activity+json' http://127.0.0.1:8080/author/nickname/outbox
## dotenv contents:
```
blog_host=blog.whatever.example.net
blog_title="Your blog title"
port=8080
```

12
knexfile.js Normal file
View File

@ -0,0 +1,12 @@
export default {
client: "sqlite3",
connection: {
filename: "./prod.sqlite3"
},
useNullAsDefault: true,
migrations: {
directory: "./migrations"
}
};

View File

@ -0,0 +1,80 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const up = async function (knex) {
return knex.schema
.createTable("users", (table) => {
table.increments("id");
table.string("name").notNullable();
table.string("nickname").notNullable();
table.string("bio");
table.boolean("deleted").notNullable();
table.timestamps(true, false, false);
})
.createTable("articles", (table) => {
table.increments("id");
table.string("title").notNullable();
table.string("slug").notNullable().unique();
table.string("file").notNullable().unique();
table.integer("users_id");
table.foreign("users_id").references("users.id").onDelete("CASCADE");
table.boolean("deleted").notNullable();
table.timestamps(true, false, false);
})
.createTable("collection_types", (table) => {
table.integer("id").unsigned().primary();
table.string("name").notNullable();
})
.createTable("collections", (table) => {
table.increments("id");
table.integer("collection_types_id").notNullable();
table.foreign("collection_types_id").references("collection_types.id");
table.integer("articles_id");
table.foreign("articles_id").references("articles.id").onDelete("CASCADE");
table.integer("users_id");
table.foreign("users_id").references("users.id").onDelete("CASCADE");
table.string("value").notNullable();
})
.createTable("outboxes", (table) => {
table.increments("id");
table.integer("users_id");
table.foreign("users_id").references("users.id").onDelete("CASCADE");
table.string("verb").notNullable();
table.integer("articles_id");
table.foreign("articles_id").references("articles.id").onDelete("CASCADE");
table.timestamps(true, false, false);
})
.then(() => {
// Hardcoding these so they can be referenced by constants in code.
knex("collection_types").insert([
{
id: 0,
name: "followers"
},
{
id: 1,
name: "likes"
},
{
id: 2,
name: "announcements"
}
]);
});
;
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const down = function (knex) {
return knex.schema
.dropTableIfExists("outboxes")
.dropTableIfExists("collections")
.dropTableIfExists("collection_types")
.dropTableIfExists("articles")
.dropTableIfExists("users")
;
};

4
module.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "activitypub-express" {
function ActivitypubExpress(options: Record<string, any>);
export default ActivitypubExpress;
}

6462
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "activitypress",
"version": "1.0.0",
"description": "Activitypub-enabled Markdown blog",
"type": "module",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"new-migration": "npx knex --migrations-directory migrations --esm migrate:make"
},
"keywords": [
"blog",
"activitypub",
"markdown"
],
"author": "moon@shipoclu.com",
"license": "AGPL-3.0",
"devDependencies": {
"@types/express": "^4.17.21",
"@types/markdown-it": "^13.0.7",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.1",
"prettier": "3.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"dependencies": {
"activitypub-express": "^4.4.1",
"better-sqlite3": "^9.2.2",
"knex": "^3.1.0",
"markdown-it": "^14.0.0",
"sqlite3": "^5.1.6",
"zod": "^3.22.4"
}
}

44
src/article.ts Normal file
View File

@ -0,0 +1,44 @@
import db from "./db.js";
import { z } from "zod";
export const slugRegex = /^[a-zA-Z0-9_-]+$/;
export const zArticle = z.object({
id: z.number().min(0),
title: z.string().min(1),
slug: z.string().regex(slugRegex).min(3).max(100),
file: z.string().regex(/^[a-zA-Z0-9_-]+\.md$/),
users_id: z.number().min(0),
deleted: z.boolean(),
created_at: z.date(),
updated_at: z.union([z.date(), z.null()])
});
export type Article = z.infer<typeof zArticle>;
export const getBySlug = async (slug: string): Promise<Article | null> => {
return db<Article>("articles")
.where("slug", slug)
.first()
.then((rec) => !!rec ? rec : null)
};
export const getById = async (articleId: number): Promise<Article | null> => {
return db<Article>("articles")
.where("id", articleId)
.first()
.then((rec) => !!rec ? rec : null)
};
export const insert = async (userId: number, slug: string, title: string): Promise<number> => {
return db("articles").insert({
users_id: userId,
slug,
title,
file: `pages/${slug}.md`,
deleted: false,
created_at: new Date()
})
.returning("id")
.then(([{ id: id }]: { id: number }[]) => id)
};

61
src/command.ts Normal file
View File

@ -0,0 +1,61 @@
import fs from "node:fs";
import markdownit from 'markdown-it'
import { slugRegex } from "./article.js";
import { newUser, getId as getUserIdByNickname } from "./user.js";
import { insert as insertArticle } from "./article.js";
import { add as addToOutbox } from "./outbox.js";
const md = markdownit();
const c = process.argv[2];
let returnCode = 0;
if (c === "new-user") {
const nickname = process.argv[3];
const name = process.argv[4];
const bio = process.argv[5] || "";
const userId = await newUser(nickname, name, bio);
console.log(userId);
}
else if (c === "new-article") {
const nickname = process.argv[3];
const slug = process.argv[4];
const title = process.argv[5];
const images = process.argv.slice(6);
const userId = await getUserIdByNickname(nickname);
if (!userId) {
console.error("Nonexistent user");
process.exit(1);
}
if (!slugRegex.test(slug)) {
returnCode = 1;
console.error("Bad slug");
process.exit(returnCode);
}
const filename = `pages/${slug}.md`;
if (!fs.existsSync(filename)) {
console.error("No file with that slug");
process.exit(1);
}
const htmlFilename = `pages/${slug}.html`;
const markdown = fs.readFileSync(filename, "utf-8");
const html = md.render(markdown);
fs.writeFileSync(htmlFilename, html);
const articleId = await insertArticle(userId, slug, title);
console.log(articleId);
await addToOutbox(articleId);
process.exit(0);
}
else {
console.error("Unrecognized command: ", c);
returnCode = 1;
process.exit(returnCode);
}

6
src/db.ts Normal file
View File

@ -0,0 +1,6 @@
import knex from "knex";
import config from "./knexfile.js";
const db = knex(config);
export default db;

72
src/index.ts Normal file
View File

@ -0,0 +1,72 @@
import express from "express";
import ActivitypubExpress from "activitypub-express";
import { get as getOutbox } from "./outbox.js";
const port = parseInt(process.env.port || "8080");
const app = express();
export const routes = {
actor: "/author/:actor",
object: "/o/:id",
activity: "/a/:id",
inbox: "/author/:actor/inbox",
outbox: "/author/:actor/outbox",
followers: "/author/:actor/followers",
following: "/author/:actor/following",
liked: "/author/:actor/liked",
collections: "/author/:actor/c/:id",
blocked: "/author/:actor/blocked",
rejections: "/author/:actor/rejections",
rejected: "/author/:actor/rejected",
shares: "/a/:id/shares",
likes: "/a/:id/likes",
} as const;
const SITE = ActivitypubExpress({
name: process.env.BLOG_NAME,
version: "1.0.0",
domain: process.env.BLOG_DOMAIN,
actorParam: "actor",
objectParam: "id",
activityParam: "id",
routes,
endpoints: {
proxyUrl: "https://localhost/proxy",
},
});
app.use(
express.json({ type: SITE.consts.jsonldTypes }),
express.urlencoded({ extended: true }),
SITE,
);
app.route(routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post);
// app.route(routes.outbox).get(SITE.net.outbox.get).post(SITE.net.outbox.post);
app.get(routes.actor, SITE.net.actor.get);
app.get(routes.followers, SITE.net.followers.get);
app.get(routes.following, SITE.net.following.get);
app.get(routes.liked, SITE.net.liked.get);
app.get(routes.object, SITE.net.object.get);
app.get(routes.activity, SITE.net.activityStream.get);
app.get(routes.shares, SITE.net.shares.get);
app.get(routes.likes, SITE.net.likes.get);
app.get("/.well-known/webfinger", SITE.net.webfinger.get);
app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get);
app.get("/nodeinfo/:version", SITE.net.nodeInfo.get);
app.post("/proxy", SITE.net.proxy.post);
app.get(routes.outbox, async (req, res) => {
const nickname = req.params.actor;
const body = await getOutbox(nickname).then((out) => JSON.stringify(out, null, 4));
res.append("Content-Type", "application/activity+json");
res.send(body);
});
app.get("/:slug", (req, res) => {
});
app.get("/:slug.md", (req, res) => {
});
const SERVER = app.listen(port);

13
src/knexfile.ts Normal file
View File

@ -0,0 +1,13 @@
export default {
client: "sqlite3",
connection: {
filename: "./prod.sqlite3"
},
useNullAsDefault: true,
migrations: {
directory: "./migrations"
}
};

102
src/outbox.ts Normal file
View File

@ -0,0 +1,102 @@
import { readFileSync } from "node:fs";
import db from "./db.js";
import { z } from "zod";
import { getId as getUserId, getActor } from "./user.js";
import { routes } from "./index.js";
import { getById as getArticleById } from "./article.js";
export const zRawOutboxRecord = z.object({
id: z.number().min(0),
users_id: z.number().min(0),
verb: z.string().regex(/^[a-zA-Z]+$/).min(1),
articles_id: z.number().min(0),
created_at: z.date(),
updated_at: z.union([z.date(), z.null()]),
slug: z.optional(z.string()),
file: z.optional(z.string()),
title: z.optional(z.string())
});
export type RawOutboxRecord = z.infer<typeof zRawOutboxRecord>;
export const get = async (nickname: string): Promise<Record<string, any> | null> => {
const userId = await getUserId(nickname);
if (userId) {
const actor = getActor(nickname);
const ret = db<RawOutboxRecord>("outboxes")
.select("outboxes.*", "slug", "file", "title")
.join("articles", "outboxes.articles_id", "=", "articles.id")
.where("outboxes.users_id", userId)
.orderBy("created_at", "desc")
.then((recs: RawOutboxRecord[]) => {
const activities: Record<string, any>[] = recs.map((rec) => {
const published = typeof rec.created_at === "number" ? new Date(rec.created_at) : rec.created_at;
const canonicalUrl = `https://${process.env.blog_host}/${rec.slug}.html`;
const context = `https://${process.env.blog_host}/c/${rec.articles_id}`;
const followers = `https://${process.env.blog_host}${routes.followers.replace(":actor", nickname)}`;
const activity: Record<string, any> = {
id: `https://${process.env.blog_host}${routes.activity.replace(":id", rec.id.toString())}`,
"@context": "https://www.w3.org/ns/activitystreams",
type: rec.verb,
actor,
context,
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [followers],
published
};
const objectId = `https://${process.env.blog_host}${routes.object.replace(":id", rec.articles_id.toString())}`;
const content = readFileSync(rec.file as string, "utf-8");
activity.object = {
id: objectId,
actor,
attributedTo: actor,
type: "Article",
context,
content,
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [followers],
url: canonicalUrl,
mediaType: "text/markdown",
published
};
return activity;
});
const collection = {
type: "OrderedCollectionPage",
context: "https://www.w3.org/ns/activitystreams",
id: `https://${process.env.blog_host}${routes.outbox.replace(":actor", nickname)}`,
orderedItems: activities
};
return collection;
});
return ret;
}
else {
return null;
}
};
export const add = async (articleId: number): Promise<number> => {
const article = await getArticleById(articleId);
if (article) {
return await db("outboxes").insert({
users_id: article.id,
verb: "Create",
articles_id: articleId,
created_at: article.created_at
})
.returning("id")
.then(([{ id: id }]: { id: number }[]) => id)
}
else throw "failed to add to outbox";
};

53
src/user.ts Normal file
View File

@ -0,0 +1,53 @@
import db from "./db.js";
import { z } from "zod";
import { routes } from "./index.js";
export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
export const zUser = z.object({
id: z.number().min(0),
name: z.string().min(1),
nickname: z.string().regex(nicknameRegex),
bio: z.string(),
deleted: z.boolean(),
created_at: z.date(),
updated_at: z.union([z.date(), z.null()])
});
export type User = z.infer<typeof zUser>;
export const getActor = (nickname: string) =>
"https://"
+ process.env.blog_host
+ routes.actor.replace(":actor", nickname);
export const getNickname = async (userId: number): Promise<string | null> =>
db("users")
.select("nickname")
.where("id", userId)
.first()
.then((rec) => !!rec ? rec.nickname : null)
;
export const getId = async (nickname: string): Promise<number | null> =>
db("users")
.select("id")
.where("nickname", nickname)
.first()
.then((rec) => !!rec ? rec.id : null)
;
export const newUser = async (nickname: string, name: string, bio: string): Promise<number> => {
return db("users")
.insert({
name,
nickname,
bio,
deleted: false,
created_at: new Date()
})
.returning("id")
.then(([{ id: id }]: { id: number }[]) => id)
;
};

109
tsconfig.json Normal file
View File

@ -0,0 +1,109 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ESNext", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}