Moving a lot around for hono

This commit is contained in:
Bradley Shellnut 2024-07-21 12:05:48 -07:00
parent 388f9a399d
commit d70b3061b5
25 changed files with 999 additions and 59 deletions

View file

@ -77,8 +77,10 @@
},
"dependencies": {
"@fontsource/fira-mono": "^5.0.13",
"@hono/zod-validator": "^0.2.2",
"@iconify-icons/line-md": "^1.2.30",
"@iconify-icons/mdi": "^1.2.48",
"@internationalized/date": "^3.5.4",
"@lucia-auth/adapter-drizzle": "^1.0.7",
"@lukeed/uuid": "^2.0.1",
"@neondatabase/serverless": "^0.9.4",
@ -97,8 +99,11 @@
"drizzle-orm": "^0.32.0",
"feather-icons": "^4.29.2",
"formsnap": "^1.0.1",
"hono": "^4.5.0",
"hono-rate-limiter": "^0.4.0",
"html-entities": "^2.5.2",
"iconify-icon": "^2.1.0",
"ioredis": "^5.4.1",
"just-capitalize": "^3.2.0",
"just-kebab-case": "^4.2.0",
"loader": "^2.1.1",
@ -110,6 +115,8 @@
"postgres": "^3.4.4",
"qrcode": "^1.5.3",
"radix-svelte": "^0.9.0",
"rate-limit-redis": "^4.2.0",
"reflect-metadata": "^0.2.2",
"svelte-french-toast": "^1.2.0",
"svelte-lazy-loader": "^1.0.0",
"tailwind-merge": "^2.4.0",

File diff suppressed because it is too large Load diff

21
src/app.d.ts vendored
View file

@ -1,3 +1,7 @@
import { ApiClient } from './lib/server/api';
import type { User } from 'lucia';
import { parseApiResponse } from '$lib/utils/api';
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
@ -13,6 +17,10 @@ declare global {
};
}
interface Locals {
api: ApiClient['api'];
parseApiResponse: typeof parseApiResponse;
getAuthedUser: () => Promise<Returned<User> | null>;
getAuthedUserOrThrow: () => Promise<Returned<User>>;
auth: import('lucia').AuthRequest;
user: import('lucia').User | null;
session: import('lucia').Session | null;
@ -37,18 +45,5 @@ declare global {
}
}
// interface PageData {}
// interface Error {}
// interface Platform {}
// /// <reference types="lucia" />
// declare global {
// namespace Lucia {
// type Auth = import('$lib/server/lucia').Auth;
// type DatabaseUserAttributes = User;
// type DatabaseSessionAttributes = {};
// }
// }
// THIS IS IMPORTANT!!!
export {};

View file

@ -1,6 +1,7 @@
import { pgTable, primaryKey, uuid } from 'drizzle-orm/pg-core';
import categories from './categories';
import externalIds from './externalIds';
import { relations } from 'drizzle-orm';
const categoriesToExternalIds = pgTable(
'categories_to_external_ids',
@ -21,4 +22,18 @@ const categoriesToExternalIds = pgTable(
},
);
export const categoriesToExternalIdsRelations = relations(
categoriesToExternalIds,
({ one }) => ({
category: one(categories, {
fields: [categoriesToExternalIds.categoryId],
references: [categories.id],
}),
externalId: one(externalIds, {
fields: [categoriesToExternalIds.externalId],
references: [externalIds.id],
}),
}),
);
export default categoriesToExternalIds;

View file

@ -1,7 +1,7 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { createId as cuid2 } from '@paralleldrive/cuid2';
import { type InferSelectModel, relations } from 'drizzle-orm';
import users from './users';
import usersTable from './users.table';
import { timestamps } from '../utils';
const collections = pgTable('collections', {
@ -11,15 +11,15 @@ const collections = pgTable('collections', {
.$defaultFn(() => cuid2()),
user_id: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
.references(() => usersTable.id, { onDelete: 'cascade' }),
name: text('name').notNull().default('My Collection'),
...timestamps,
});
export const collection_relations = relations(collections, ({ one }) => ({
user: one(users, {
user: one(usersTable, {
fields: [collections.user_id],
references: [users.id],
references: [usersTable.id],
}),
}));

View file

@ -1,11 +1,11 @@
export { default as users, user_relations, type Users } from './users';
export { default as usersTable, userRelations as user_relations, type Users } from './users.table';
export { default as recoveryCodes, type RecoveryCodes } from './recoveryCodes';
export {
default as password_reset_tokens,
password_reset_token_relations,
type PasswordResetTokens,
} from './passwordResetTokens';
export { default as sessions, type Sessions } from './sessions';
export { default as sessionsTable, type Sessions } from './sessions.table';
export { default as roles, role_relations, type Roles } from './roles';
export { default as userRoles, user_role_relations, type UserRoles } from './userRoles';
export { default as collections, collection_relations, type Collections } from './collections';

View file

@ -1,7 +1,7 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { createId as cuid2 } from '@paralleldrive/cuid2';
import { type InferSelectModel, relations } from 'drizzle-orm';
import users from './users';
import usersTable from './users.table';
import { timestamps } from '../utils';
const password_reset_tokens = pgTable('password_reset_tokens', {
@ -10,7 +10,7 @@ const password_reset_tokens = pgTable('password_reset_tokens', {
.$defaultFn(() => cuid2()),
user_id: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
.references(() => usersTable.id, { onDelete: 'cascade' }),
expires_at: timestamp('expires_at'),
...timestamps,
});
@ -18,9 +18,9 @@ const password_reset_tokens = pgTable('password_reset_tokens', {
export type PasswordResetTokens = InferSelectModel<typeof password_reset_tokens>;
export const password_reset_token_relations = relations(password_reset_tokens, ({ one }) => ({
user: one(users, {
user: one(usersTable, {
fields: [password_reset_tokens.user_id],
references: [users.id],
references: [usersTable.id],
}),
}));

View file

@ -1,13 +1,13 @@
import { boolean, pgTable, text, uuid } from 'drizzle-orm/pg-core';
import type { InferSelectModel } from 'drizzle-orm';
import users from './users';
import usersTable from './users.table';
import { timestamps } from '../utils';
const recovery_codes = pgTable('recovery_codes', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id),
.references(() => usersTable.id),
code: text('code').notNull(),
used: boolean('used').default(false),
...timestamps,

View file

@ -1,12 +1,12 @@
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { type InferSelectModel } from 'drizzle-orm';
import users from './users';
import { relations, type InferSelectModel } from 'drizzle-orm';
import usersTable from './users.table';
const sessions = pgTable('sessions', {
const sessionsTable = pgTable('sessions', {
id: text('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id),
.references(() => usersTable.id),
expiresAt: timestamp('expires_at', {
withTimezone: true,
mode: 'date',
@ -17,6 +17,13 @@ const sessions = pgTable('sessions', {
isTwoFactorAuthenticated: boolean('is_two_factor_authenticated').default(false),
});
export type Sessions = InferSelectModel<typeof sessions>;
export const sessionsRelations = relations(sessionsTable, ({ one }) => ({
user: one(usersTable, {
fields: [sessionsTable.userId],
references: [usersTable.id],
})
}));
export default sessions;
export type Sessions = InferSelectModel<typeof sessionsTable>;
export default sessionsTable;

View file

@ -2,7 +2,7 @@ import { createId as cuid2 } from '@paralleldrive/cuid2';
import { type InferSelectModel, relations } from 'drizzle-orm';
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { timestamps } from '../utils';
import users from './users';
import usersTable from './users.table';
const twoFactorTable = pgTable('two_factor', {
id: uuid('id').primaryKey().defaultRandom(),
@ -17,15 +17,15 @@ const twoFactorTable = pgTable('two_factor', {
}),
userId: uuid('user_id')
.notNull()
.references(() => users.id)
.references(() => usersTable.id)
.unique(),
...timestamps,
});
export const emailVerificationsRelations = relations(twoFactorTable, ({ one }) => ({
user: one(users, {
user: one(usersTable, {
fields: [twoFactorTable.userId],
references: [users.id],
references: [usersTable.id],
}),
}));

View file

@ -1,7 +1,7 @@
import { boolean, pgTable, text, uuid } from 'drizzle-orm/pg-core';
import { createId as cuid2 } from '@paralleldrive/cuid2';
import { type InferSelectModel, relations } from 'drizzle-orm';
import users from './users';
import usersTable from './users.table';
import roles from './roles';
import { timestamps } from '../utils';
@ -12,7 +12,7 @@ const user_roles = pgTable('user_roles', {
.$defaultFn(() => cuid2()),
user_id: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
.references(() => usersTable.id, { onDelete: 'cascade' }),
role_id: uuid('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
@ -25,9 +25,9 @@ export const user_role_relations = relations(user_roles, ({ one }) => ({
fields: [user_roles.role_id],
references: [roles.id],
}),
user: one(users, {
user: one(usersTable, {
fields: [user_roles.user_id],
references: [users.id],
references: [usersTable.id],
}),
}));

View file

@ -4,7 +4,7 @@ import { type InferSelectModel, relations } from 'drizzle-orm';
import { timestamps } from '../utils';
import user_roles from './userRoles';
const users = pgTable('users', {
const usersTable = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
cuid: text('cuid')
.unique()
@ -20,10 +20,10 @@ const users = pgTable('users', {
...timestamps,
});
export const user_relations = relations(users, ({ many }) => ({
export const userRelations = relations(usersTable, ({ many }) => ({
user_roles: many(user_roles),
}));
export type Users = InferSelectModel<typeof users>;
export type Users = InferSelectModel<typeof usersTable>;
export default users;
export default usersTable;

View file

@ -1,7 +1,7 @@
import { pgTable, text, uuid } from 'drizzle-orm/pg-core';
import { createId as cuid2 } from '@paralleldrive/cuid2';
import { type InferSelectModel, relations } from 'drizzle-orm';
import users from './users';
import usersTable from './users.table';
import { timestamps } from '../utils';
const wishlists = pgTable('wishlists', {
@ -11,7 +11,7 @@ const wishlists = pgTable('wishlists', {
.$defaultFn(() => cuid2()),
user_id: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
.references(() => usersTable.id, { onDelete: 'cascade' }),
name: text('name').notNull().default('My Wishlist'),
...timestamps,
});
@ -19,9 +19,9 @@ const wishlists = pgTable('wishlists', {
export type Wishlists = InferSelectModel<typeof wishlists>;
export const wishlists_relations = relations(wishlists, ({ one }) => ({
user: one(users, {
user: one(usersTable, {
fields: [wishlists.user_id],
references: [users.id],
references: [usersTable.id],
}),
}));

View file

@ -1,8 +1,12 @@
// import * as Sentry from '@sentry/sveltekit';
import { hc } from 'hono/client';
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
import { redirect, type Handle } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { lucia } from '$lib/server/auth';
import type { ApiRoutes } from '$lib/server/api';
import { parseApiResponse } from '$lib/utils/api';
import { StatusCodes } from '$lib/constants/status-codes';
// TODO: Fix Sentry as it is not working on SvelteKit v2
// Sentry.init({
@ -12,6 +16,39 @@ import { lucia } from '$lib/server/auth';
// enabled: !dev
// });
const apiClient: Handle = async ({ event, resolve }) => {
/* ------------------------------ Register api ------------------------------ */
const { api } = hc<ApiRoutes>('/', {
fetch: event.fetch,
headers: {
'x-forwarded-for': event.getClientAddress(),
host: event.request.headers.get('host') || ''
}
});
/* ----------------------------- Auth functions ----------------------------- */
async function getAuthedUser() {
const { data } = await api.iam.user.$get().then(parseApiResponse)
return data && data.user;
}
async function getAuthedUserOrThrow() {
const { data } = await api.iam.user.$get().then(parseApiResponse);
if (!data || !data.user) throw redirect(StatusCodes.TEMPORARY_REDIRECT, '/');
return data?.user;
}
/* ------------------------------ Set contexts ------------------------------ */
event.locals.api = api;
event.locals.parseApiResponse = parseApiResponse;
event.locals.getAuthedUser = getAuthedUser;
event.locals.getAuthedUserOrThrow = getAuthedUserOrThrow;
/* ----------------------------- Return response ---------------------------- */
const response = await resolve(event);
return response;
};
export const authentication: Handle = async function ({ event, resolve }) {
event.locals.startTimer = Date.now();
@ -55,5 +92,6 @@ export const authentication: Handle = async function ({ event, resolve }) {
export const handle: Handle = sequence(
// Sentry.sentryHandle(),
authentication,
apiClient
);
// export const handleError = Sentry.handleErrorWithSentry();

View file

@ -0,0 +1,14 @@
import * as envs from '$env/static/private';
const isPreview = process.env.VERCEL_ENV === 'preview' || process.env.VERCEL_ENV === 'development';
let domain;
if (process.env.NODE_ENV === 'production' || process.env.VERCEL_ENV === 'production') {
domain = 'boredgame.vercel.app';
} else if (isPreview) {
domain = process.env.VERCEL_BRANCH_URL;
} else {
domain = 'localhost';
}
export const config = { ...envs, isProduction: process.env.NODE_ENV === 'production' || process.env.VERCEL_ENV === 'production', domain };

View file

@ -0,0 +1,26 @@
import { StatusCodes } from '$lib/constants/status-codes';
import { HTTPException } from 'hono/http-exception';
export function TooManyRequests(message: string = 'Too many requests') {
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message });
}
export function Forbidden(message: string = 'Forbidden') {
return new HTTPException(StatusCodes.FORBIDDEN, { message });
}
export function Unauthorized(message: string = 'Unauthorized') {
return new HTTPException(StatusCodes.UNAUTHORIZED, { message });
}
export function NotFound(message: string = 'Not Found') {
return new HTTPException(StatusCodes.NOT_FOUND, { message });
}
export function BadRequest(message: string = 'Bad Request') {
return new HTTPException(StatusCodes.BAD_REQUEST, { message });
}
export function InternalError(message: string = 'Internal Error') {
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message });
}

View file

@ -0,0 +1,10 @@
import { Hono } from 'hono';
import { requireAuth } from "../middleware/auth.middleware";
const users = new Hono().get('/me', requireAuth, async (c) => {
const user = c.var.user;
return c.json({ user });
});
export default users;
export type UsersType = typeof users

View file

@ -0,0 +1,23 @@
import { Hono } from 'hono';
import { hc } from 'hono/client';
import { validateAuthSession, verifyOrigin } from './middleware/auth.middleware';
import users from './controllers/iam.controller';
import { config } from './common/config';
/* ----------------------------------- Api ---------------------------------- */
const app = new Hono().basePath('/api');
/* --------------------------- Global Middlewares --------------------------- */
app.use(verifyOrigin).use(validateAuthSession);
/* --------------------------------- Routes --------------------------------- */
const routes = app
.route('/iam', users)
/* -------------------------------------------------------------------------- */
/* Exports */
/* -------------------------------------------------------------------------- */
export const rpc = hc<typeof routes>(config.ORIGIN);
export type ApiClient = typeof rpc;
export type ApiRoutes = typeof routes;
export { app };

View file

@ -0,0 +1,56 @@
// lib/server/lucia.ts
import { Lucia, TimeSpan } from 'lucia';
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
import db from '$db/index';
import { sessionsTable, usersTable } from '$db/schema';
import { config } from '../../common/config';
const adapter = new DrizzlePostgreSQLAdapter(db, sessionsTable, usersTable);
export const lucia = new Lucia(adapter, {
getSessionAttributes: (attributes) => {
return {
ipCountry: attributes.ip_country,
ipAddress: attributes.ip_address,
isTwoFactorAuthEnabled: attributes.twoFactorAuthEnabled,
isTwoFactorAuthenticated: attributes.isTwoFactorAuthenticated,
};
},
getUserAttributes: (attributes) => {
return {
...attributes,
};
},
sessionExpiresIn: new TimeSpan(30, 'd'), // 30 days
sessionCookie: {
name: 'session',
expires: false, // session cookies have very long lifespan (2 years)
attributes: {
// set to `true` when using HTTPS
secure: config.isProduction,
sameSite: 'strict',
domain: config.domain,
},
},
});
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
DatabaseSessionAttributes: DatabaseSessionAttributes;
}
interface DatabaseSessionAttributes {
ip_country: string;
ip_address: string;
twoFactorAuthEnabled: boolean;
isTwoFactorAuthenticated: boolean;
}
interface DatabaseUserAttributes {
username: string;
email: string;
firstName: string;
lastName: string;
theme: string;
}
}

View file

@ -0,0 +1,50 @@
import type { MiddlewareHandler } from 'hono';
import { createMiddleware } from 'hono/factory';
import type { HonoTypes } from '../types';
import { lucia } from '../infrastructure/auth/lucia';
import { verifyRequestOrigin } from 'lucia';
import type { Session, User } from 'lucia';
import { Unauthorized } from '../common/errors';
export const verifyOrigin: MiddlewareHandler<HonoTypes> = createMiddleware(async (c, next) => {
if (c.req.method === "GET") {
return next();
}
const originHeader = c.req.header("Origin") ?? null;
const hostHeader = c.req.header("Host") ?? null;
if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) {
return c.body(null, 403);
}
return next();
})
export const validateAuthSession: MiddlewareHandler<HonoTypes> = createMiddleware(async (c, next) => {
const sessionId = lucia.readSessionCookie(c.req.header("Cookie") ?? "");
if (!sessionId) {
c.set("user", null);
c.set("session", null);
return next();
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
c.header("Set-Cookie", lucia.createSessionCookie(session.id).serialize(), { append: true });
}
if (!session) {
c.header("Set-Cookie", lucia.createBlankSessionCookie().serialize(), { append: true });
}
c.set("session", session);
c.set("user", user);
return next();
})
export const requireAuth: MiddlewareHandler<{
Variables: {
session: Session;
user: User;
};
}> = createMiddleware(async (c, next) => {
const user = c.var.user;
if (!user) throw Unauthorized('You must be logged in to access this resource');
return next();
});

View file

@ -0,0 +1,32 @@
import { rateLimiter } from "hono-rate-limiter";
import { RedisStore } from 'rate-limit-redis'
import RedisClient from 'ioredis'
import type { HonoTypes } from "../types";
import { config } from "../common/config";
const client = new RedisClient(config.REDIS_URL)
export function limiter({ limit, minutes, key = "" }: {
limit: number;
minutes: number;
key?: string;
}) {
return rateLimiter({
windowMs: minutes * 60 * 1000, // every x minutes
limit, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
standardHeaders: "draft-6", // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header
keyGenerator: (c) => {
const vars = c.var as HonoTypes['Variables'];
const clientKey = vars.user?.id || c.req.header("x-forwarded-for");
const pathKey = key || c.req.routePath;
return `${clientKey}_${pathKey}`
}, // Method to generate custom identifiers for clients.
// Redis store configuration
store: new RedisStore({
// @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis
sendCommand: (...args: string[]) => client.call(...args),
}) as any,
})
}

View file

@ -0,0 +1,14 @@
import type { Promisify, RateLimitInfo } from 'hono-rate-limiter';
import type { Session, User } from 'lucia';
export type HonoTypes = {
Variables: {
session: Session | null;
user: User | null;
rateLimit: RateLimitInfo;
rateLimitStore: {
getKey?: (key: string) => Promisify<RateLimitInfo | undefined>;
resetKey: (key: string) => Promisify<void>;
};
};
};

View file

@ -2,9 +2,9 @@
import { Lucia, TimeSpan } from 'lucia';
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
import db from '../../db';
import { sessions, users } from '$db/schema';
import { sessionsTable, usersTable } from '$db/schema';
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
const adapter = new DrizzlePostgreSQLAdapter(db, sessionsTable, usersTable);
let domain;
if (process.env.NODE_ENV === 'production' || process.env.VERCEL_ENV === 'production') {

25
src/lib/utils/api.ts Normal file
View file

@ -0,0 +1,25 @@
import type { ClientResponse } from "hono/client";
export async function parseApiResponse<T>(response: ClientResponse<T>) {
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
return response.ok
? { data: null, error: null, response }
: { data: null, error: 'An unknown error has occured', response };
}
if (response.ok) {
const data = await response.json() as T;
return { data, error: null, status: response.status };
}
// handle errors
let error = await response.text();
try {
error = JSON.parse(error); // attempt to parse as JSON
} catch {
// noop
return { data: null, error, response };
}
return { data: null, error, response };
}

View file

@ -0,0 +1,9 @@
import { app } from '$lib/server/api';
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = ({ request }) => app.request(request);
export const PUT: RequestHandler = ({ request }) => app.request(request);
export const DELETE: RequestHandler = ({ request }) => app.fetch(request);
export const POST: RequestHandler = ({ request }) => app.fetch(request);
export const PATCH: RequestHandler = ({ request }) => app.fetch(request);
export const fallback: RequestHandler = ({ request }) => app.fetch(request);