Compare commits
4 commits
8b40d7e042
...
37d414314e
Author | SHA1 | Date | |
---|---|---|---|
37d414314e | |||
0fc002b399 | |||
ca724dab9a | |||
40ad12c924 |
13 changed files with 200 additions and 5 deletions
|
@ -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/<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
|
||||
|
||||
Mercury is licensed under the GNU Affero General Public License, version 3 **only**.
|
||||
|
|
|
@ -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",
|
||||
|
|
20
src/ap/blog.ts
Normal file
20
src/ap/blog.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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;
|
||||
|
|
50
src/db/migrations/1689900517279-addApIds.js
Normal file
50
src/db/migrations/1689900517279-addApIds.js
Normal 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
31
src/routes/ap/blog.ts
Normal 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;
|
37
src/routes/nodeinfo/nodeinfo_2.0.ts
Normal file
37
src/routes/nodeinfo/nodeinfo_2.0.ts
Normal 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;
|
20
src/routes/well-known/nodeinfo.ts
Normal file
20
src/routes/well-known/nodeinfo.ts
Normal 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;
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -21,5 +21,6 @@
|
|||
"~/*": ["./src/*"],
|
||||
"~entities/*": ["./src/db/entities/*"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"exclude": ["./dist/"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue