init
This commit is contained in:
commit
a2b7303da3
25 changed files with 3501 additions and 0 deletions
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,
|
||||
},
|
||||
};
|
||||
}
|
18
src/config.ts
Normal file
18
src/config.ts
Normal 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
24
src/db/account.ts
Normal 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;
|
||||
}
|
15
src/db/entities/account.ts
Normal file
15
src/db/entities/account.ts
Normal 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
20
src/db/index.ts
Normal 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;
|
28
src/db/migrations/1690040643986-init.js
Normal file
28
src/db/migrations/1690040643986-init.js
Normal 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
20
src/db/util/snowflake.ts
Normal 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
3
src/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import start from "./start.js";
|
||||
|
||||
start();
|
5
src/log.ts
Normal file
5
src/log.ts
Normal 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
31
src/routes.ts
Normal 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);
|
||||
}
|
||||
}
|
42
src/routes/well-known/auth-lookup.ts
Normal file
42
src/routes/well-known/auth-lookup.ts
Normal 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;
|
11
src/routes/well-known/auth-node.ts
Normal file
11
src/routes/well-known/auth-node.ts
Normal 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
34
src/start.ts
Normal 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 });
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue