remixed v0

This commit is contained in:
m5r
2022-05-14 12:22:06 +02:00
parent 9275d4499b
commit 98b89ae0f7
338 changed files with 22549 additions and 44628 deletions

178
app/utils/auth.server.ts Normal file
View File

@ -0,0 +1,178 @@
import { type Session, redirect } from "@remix-run/node";
import type { FormStrategyVerifyParams } from "remix-auth-form";
import SecurePassword from "secure-password";
import type { MembershipRole, Organization, User } from "@prisma/client";
import db from "./db.server";
import logger from "./logger.server";
import authenticator from "./authenticator.server";
import { AuthenticationError } from "./errors";
import { commitSession, destroySession, getSession } from "./session.server";
export type SessionOrganization = Pick<Organization, "name" | "id"> & { role: MembershipRole };
export type SessionUser = Omit<User, "hashedPassword"> & {
organizations: SessionOrganization[];
};
const SP = new SecurePassword();
export async function login({ form }: FormStrategyVerifyParams): Promise<SessionUser> {
const email = form.get("email");
const password = form.get("password");
const isEmailValid = typeof email === "string" && email.length > 0;
const isPasswordValid = typeof password === "string" && password.length > 0;
if (!isEmailValid && !isPasswordValid) {
throw new AuthenticationError("Email and password are required");
}
if (!isEmailValid) {
throw new AuthenticationError("Email is required");
}
if (!isPasswordValid) {
throw new AuthenticationError("Password is required");
}
const user = await db.user.findUnique({
where: { email: email.toLowerCase() },
include: {
memberships: {
select: {
organization: {
select: { name: true, id: true },
},
role: true,
},
},
},
});
if (!user || !user.hashedPassword) {
logger.warn(`User with email=${email.toLowerCase()} not found`);
throw new AuthenticationError("Incorrect password");
}
switch (await verifyPassword(user.hashedPassword, password)) {
case SecurePassword.VALID:
break;
case SecurePassword.VALID_NEEDS_REHASH:
// Upgrade hashed password with a more secure hash
const improvedHash = await hashPassword(password);
await db.user.update({ where: { id: user.id }, data: { hashedPassword: improvedHash } });
break;
default:
logger.warn(`Tried to log into account with email=${email.toLowerCase()} with an incorrect password`);
throw new AuthenticationError("Incorrect password");
}
const { hashedPassword, memberships, ...rest } = user;
const organizations = memberships.map((membership) => ({
...membership.organization,
role: membership.role,
}));
return {
...rest,
organizations,
};
}
export async function verifyPassword(hashedPassword: string, password: string) {
try {
return await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"));
} catch (error) {
logger.error(error);
return false;
}
}
export async function hashPassword(password: string) {
const hashedBuffer = await SP.hash(Buffer.from(password));
return hashedBuffer.toString("base64");
}
type AuthenticateParams = {
email: string;
password: string;
request: Request;
successRedirect?: string | null;
failureRedirect?: string;
};
export async function authenticate({
email,
password,
request,
successRedirect,
failureRedirect = "/sign-in",
}: AuthenticateParams) {
const body = new URLSearchParams({ email, password });
const signInRequest = new Request(request.url, {
body,
method: "post",
headers: request.headers,
});
const user = await authenticator.authenticate("email-password", signInRequest, { failureRedirect });
const session = await getSession(request);
session.set(authenticator.sessionKey, user);
const redirectTo = successRedirect ?? "/messages";
return redirect(redirectTo, {
headers: { "Set-Cookie": await commitSession(session) },
});
}
export function getErrorMessage(session: Session) {
const authError = session.get(authenticator.sessionErrorKey || "auth:error");
return authError?.message;
}
export async function requireLoggedOut(request: Request) {
const user = await authenticator.isAuthenticated(request);
if (user) {
throw redirect("/messages");
}
}
export async function requireLoggedIn(request: Request) {
const user = await authenticator.isAuthenticated(request);
if (!user) {
throw redirect("/sign-in", {
headers: { "Set-Cookie": await destroySession(await getSession(request)) },
});
}
return user;
}
export async function refreshSessionData(request: Request) {
const { id } = await requireLoggedIn(request);
const user = await db.user.findUnique({
where: { id },
include: {
memberships: {
select: {
organization: {
select: { name: true, id: true },
},
role: true,
},
},
},
});
if (!user || !user.hashedPassword) {
logger.warn(`User with id=${id} not found`);
throw new AuthenticationError("Could not refresh session, user does not exist");
}
const { hashedPassword, memberships, ...rest } = user;
const organizations = memberships.map((membership) => ({
...membership.organization,
role: membership.role,
}));
const sessionUser: SessionUser = {
...rest,
organizations,
};
const session = await getSession(request);
session.set(authenticator.sessionKey, sessionUser);
return { session, user: sessionUser };
}

View File

@ -0,0 +1,11 @@
import { Authenticator } from "remix-auth";
import { FormStrategy } from "remix-auth-form";
import { sessionStorage } from "./session.server";
import { type SessionUser, login } from "./auth.server";
const authenticator = new Authenticator<SessionUser>(sessionStorage);
authenticator.use(new FormStrategy(login), "email-password");
export default authenticator;

23
app/utils/db.server.ts Normal file
View File

@ -0,0 +1,23 @@
import { PrismaClient } from "@prisma/client";
let db: PrismaClient;
declare global {
var __db: PrismaClient | undefined;
}
// this is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the DB with every change either.
if (process.env.NODE_ENV === "production") {
db = new PrismaClient();
db.$connect();
} else {
if (!global.__db) {
global.__db = new PrismaClient();
global.__db.$connect();
}
db = global.__db;
}
export default db;

22
app/utils/errors.ts Normal file
View File

@ -0,0 +1,22 @@
export class AuthenticationError extends Error {
name = "AuthenticationError";
statusCode = 401;
constructor(message = "You must be logged in to access this") {
super(message);
}
}
export class ResetPasswordError extends Error {
name = "ResetPasswordError";
message = "Reset password link is invalid or it has expired.";
}
export class NotFoundError extends Error {
name = "NotFoundError";
statusCode = 404;
constructor(message = "This could not be found") {
super(message);
}
}

View File

@ -0,0 +1,6 @@
// TODO: s/\.server// when https://github.com/fullstack-build/tslog/issues/88 gets implemented
import { Logger } from "tslog";
const logger = new Logger();
export default logger;

View File

@ -0,0 +1,39 @@
import { type SendMailOptions, createTransport } from "nodemailer";
import { Credentials, SES } from "aws-sdk";
import previewEmail from "preview-email";
import serverConfig from "~/config/config.server";
type SendEmailParams = {
text?: string;
html?: string;
subject: string;
recipients: string | string[];
};
export default async function sendEmail({ text, html, subject, recipients }: SendEmailParams) {
const email: SendMailOptions = {
text,
html,
subject,
encoding: "UTF-8",
to: recipients,
from: serverConfig.awsSes.fromEmail,
};
if (process.env.NODE_ENV !== "production" || process.env.CI) {
return previewEmail(email);
}
const transporter = createTransport({
SES: new SES({
region: serverConfig.awsSes.awsRegion,
credentials: new Credentials({
accessKeyId: serverConfig.awsSes.accessKeyId,
secretAccessKey: serverConfig.awsSes.secretAccessKey,
}),
}),
});
return transporter.sendMail(email);
}

View File

@ -0,0 +1,24 @@
import type { Organization } from "@prisma/client";
import db from "~/utils/db.server";
export async function deleteOrganizationEntities(organization: Organization) {
// await cancelSubscription(organization.stripeSubscriptionId);
// delete user accounts who were only in this one organization
await db.user.deleteMany({
where: {
memberships: {
every: { organizationId: organization.id },
},
},
});
await db.membership.deleteMany({
where: {
organizationId: organization.id,
},
});
await db.organization.delete({ where: { id: organization.id } });
}

64
app/utils/queue.server.ts Normal file
View File

@ -0,0 +1,64 @@
import { type Processor, type JobsOptions, Queue as BullQueue, Worker, QueueScheduler } from "bullmq";
import redis from "./redis.server";
import logger from "./logger.server";
type RegisteredQueue = {
queue: BullQueue;
worker: Worker;
scheduler: QueueScheduler;
};
declare global {
var __registeredQueues: Map<string, RegisteredQueue> | undefined;
}
const registeredQueues = global.__registeredQueues || (global.__registeredQueues = new Map<string, RegisteredQueue>());
export function Queue<Payload>(
name: string,
handler: Processor<Payload>,
defaultJobOptions: JobsOptions = {},
): BullQueue<Payload> {
if (registeredQueues.has(name)) {
return registeredQueues.get(name)!.queue;
}
const jobOptions: JobsOptions = {
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
...defaultJobOptions,
};
const queue = new BullQueue<Payload>(name, {
defaultJobOptions: jobOptions,
connection: redis,
});
const worker = new Worker<Payload>(name, handler, { connection: redis });
const scheduler = new QueueScheduler(name, { connection: redis });
registeredQueues.set(name, { queue, worker, scheduler });
return queue;
}
export function CronJob(
name: string,
handler: Processor<undefined>,
cronSchedule: string,
defaultJobOptions: Exclude<JobsOptions, "repeat"> = {},
) {
const jobOptions: JobsOptions = {
...defaultJobOptions,
repeat: { cron: cronSchedule },
};
return function register() {
if (registeredQueues.has(name)) {
return registeredQueues.get(name)!.queue;
}
const queue = Queue<undefined>(name, handler, jobOptions);
queue.add(name, undefined, jobOptions);
logger.info(`registered cron job "${name}"`);
return queue;
};
}

31
app/utils/redis.server.ts Normal file
View File

@ -0,0 +1,31 @@
import Redis, { type Redis as RedisType, type RedisOptions } from "ioredis";
import serverConfig from "~/config/config.server";
let redis: RedisType;
declare global {
var __redis: RedisType | undefined;
}
const redisOptions: RedisOptions = {
maxRetriesPerRequest: null,
enableReadyCheck: false,
password: serverConfig.redis.password,
family: 6, // Fly servers use IPv6 only
port: 6379,
};
// this is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the Redis with every change either.
if (process.env.NODE_ENV === "production") {
redis = new Redis(serverConfig.redis.url, redisOptions);
} else {
if (!global.__redis) {
global.__redis = new Redis(serverConfig.redis.url, redisOptions);
}
redis = global.__redis;
}
export default redis;

8
app/utils/seo.ts Normal file
View File

@ -0,0 +1,8 @@
import { initSeo } from "remix-seo";
export const { getSeo, getSeoMeta, getSeoLinks } = initSeo({
title: "",
titleTemplate: "%s | Shellphone",
description: "",
defaultTitle: "Shellphone",
});

View File

@ -0,0 +1,92 @@
import { type Session, type SessionIdStorageStrategy, createSessionStorage } from "@remix-run/node";
import serverConfig from "~/config/config.server";
import db from "./db.server";
import logger from "./logger.server";
const SECOND = 1;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
export const sessionStorage = createDatabaseSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [serverConfig.app.sessionSecret],
secure: process.env.NODE_ENV === "production" && process.env.CI !== "true",
maxAge: 30 * DAY,
},
});
export function getSession(request: Request): Promise<Session> {
return sessionStorage.getSession(request.headers.get("Cookie"));
}
export const { commitSession, destroySession, getSession: __getSession } = sessionStorage;
function createDatabaseSessionStorage({ cookie }: Pick<SessionIdStorageStrategy, "cookie">) {
return createSessionStorage({
cookie,
async createData(sessionData, expiresAt) {
let user;
if (sessionData.user) {
user = { connect: { id: sessionData.user.id } };
}
const { id } = await db.session.create({
data: {
expiresAt,
user,
data: JSON.stringify(sessionData),
},
});
return id;
},
async readData(id) {
const session = await db.session.findUnique({ where: { id } });
if (!session) {
return null;
}
const sessionHasExpired = session.expiresAt && session.expiresAt < new Date();
if (sessionHasExpired) {
await db.session.delete({ where: { id } });
return null;
}
return JSON.parse(session.data);
},
async updateData(id, sessionData, expiresAt) {
try {
await db.session.update({
where: { id },
data: {
data: JSON.stringify(sessionData),
expiresAt,
},
});
} catch (error: any) {
if (error.code === "P2025") {
logger.warn("Could not update session because it's not in the DB");
return;
}
throw error;
}
},
async deleteData(id) {
try {
await db.session.delete({ where: { id } });
} catch (error: any) {
if (error.code === "P2025") {
logger.warn("Could not delete session because it's not in the DB");
return;
}
throw error;
}
},
});
}

10
app/utils/token.server.ts Normal file
View File

@ -0,0 +1,10 @@
import { nanoid } from "nanoid";
import crypto from "crypto";
export function generateToken() {
return nanoid(32);
}
export function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}

View File

@ -0,0 +1,34 @@
import type { z } from "zod";
type ErrorMessage = string;
type Errors<Schema> = Partial<Record<keyof Schema, ErrorMessage>>;
export type FormError<Schema extends z.Schema<unknown>> = Partial<
Record<keyof Schema["_type"] | "general", ErrorMessage>
>;
type ValidationResult<Data, Schema> = { data: Data; errors: undefined } | { data: undefined; errors: Errors<Schema> };
export function validate<Data, Schema = z.Schema<Data>["_type"]>(
schema: z.Schema<Data>,
value: unknown,
): ValidationResult<Data, Schema> {
const result = schema.safeParse(value);
if (result.success) {
return {
data: result.data,
errors: undefined,
};
}
const errors: Errors<Schema> = {};
result.error.issues.forEach((error) => {
const path = error.path[0] as keyof Schema;
if (!errors[path]) {
errors[path] = error.message;
}
});
return {
data: undefined,
errors,
};
}