diff --git a/README.md b/README.md index 5f3a755..70a6a59 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,15 @@ Consider all environment variables required. - Formatting: `pnpm format` - Linting (if you don't have an ESLint plugin): `pnpm format` +## Creating migrations + +Creating migrations is a little awkward because TypeORM expects `ts-node`, which uses `tsc`. +To create a migration, first run `pnpm build`, +then run `pnpm typeorm migration:generate -p -d ./dist/db/index.js ./src/db/migrations/`, +replacing `` with the name of the migration you're creating. +Then rename the created file to end in `.js` and remove the TypeScript-specific code from it. +(Yes, this is incredibly janky, but it works, and it only needs to be done once per migration, actually migrating works flawlessly) + ## License Mercury is licensed under the GNU Affero General Public License, version 3 **only**. diff --git a/package.json b/package.json index 9698dbb..0c941ea 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "watch:dev": "nodemon --watch \"dist/**/*\" -e js ./dist/index.js", "format": "prettier -w", "lint": "eslint src/", - "typeorm": "typeorm-ts-node-esm", - "migrate": "typeorm-ts-node-esm migration:run -d ./src/db/index.ts" + "typeorm": "typeorm", + "migrate": "pnpm build && typeorm migration:run -d ./dist/db/index.js" }, "dependencies": { "argon2": "^0.30.3", diff --git a/src/ap/blog.ts b/src/ap/blog.ts new file mode 100644 index 0000000..8a4f56f --- /dev/null +++ b/src/ap/blog.ts @@ -0,0 +1,20 @@ +import { BASE_URL } from "~/config.js"; +import { Blog } from "~/db/entities/blog.js"; + +/** Transforms the given Blog into an ActivityPub Person. It is the caller's responsibility to ensure the blog is local. */ +export function blogToActivityPub(blog: Blog) { + return { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Person", + id: `${BASE_URL}/blogs/${blog.username}`, + inbox: `${BASE_URL}/blogs/${blog.username}/inbox`, + outbox: `${BASE_URL}/blogs/${blog.username}/outbox`, + name: blog.username, + preferredUsername: blog.username, + publicKey: { + id: `${BASE_URL}/blogs/${blog.username}#main-key`, + owner: `${BASE_URL}/blogs/${blog.username}`, + publicKeyPem: blog.publicKey, + }, + }; +} diff --git a/src/db/blog.ts b/src/db/blog.ts index 64bc8ba..c23ccc6 100644 --- a/src/db/blog.ts +++ b/src/db/blog.ts @@ -5,11 +5,11 @@ import { Blog } from "~entities/blog.js"; import { generateKeyPair } from "./util/rsa.js"; import MercuryDataSource from "./index.js"; -export const notLocalAccount = new Error("account is not local"); +export const incorrectAccountType = new Error("wrong account type for function"); /** Create a local blog. Throws an error if the given account is not a local account. */ export async function createLocalBlog(account: Account, name: string) { - if (account.host) throw notLocalAccount; + if (account.host) throw incorrectAccountType; const keyPair = await generateKeyPair(); @@ -25,3 +25,19 @@ export async function createLocalBlog(account: Account, name: string) { return blog; } + +export async function createRemoteBlog(account: Account, name: string, apId: string, publicKey: string) { + if (!account.host) throw incorrectAccountType; + + const blog = new Blog(); + blog.id = ulid(); + blog.apId = apId; + blog.username = name; + blog.host = account.host; + blog.account = account; + blog.publicKey = publicKey; + + await MercuryDataSource.getRepository(Blog).save(blog); + + return blog; +} diff --git a/src/db/entities/blog.ts b/src/db/entities/blog.ts index e92f03f..daef6c0 100644 --- a/src/db/entities/blog.ts +++ b/src/db/entities/blog.ts @@ -14,6 +14,8 @@ import { Post } from "./post.js"; export class Blog { @PrimaryColumn("text") id: string; + @Column("text", { nullable: true, unique: true, comment: "ActivityPub ID" }) + apId: string | null; @Column("text", { nullable: false }) username: string; @Column("text", { nullable: true }) diff --git a/src/db/entities/post.ts b/src/db/entities/post.ts index 4cc665b..10a1e38 100644 --- a/src/db/entities/post.ts +++ b/src/db/entities/post.ts @@ -5,6 +5,8 @@ import { Blog } from "./blog.js"; export class Post { @PrimaryColumn("text") id: string; + @Column("text", { nullable: true, unique: true, comment: "ActivityPub ID" }) + apId: string | null; @Column("text", { nullable: true }) content: string | null; @Column("text", { nullable: true }) diff --git a/src/db/index.ts b/src/db/index.ts index 2e22177..5c202e3 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -15,6 +15,8 @@ const MercuryDataSource = new DataSource({ database: config.DATABASE_NAME, entities: [Account, Blog, Post], migrations: ["src/db/migrations/*.js"], + logging: + process.env.NODE_ENV === "production" ? ["error"] : ["query", "error"], }); export default MercuryDataSource; diff --git a/src/db/migrations/1689900517279-addApIds.js b/src/db/migrations/1689900517279-addApIds.js new file mode 100644 index 0000000..493a336 --- /dev/null +++ b/src/db/migrations/1689900517279-addApIds.js @@ -0,0 +1,50 @@ +export class AddApIds1689900517279 { + name = 'AddApIds1689900517279' + + async up(queryRunner) { + await queryRunner.query(` + ALTER TABLE "post" + ADD "apId" text + `); + await queryRunner.query(` + ALTER TABLE "post" + ADD CONSTRAINT "UQ_e16e967a725a0f3f681bf99bd6e" UNIQUE ("apId") + `); + await queryRunner.query(` + COMMENT ON COLUMN "post"."apId" IS 'ActivityPub ID' + `); + await queryRunner.query(` + ALTER TABLE "blog" + ADD "apId" text + `); + await queryRunner.query(` + ALTER TABLE "blog" + ADD CONSTRAINT "UQ_624066bd60ecf91ee390637d171" UNIQUE ("apId") + `); + await queryRunner.query(` + COMMENT ON COLUMN "blog"."apId" IS 'ActivityPub ID' + `); + } + + async down(queryRunner) { + await queryRunner.query(` + COMMENT ON COLUMN "blog"."apId" IS 'ActivityPub ID' + `); + await queryRunner.query(` + ALTER TABLE "blog" DROP CONSTRAINT "UQ_624066bd60ecf91ee390637d171" + `); + await queryRunner.query(` + ALTER TABLE "blog" DROP COLUMN "apId" + `); + await queryRunner.query(` + COMMENT ON COLUMN "post"."apId" IS 'ActivityPub ID' + `); + await queryRunner.query(` + ALTER TABLE "post" DROP CONSTRAINT "UQ_e16e967a725a0f3f681bf99bd6e" + `); + await queryRunner.query(` + ALTER TABLE "post" DROP COLUMN "apId" + `); + } + +} diff --git a/src/routes/ap/blog.ts b/src/routes/ap/blog.ts new file mode 100644 index 0000000..3dd13b0 --- /dev/null +++ b/src/routes/ap/blog.ts @@ -0,0 +1,31 @@ +import { RouteOptions } from "fastify"; + +import MercuryDataSource from "~/db/index.js"; +import { Blog } from "~/db/entities/blog.js"; +import { IsNull } from "typeorm"; +import { blogToActivityPub } from "~/ap/blog.js"; + +const route: RouteOptions = { + method: "GET", + url: "/blogs/:username", + handler: async (req, res) => { + const username = (req.params as { username: string }).username; + + // Only respond to ActivityPub requests + if (req.headers.accept && !req.headers.accept.indexOf("application/json")) { + res.redirect(303, `/@${username}`); + } + + const blog = await MercuryDataSource.getRepository(Blog).findOneBy({ + username, + host: IsNull(), + }); + if (!blog) { + return res.status(404).send({ message: "Not found" }); + } + + return res.send(blogToActivityPub(blog)); + }, +}; + +export default route; diff --git a/src/routes/nodeinfo/nodeinfo_2.0.ts b/src/routes/nodeinfo/nodeinfo_2.0.ts new file mode 100644 index 0000000..122a60b --- /dev/null +++ b/src/routes/nodeinfo/nodeinfo_2.0.ts @@ -0,0 +1,37 @@ +import { RouteOptions } from "fastify"; +import { IsNull } from "typeorm"; + +import MercuryDataSource from "~/db/index.js"; +import { Blog } from "~/db/entities/blog.js"; +import { Post } from "~/db/entities/post.js"; + +const route: RouteOptions = { + method: "GET", + url: "/nodeinfo/2.0", + handler: async (_, res) => { + const [userCount, postCount] = await Promise.all([ + MercuryDataSource.getRepository(Blog).countBy({ + host: IsNull(), + }), + MercuryDataSource.getRepository(Post).count({ + relations: { blog: true }, + where: { blog: { host: IsNull() } }, + }), + ]); + + res.send({ + version: "2.0", + software: { name: "mercury", version: "0.1.0-dev" }, + protocols: ["activitypub"], + openRegistrations: false, // TODO: get from database + usage: { + users: { + total: userCount, + }, + localPosts: postCount, + }, + }); + }, +}; + +export default route; diff --git a/src/routes/well-known/nodeinfo.ts b/src/routes/well-known/nodeinfo.ts new file mode 100644 index 0000000..9eeb672 --- /dev/null +++ b/src/routes/well-known/nodeinfo.ts @@ -0,0 +1,20 @@ +import { RouteOptions } from "fastify"; + +import { BASE_URL } from "~/config.js"; + +const route: RouteOptions = { + method: "GET", + url: "/.well-known/nodeinfo", + handler: async (_, res) => { + res.send({ + links: [ + { + href: `${BASE_URL}/nodeinfo/2.0`, + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", + }, + ], + }); + }, +}; + +export default route; diff --git a/src/start.ts b/src/start.ts index c20d5b5..d3da7fe 100644 --- a/src/start.ts +++ b/src/start.ts @@ -21,6 +21,11 @@ export default async function start() { log.debug("Setting up routes"); const app = Fastify(); + app.setNotFoundHandler((req, res) => { + log.debug("Route %s not found", req.url); + res.status(404).send({ message: "Not found" }); + }); + const routes = await getRoutes(); mountRoutes(app, routes); diff --git a/tsconfig.json b/tsconfig.json index 315db74..01f9e71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,6 @@ "~/*": ["./src/*"], "~entities/*": ["./src/db/entities/*"] } - } + }, + "exclude": ["./dist/"] }