This commit is contained in:
sam 2023-07-22 17:54:32 +02:00
commit a2b7303da3
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
25 changed files with 3501 additions and 0 deletions

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

18
src/config.ts Normal file
View file

@ -0,0 +1,18 @@
import { config as dotenv } from "dotenv";
dotenv();
export const PORT = Number(process.env.PORT) || 3000;
export const DATABASE_HOST = process.env.DATABASE_HOST || "localhost";
export const DATABASE_PORT = Number(process.env.DATABASE_PORT) || 5432;
export const DATABASE_USER = process.env.DATABASE_USER || "postgres";
export const DATABASE_PASS = process.env.DATABASE_PASS || "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}`;

24
src/db/account.ts Normal file
View file

@ -0,0 +1,24 @@
import { hash } from "argon2";
import LongmontDataSource from "./index.js";
import { Account } from "./entities/account.js";
import generateSnowflake from "./util/snowflake.js";
/** Creates a new account and returns it. */
export async function createAccount(
username: string,
email: string,
password: string,
) {
const repo = LongmontDataSource.getRepository(Account);
const account = new Account();
account.username = username;
account.email = email;
account.password = await hash(password);
account.id = generateSnowflake().toString();
await repo.save(account);
return account;
}

View file

@ -0,0 +1,15 @@
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
@Entity()
@Index(["username"], { unique: true })
export class Account {
@PrimaryColumn("bigint")
id: string;
@Column("text", { nullable: false })
username: string;
@Column("text", { nullable: false, unique: true })
email: string;
@Column("text", { nullable: false })
password: string;
}

20
src/db/index.ts Normal file
View file

@ -0,0 +1,20 @@
import { DataSource } from "typeorm";
import * as config from "~/config.js";
// Entity types
import { Account } from "~entities/account.js";
const LongmontDataSource = new DataSource({
type: "postgres",
host: config.DATABASE_HOST,
port: config.DATABASE_PORT,
username: config.DATABASE_USER,
password: config.DATABASE_PASS,
database: config.DATABASE_NAME,
entities: [Account],
migrations: ["src/db/migrations/*.js"],
logging:
process.env.NODE_ENV === "production" ? ["error"] : ["query", "error"],
});
export default LongmontDataSource;

View file

@ -0,0 +1,28 @@
export class Init1690040643986 {
name = "Init1690040643986";
async up(queryRunner) {
await queryRunner.query(`
CREATE TABLE "account" (
"id" bigint NOT NULL,
"username" text NOT NULL,
"email" text NOT NULL,
"password" text NOT NULL,
CONSTRAINT "UQ_4c8f96ccf523e9a3faefd5bdd4c" UNIQUE ("email"),
CONSTRAINT "PK_54115ee388cdb6d86bb4bf5b2ea" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_41dfcb70af895ddf9a53094515" ON "account" ("username")
`);
}
async down(queryRunner) {
await queryRunner.query(`
DROP INDEX "public"."IDX_41dfcb70af895ddf9a53094515"
`);
await queryRunner.query(`
DROP TABLE "account"
`);
}
}

20
src/db/util/snowflake.ts Normal file
View file

@ -0,0 +1,20 @@
import { pid } from "node:process";
let increment = 0;
const EPOCH = 1_672_531_200_000n;
export default function generateSnowflake(): bigint {
const fakeThreadId = BigInt(Math.floor(Math.random() * 32));
const inc = BigInt(increment);
increment++;
const timestamp = BigInt(new Date().getTime()) - EPOCH;
return (
(timestamp << 22n) |
(BigInt(pid % 32) << 17n) |
(fakeThreadId << 12n) |
inc % 4096n
);
}

3
src/index.ts Normal file
View file

@ -0,0 +1,3 @@
import start from "./start.js";
start();

5
src/log.ts Normal file
View file

@ -0,0 +1,5 @@
import { Logger, ILogObj } from "tslog";
const log: Logger<ILogObj> = new Logger();
export default log;

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,42 @@
import type { RouteOptions } from "fastify";
import log from "~/log.js";
import LongmontDataSource from "~/db/index.js";
import { Account } from "~entities/account.js";
import { BASE_URL } from "~/config.js";
const route: RouteOptions = {
method: "GET",
url: "/.well-known/longmont/auth-lookup",
handler: async (req, res) => {
// TypeScript complains if we just use plain `req.query` :(
const encodedResource = (req.query as { acct: string }).acct;
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 auth lookup request for %s", resource);
const account = await LongmontDataSource.getRepository(Account).findOneBy({
username: resource,
});
if (!account) {
res.status(404).send({
error: "Account not found",
});
return;
}
res.send({
subject: account.username,
href: `${BASE_URL}/users/${account.id}`,
});
},
};
export default route;

View file

@ -0,0 +1,11 @@
import { RouteOptions } from "fastify";
const route: RouteOptions = {
method: "GET",
url: "/.well-known/longmont/auth-node",
handler: async (_, res) => {
res.status(204);
},
};
export default route;

34
src/start.ts Normal file
View file

@ -0,0 +1,34 @@
import "reflect-metadata"; // Required for TypeORM
import Fastify from "fastify";
import LongmontDataSource from "./db/index.js";
import log from "./log.js";
import { PORT } from "./config.js";
import getRoutes, { mountRoutes } from "./routes.js";
export default async function start() {
log.info("Initializing database");
await LongmontDataSource.initialize();
const pendingMigrations = await LongmontDataSource.showMigrations();
if (pendingMigrations) {
log.error(
"There are pending migrations, please run these with `pnpm migrate`.",
);
return;
}
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);
log.info("Listening on port %d", PORT);
await app.listen({ port: PORT });
}