Compare commits

..

4 commits

13 changed files with 200 additions and 5 deletions

View file

@ -16,6 +16,15 @@ Consider all environment variables required.
- Formatting: `pnpm format` - Formatting: `pnpm format`
- Linting (if you don't have an ESLint plugin): `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/<name>`,
replacing `<name>` 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 ## License
Mercury is licensed under the GNU Affero General Public License, version 3 **only**. Mercury is licensed under the GNU Affero General Public License, version 3 **only**.

View file

@ -14,8 +14,8 @@
"watch:dev": "nodemon --watch \"dist/**/*\" -e js ./dist/index.js", "watch:dev": "nodemon --watch \"dist/**/*\" -e js ./dist/index.js",
"format": "prettier -w", "format": "prettier -w",
"lint": "eslint src/", "lint": "eslint src/",
"typeorm": "typeorm-ts-node-esm", "typeorm": "typeorm",
"migrate": "typeorm-ts-node-esm migration:run -d ./src/db/index.ts" "migrate": "pnpm build && typeorm migration:run -d ./dist/db/index.js"
}, },
"dependencies": { "dependencies": {
"argon2": "^0.30.3", "argon2": "^0.30.3",

20
src/ap/blog.ts Normal file
View file

@ -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,
},
};
}

View file

@ -5,11 +5,11 @@ import { Blog } from "~entities/blog.js";
import { generateKeyPair } from "./util/rsa.js"; import { generateKeyPair } from "./util/rsa.js";
import MercuryDataSource from "./index.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. */ /** Create a local blog. Throws an error if the given account is not a local account. */
export async function createLocalBlog(account: Account, name: string) { export async function createLocalBlog(account: Account, name: string) {
if (account.host) throw notLocalAccount; if (account.host) throw incorrectAccountType;
const keyPair = await generateKeyPair(); const keyPair = await generateKeyPair();
@ -25,3 +25,19 @@ export async function createLocalBlog(account: Account, name: string) {
return blog; 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;
}

View file

@ -14,6 +14,8 @@ import { Post } from "./post.js";
export class Blog { export class Blog {
@PrimaryColumn("text") @PrimaryColumn("text")
id: string; id: string;
@Column("text", { nullable: true, unique: true, comment: "ActivityPub ID" })
apId: string | null;
@Column("text", { nullable: false }) @Column("text", { nullable: false })
username: string; username: string;
@Column("text", { nullable: true }) @Column("text", { nullable: true })

View file

@ -5,6 +5,8 @@ import { Blog } from "./blog.js";
export class Post { export class Post {
@PrimaryColumn("text") @PrimaryColumn("text")
id: string; id: string;
@Column("text", { nullable: true, unique: true, comment: "ActivityPub ID" })
apId: string | null;
@Column("text", { nullable: true }) @Column("text", { nullable: true })
content: string | null; content: string | null;
@Column("text", { nullable: true }) @Column("text", { nullable: true })

View file

@ -15,6 +15,8 @@ const MercuryDataSource = new DataSource({
database: config.DATABASE_NAME, database: config.DATABASE_NAME,
entities: [Account, Blog, Post], entities: [Account, Blog, Post],
migrations: ["src/db/migrations/*.js"], migrations: ["src/db/migrations/*.js"],
logging:
process.env.NODE_ENV === "production" ? ["error"] : ["query", "error"],
}); });
export default MercuryDataSource; export default MercuryDataSource;

View file

@ -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"
`);
}
}

31
src/routes/ap/blog.ts Normal file
View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -21,6 +21,11 @@ export default async function start() {
log.debug("Setting up routes"); log.debug("Setting up routes");
const app = Fastify(); 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(); const routes = await getRoutes();
mountRoutes(app, routes); mountRoutes(app, routes);

View file

@ -21,5 +21,6 @@
"~/*": ["./src/*"], "~/*": ["./src/*"],
"~entities/*": ["./src/db/entities/*"] "~entities/*": ["./src/db/entities/*"]
} }
} },
"exclude": ["./dist/"]
} }