Compare commits

...

2 commits

Author SHA1 Message Date
sam
42f48fb046
switch compiler to swc, add eslint 2023-07-21 01:27:24 +02:00
sam
9f74db9857
add webfinger and routes boilerplate 2023-07-21 01:08:06 +02:00
17 changed files with 1983 additions and 133 deletions

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
build/

20
.eslintrc.json Normal file
View file

@ -0,0 +1,20 @@
{
"env": {
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
}
}

2
.gitignore vendored
View file

@ -1,3 +1,3 @@
node_modules/ node_modules/
build/ dist/
.env .env

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
build/

19
.swcrc Normal file
View file

@ -0,0 +1,19 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false,
"decorators": true,
"dynamicImport": true
},
"target": "es2020",
"paths": {
"~/*": ["./src/*"],
"~entities/*": ["./src/db/entities/*"]
},
"baseUrl": "."
},
"module": {
"type": "es6"
}
}

View file

@ -1 +1,21 @@
# Mercury # Mercury
ActivityPub server implementation (hopefully, eventually),
currently mostly a way for me to play around with writing an API server in TypeScript.
## Configuration
For now, check out `src/config.ts`, the names should be mostly self explanatory.
Consider all environment variables required.
## Development commands
- Building the server: `pnpm build`
- Migrating the database: `pnpm migrate`
- Watching for changes and reloading the server automatically: `pnpm dev`
- Formatting: `pnpm format`
- Linting (if you don't have an ESLint plugin): `pnpm format`
## License
Mercury is licensed under the GNU Affero General Public License, version 3 **only**.

View file

@ -8,8 +8,12 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"dev": "nodemon ./src/index.ts", "dev": "concurrently \"pnpm watch:build\" \"pnpm watch:dev\"",
"build": "tsc", "build": "swc src -d dist",
"watch:build": "swc src -w --out-dir dist",
"watch:dev": "nodemon --watch \"dist/**/*\" -e js ./dist/index.js",
"format": "prettier -w",
"lint": "eslint src/",
"typeorm": "typeorm-ts-node-esm", "typeorm": "typeorm-ts-node-esm",
"migrate": "typeorm-ts-node-esm migration:run -d ./src/db/index.ts" "migrate": "typeorm-ts-node-esm migration:run -d ./src/db/index.ts"
}, },
@ -17,6 +21,8 @@
"argon2": "^0.30.3", "argon2": "^0.30.3",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"fastify": "^4.20.0",
"glob": "^10.3.3",
"pg": "^8.11.1", "pg": "^8.11.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"tslog": "^4.8.2", "tslog": "^4.8.2",
@ -24,10 +30,16 @@
"ulid": "^2.3.0" "ulid": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"@swc/cli": "^0.1.62",
"@swc/core": "^1.3.70",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"chokidar": "^3.5.3",
"concurrently": "^8.2.0",
"eslint": "^8.45.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"ts-node": "^10.9.1",
"typescript": "5.1.6" "typescript": "5.1.6"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -9,3 +9,10 @@ export const DATABASE_PORT = Number(process.env.DATABASE_PORT) || 5432;
export const DATABASE_USER = process.env.DATABASE_USER || "postgres"; export const DATABASE_USER = process.env.DATABASE_USER || "postgres";
export const DATABASE_PASS = process.env.DATABASE_PASS || "postgres"; export const DATABASE_PASS = process.env.DATABASE_PASS || "postgres";
export const DATABASE_NAME = process.env.DATABASE_NAME || "postgres"; export const DATABASE_NAME = process.env.DATABASE_NAME || "postgres";
export const HTTPS = process.env.HTTPS === "true";
export const DOMAIN = process.env.DOMAIN;
if (!DOMAIN) throw "$DOMAIN is empty";
export const BASE_URL = `${HTTPS ? "https" : "http"}://${DOMAIN}`;

View file

@ -1,7 +1,7 @@
import { ulid } from "ulid"; import { ulid } from "ulid";
import { hash } from "argon2"; import { hash } from "argon2";
import { Account } from "./entities/account.js"; import { Account } from "~entities/account.js";
import MercuryDataSource from "./index.js"; import MercuryDataSource from "./index.js";
const missingAuthData = new Error("missing auth data for local user"); const missingAuthData = new Error("missing auth data for local user");

View file

@ -1,7 +1,7 @@
import { ulid } from "ulid"; import { ulid } from "ulid";
import { Account } from "./entities/account.js"; import { Account } from "~entities/account.js";
import { Blog } from "./entities/blog.js"; 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";

View file

@ -1,10 +1,10 @@
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import * as config from "../config.js"; import * as config from "~/config.js";
// Entity types // Entity types
import { Account } from "./entities/account.js"; import { Account } from "~entities/account.js";
import { Blog } from "./entities/blog.js"; import { Blog } from "~entities/blog.js";
import { Post } from "./entities/post.js"; import { Post } from "~entities/post.js";
const MercuryDataSource = new DataSource({ const MercuryDataSource = new DataSource({
type: "postgres", type: "postgres",

View file

@ -1,7 +1,7 @@
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
import * as util from "node:util"; import { promisify }from "node:util";
const generate = util.promisify(crypto.generateKeyPair); const generate = promisify(crypto.generateKeyPair);
export async function generateKeyPair() { export async function generateKeyPair() {
return await generate("rsa", { return await generate("rsa", {

31
src/routes.ts Normal file
View file

@ -0,0 +1,31 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { glob } from "glob";
import type { FastifyInstance, RouteOptions } from "fastify";
import log from "./log.js";
export default async function getRoutes() {
const rootDir = dirname(fileURLToPath(import.meta.url));
const routes: RouteOptions[] = [];
const matches = await glob(join(rootDir, "/routes/**/*.{js,ts}"));
for (const filename of matches) {
try {
const mod = await import(filename);
routes.push(mod.default as RouteOptions);
} catch (e) {
log.error("Importing route %s", filename, e);
}
}
return routes;
}
export function mountRoutes(app: FastifyInstance, routes: RouteOptions[]) {
for (const route of routes) {
log.trace("Mounting route %s %s", route.method, route.url);
app.route(route);
}
}

View file

@ -0,0 +1,80 @@
import type { FastifyReply, FastifyRequest, RouteOptions } from "fastify";
import { IsNull } from "typeorm";
import log from "~/log.js";
import MercuryDataSource from "~/db/index.js";
import { Blog } from "~entities/blog.js";
import { BASE_URL } from "~/config.js";
const route: RouteOptions = {
method: "GET",
url: "/.well-known/webfinger",
handler: async (req, res) => {
// TypeScript complains if we just use plain `req.query` :(
const encodedResource = (req.query as { resource: string }).resource;
if (!encodedResource || typeof encodedResource !== "string") {
res.status(400).send({
error: "resource query parameter is missing or invalid",
});
return;
}
const resource = decodeURIComponent(encodedResource);
log.debug("Handling WebFinger request for %s", resource);
if (resource.startsWith("acct:")) {
await handleAcctWebfinger(req, res, resource);
return;
}
res.status(500).send({ error: "Unhandled WebFinger resource type" });
},
};
export default route;
async function handleAcctWebfinger(
req: FastifyRequest,
res: FastifyReply,
resource: string
): Promise<void> {
const [username, domain] = resource.slice("acct:".length).split("@");
if (domain !== process.env.DOMAIN) {
res.status(404).send({
error: "Account not found",
});
return;
}
const blog = await MercuryDataSource.getRepository(Blog).findOneBy({
username: username,
host: IsNull(),
});
if (!blog) {
res.status(404).send({
error: "Account not found",
});
return;
}
res.send({
subject: resource,
aliases: [
`${BASE_URL}/@${blog.username}`,
`${BASE_URL}/blogs/${blog.username}`,
],
links: [
{
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: `${BASE_URL}/@${blog.username}`,
},
{
rel: "self",
type: "application/activity+json",
href: `${BASE_URL}/blogs/${blog.username}`,
},
],
});
}

View file

@ -1,9 +1,10 @@
import "reflect-metadata"; // Required for TypeORM import "reflect-metadata"; // Required for TypeORM
import express from "express"; import Fastify from "fastify";
import MercuryDataSource from "./db/index.js"; import MercuryDataSource from "./db/index.js";
import log from "./log.js"; import log from "./log.js";
import { Blog } from "./db/entities/blog.js";
import { PORT } from "./config.js"; import { PORT } from "./config.js";
import getRoutes, { mountRoutes } from "./routes.js";
export default async function start() { export default async function start() {
log.info("Initializing database"); log.info("Initializing database");
@ -17,17 +18,12 @@ export default async function start() {
return; return;
} }
log.debug("Setting up routes") log.debug("Setting up routes");
const app = express(); const app = Fastify();
app.get("/", async (_, res) => { const routes = await getRoutes();
const blogRepository = MercuryDataSource.getRepository(Blog); mountRoutes(app, routes);
const resp = await blogRepository.find(); log.info("Listening on port %d", PORT);
await app.listen({ port: PORT });
return res.json(resp)
})
log.info("Listening on port %d", PORT)
app.listen(PORT);
} }

View file

@ -6,8 +6,7 @@
"rootDir": "./src", "rootDir": "./src",
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"declaration": true, "declaration": false,
"declarationMap": true,
"sourceMap": true, "sourceMap": true,
"outDir": "./build", "outDir": "./build",
"esModuleInterop": true, "esModuleInterop": true,
@ -17,5 +16,10 @@
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noImplicitAny": false, "noImplicitAny": false,
"baseUrl": "./",
"paths": {
"~/*": ["./src/*"],
"~entities/*": ["./src/db/entities/*"]
}
} }
} }