feat: auth & db

This commit is contained in:
Derock 2023-11-01 21:23:18 -04:00
parent 8e2ad2174f
commit 5e63ffb18c
No known key found for this signature in database
16 changed files with 901 additions and 176 deletions

3
.gitignore vendored
View file

@ -9,8 +9,7 @@
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
/data/
# next.js
/.next/

BIN
bun.lockb

Binary file not shown.

10
drizzle.config.ts Normal file
View file

@ -0,0 +1,10 @@
import type { Config } from "drizzle-kit";
import { env } from "~/env.mjs";
export default {
schema: "./src/server/db/schema.ts",
driver: "better-sqlite",
dbCredentials: {
url: env.DATABASE_PATH,
},
} satisfies Config;

BIN
exts/sqlite-uuidv7.so Normal file

Binary file not shown.

View file

@ -12,7 +12,6 @@
"start": "next start"
},
"dependencies": {
"@prisma/client": "^5.1.1",
"@prisma/migrate": "^5.4.1",
"@t3-oss/env-nextjs": "^0.6.0",
"@tanstack/react-query": "^4.32.6",
@ -20,8 +19,10 @@
"@trpc/next": "^10.37.1",
"@trpc/react-query": "^10.37.1",
"@trpc/server": "^10.37.1",
"better-sqlite3": "^9.0.0",
"bunyan": "^1.8.15",
"bunyan-format": "^0.2.1",
"drizzle-orm": "^0.28.6",
"next": "^13.4.19",
"next-auth": "^4.23.2",
"react": "18.2.0",
@ -31,6 +32,7 @@
"zod": "^3.21.4"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.6",
"@types/bunyan": "^1.8.9",
"@types/bunyan-format": "^0.2.6",
"@types/eslint": "^8.44.2",
@ -40,12 +42,12 @@
"@typescript-eslint/eslint-plugin": "^6.3.0",
"@typescript-eslint/parser": "^6.3.0",
"autoprefixer": "^10.4.14",
"drizzle-kit": "^0.19.13",
"eslint": "^8.47.0",
"eslint-config-next": "^13.4.19",
"postcss": "^8.4.27",
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.5.1",
"prisma": "^5.1.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6"
},

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}

View file

@ -7,16 +7,13 @@ export const env = createEnv({
* isn't built with invalid env vars.
*/
server: {
DATABASE_URL: z
.string()
.url()
.refine(
(str) => !str.includes("YOUR_MYSQL_URL_HERE"),
"You forgot to change the default URL"
),
DATABASE_PATH: z.string().default("./data/db.sqlite"),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
SQLITE_UUIDV7_EXT_PATH: z.string().optional(),
},
/**
@ -33,9 +30,9 @@ export const env = createEnv({
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
DATABASE_PATH: process.env.DATABASE_PATH,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
SQLITE_UUIDV7_EXT_PATH: process.env.SQLITE_UUIDV7_EXT_PATH,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

View file

@ -4,9 +4,13 @@ const { version } = pacakge;
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const logger = (await import("./server/utils/logger")).default;
const { migrate } = await import("./server/db/migrate");
const { migrate } = await import("drizzle-orm/better-sqlite3/migrator");
const { db } = await import("./server/db");
logger.info(`🚀 Hostforge v${version} starting up`);
await migrate();
logger.child({ module: "database" }).info("⚙️ Migrating database");
migrate(db, { migrationsFolder: "./migrations" });
logger.child({ module: "database" }).info("✅ Database migrated");
logger.info(`🚀 Hostforge v${version} ready!`);
}
}

View file

@ -6,12 +6,13 @@
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
* need to use are documented accordingly near the end.
*/
import { initTRPC } from "@trpc/server";
import { TRPCError, initTRPC } from "@trpc/server";
import { type NextRequest } from "next/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { db } from "~/server/db";
import { Session } from "../auth/Session";
/**
* 1. CONTEXT
@ -20,9 +21,9 @@ import { db } from "~/server/db";
*
* These allow you to access things when processing a request, like the database, the session, etc.
*/
interface CreateContextOptions {
headers: Headers;
session: Session | null;
}
/**
@ -38,6 +39,7 @@ interface CreateContextOptions {
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
return {
headers: opts.headers,
session: opts.session,
db,
};
};
@ -48,11 +50,20 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
*
* @see https://trpc.io/docs/context
*/
export const createTRPCContext = (opts: { req: NextRequest }) => {
// Fetch stuff that depends on the request
export const createTRPCContext = async (opts: { req: NextRequest }) => {
// resolve session data
const sessionToken = opts.req.cookies.get("session")?.value;
const session: Session | null = sessionToken
? await Session.fetchFromTokenAndUpdate(sessionToken, {
ip: opts.req.ip,
ua: opts.req.headers.get("user-agent") ?? undefined,
})
: null;
return createInnerTRPCContext({
headers: opts.req.headers,
session,
});
};
@ -63,8 +74,7 @@ export const createTRPCContext = (opts: { req: NextRequest }) => {
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
export const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
@ -100,3 +110,26 @@ export const createTRPCRouter = t.router;
* are logged in.
*/
export const publicProcedure = t.procedure;
/**
* Authenticated procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It guarantees
* that a user querying is authorized, and you can access user session data.
*/
export const authenticatedProcedure = t.procedure.use(
t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action.",
});
}
return next({
ctx: {
session: ctx.session,
},
});
}),
);

View file

@ -0,0 +1,55 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import { users, sessions } from "../db/schema";
export class Session {
/**
* Fetch a session from a session token.
* @param token The session cookie
* @returns
*/
static async fetchFromToken(token: string) {
const [sessionData] = await db
.select()
.from(sessions)
.where(eq(sessions.token, token));
if (!sessionData) {
return null;
}
return new Session(sessionData);
}
/**
* Similar to fetchFromToken, but also updates the session's lastAccessed, lastIP, and lastUA fields.
* @param token The session cookie
* @param context The context of the request
* @returns
*/
static async fetchFromTokenAndUpdate(
token: string,
context: {
ip?: string;
ua?: string;
},
) {
const [sessionData] = await db
.update(sessions)
.set({ lastAccessed: new Date(), lastIP: context.ip, lastUA: context.ua })
.where(eq(sessions.token, token))
.returning();
if (!sessionData) {
return null;
}
return new Session(sessionData);
}
/**
* Create a new session instance from a user's session data.
* @param sessionData The user's session data.
*/
constructor(public readonly data: typeof sessions.$inferSelect) {}
}

View file

@ -1,16 +1,18 @@
import { PrismaClient } from "@prisma/client";
import { drizzle } from "drizzle-orm/better-sqlite3";
import SQLite3 from "better-sqlite3";
import { join } from "path";
import { env } from "~/env.mjs";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const sqlite = new SQLite3(env.DATABASE_PATH);
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log:
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
// enable WAL mode
sqlite.pragma("journal_mode = WAL");
if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;
// load uuidv7 extension
// built from https://github.com/craigpastro/sqlite-uuidv7
sqlite.loadExtension(
env.SQLITE_UUIDV7_EXT_PATH ??
join(__dirname, "../../../exts/sqlite-uuidv7.so"),
);
export const db = drizzle(sqlite);

View file

@ -1,78 +0,0 @@
import { Migrate } from "@prisma/migrate/dist/Migrate";
import rootLogger from "../utils/logger";
import { ensureDatabaseExists } from "@prisma/migrate/dist/utils/ensureDatabaseExists";
/**
* Runs database migrations. Code stolen from diced/zipline:
* https://github.com/diced/zipline/blob/d112c3a509fcc4c3f36906dca5ad02a74a4f423e/src/server/util.ts#L9
*/
export async function migrate() {
const logger = rootLogger.child({ module: "db::migrate" });
logger.info("Running database migrations");
try {
logger.debug("establishing database connection");
const migrate = new Migrate("./prisma/schema.prisma");
logger.debug(
"ensuring database exists, if not creating database - may error if no permissions",
);
await ensureDatabaseExists("apply", "./prisma/schema.prisma");
const diagnose = await migrate.diagnoseMigrationHistory({
optInToShadowDatabase: false,
});
if (diagnose.history?.diagnostic === "databaseIsBehind") {
if (!diagnose.hasMigrationsTable) {
logger.debug("no migrations table found, attempting schema push");
try {
logger.debug("pushing schema");
const migration = await migrate.push({ force: false });
if (migration.unexecutable && migration.unexecutable.length > 0)
throw new Error(
"This database is not empty, schema push is not possible.",
);
} catch (e) {
migrate.stop();
logger.error("failed to push schema");
throw e;
}
logger.debug("finished pushing schema, marking migrations as applied");
for (const migration of diagnose.history.unappliedMigrationNames) {
await migrate.markMigrationApplied({ migrationId: migration });
}
migrate.stop();
logger.info("finished migrating database");
} else if (diagnose.hasMigrationsTable) {
logger.debug("database is behind, attempting to migrate");
try {
logger.debug("migrating database");
await migrate.applyMigrations();
} catch (e) {
logger.error("failed to migrate database");
migrate.stop();
throw e;
}
migrate.stop();
logger.info("finished migrating database");
}
} else {
logger.info("exiting migrations engine - database is up to date");
migrate.stop();
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("P1001")) {
logger.error(
`Unable to connect to database \`${process.env.DATABASE_URL}\`, check your database connection`,
);
logger.debug(error);
} else {
logger.error("Failed to migrate database... exiting...");
logger.error(error);
}
process.nextTick(() => process.exit(1));
}
}

View file

@ -1,7 +0,0 @@
declare module "@prisma/migrate/dist/Migrate" {
export const Migrate: any;
}
declare module "@prisma/migrate/dist/utils/ensureDatabaseExists" {
export const ensureDatabaseExists: (...args: any[]) => Promise<void>;
}

77
src/server/db/schema.ts Normal file
View file

@ -0,0 +1,77 @@
import { sql, relations } from "drizzle-orm";
import {
text,
blob,
integer,
sqliteTable,
index,
} from "drizzle-orm/sqlite-core";
// util
const uuidv7 = sql`(uuid_generate_v7())`;
const now = sql`CURRENT_TIMESTAMP`;
/**
* User table.
* Represents a global user.
*/
export const users = sqliteTable(
"users",
{
id: text("id").default(uuidv7).primaryKey(),
username: text("username").unique().notNull(),
password: blob("password"), // raw hash
// user configuration
mfaToken: blob("mfa_token"), // raw hash
},
(table) => ({
usernameIdx: index("username_idx").on(table.username),
}),
);
export const userRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
mfaRequestSessions: many(MFARequestSessions),
}));
/**
* User Session table.
* Represents a user's session.
*/
export const sessions = sqliteTable("session", {
token: text("token").primaryKey(),
lastUA: text("last_useragent"),
lastIP: text("last_ip"),
// NOT IN MILLISECONDS!
lastAccessed: integer("last_accessed", { mode: "timestamp" }),
createdAt: integer("created_at").default(now),
userId: text("id"),
});
export const sessionRelations = relations(sessions, ({ one }) => ({
user: one(users, {
fields: [sessions.userId],
references: [users.id],
}),
}));
/**
* MFA Request Session.
* The intermediate between successful basic/oauth login and a full session for users with MFA enabled
*/
export const MFARequestSessions = sqliteTable("mfa_request_sessions", {
token: text("token").primaryKey(),
userId: text("user_id"),
createdAt: integer("created_at").default(now),
});
export const MFARequestSessionRelations = relations(
MFARequestSessions,
({ one }) => ({
user: one(users, {
fields: [MFARequestSessions.userId],
references: [users.id],
}),
}),
);