make app usable without account, remove extra stuff
This commit is contained in:
@ -1,204 +0,0 @@
|
||||
import { redirect, type Session } from "@remix-run/node";
|
||||
import type { FormStrategyVerifyParams } from "remix-auth-form";
|
||||
import SecurePassword from "secure-password";
|
||||
import type { MembershipRole, Organization, TwilioAccount, User } from "@prisma/client";
|
||||
|
||||
import db from "./db.server";
|
||||
import logger from "./logger.server";
|
||||
import authenticator from "./authenticator.server";
|
||||
import { AuthenticationError, NotFoundError } from "./errors";
|
||||
import { commitSession, destroySession, getSession } from "./session.server";
|
||||
|
||||
type SessionTwilioAccount = Pick<TwilioAccount, "accountSid" | "authToken">;
|
||||
type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole; membershipId: string };
|
||||
export type SessionUser = Pick<User, "id" | "role" | "email" | "fullName">;
|
||||
export type SessionData = {
|
||||
user: SessionUser;
|
||||
organization: SessionOrganization;
|
||||
twilio: SessionTwilioAccount | null;
|
||||
};
|
||||
|
||||
const SP = new SecurePassword();
|
||||
|
||||
export async function login({ form }: FormStrategyVerifyParams): Promise<SessionData> {
|
||||
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() } });
|
||||
if (!user || !user.hashedPassword) {
|
||||
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");
|
||||
}
|
||||
|
||||
try {
|
||||
return await buildSessionData(user.id);
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AuthenticationError("Incorrect password");
|
||||
}
|
||||
}
|
||||
|
||||
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 sessionData = await authenticator.authenticate("email-password", signInRequest, { failureRedirect });
|
||||
const session = await getSession(request);
|
||||
session.set(authenticator.sessionKey, sessionData);
|
||||
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) {
|
||||
const signInUrl = "/sign-in";
|
||||
const redirectTo = buildRedirectTo(new URL(request.url));
|
||||
const searchParams = new URLSearchParams({ redirectTo });
|
||||
|
||||
throw redirect(`${signInUrl}?${searchParams.toString()}`, {
|
||||
headers: { "Set-Cookie": await destroySession(await getSession(request)) },
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function buildRedirectTo(url: URL) {
|
||||
let redirectTo = url.pathname;
|
||||
const searchParams = url.searchParams.toString();
|
||||
if (searchParams.length > 0) {
|
||||
redirectTo += `?${searchParams}`;
|
||||
}
|
||||
|
||||
return encodeURIComponent(redirectTo);
|
||||
}
|
||||
|
||||
export async function refreshSessionData(request: Request) {
|
||||
const {
|
||||
user: { id },
|
||||
} = await requireLoggedIn(request);
|
||||
const user = await db.user.findUnique({ where: { id } });
|
||||
if (!user || !user.hashedPassword) {
|
||||
logger.warn(`User with id=${id} not found`);
|
||||
throw new AuthenticationError("Could not refresh session, user does not exist");
|
||||
}
|
||||
|
||||
const sessionData = await buildSessionData(id);
|
||||
const session = await getSession(request);
|
||||
session.set(authenticator.sessionKey, sessionData);
|
||||
|
||||
return { session, sessionData: sessionData };
|
||||
}
|
||||
|
||||
async function buildSessionData(id: string): Promise<SessionData> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
memberships: {
|
||||
select: {
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
twilioAccount: {
|
||||
select: { accountSid: true, authToken: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
role: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
logger.warn(`User with id=${id} not found`);
|
||||
throw new NotFoundError(`User with id=${id} not found`);
|
||||
}
|
||||
|
||||
const { hashedPassword, memberships, ...rest } = user;
|
||||
const organizations = memberships.map((membership) => ({
|
||||
...membership.organization,
|
||||
role: membership.role,
|
||||
membershipId: membership.id,
|
||||
}));
|
||||
const { twilioAccount, ...organization } = organizations[0];
|
||||
return {
|
||||
user: rest,
|
||||
organization,
|
||||
twilio: twilioAccount,
|
||||
};
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import { Authenticator } from "remix-auth";
|
||||
import { FormStrategy } from "remix-auth-form";
|
||||
|
||||
import { sessionStorage } from "./session.server";
|
||||
import { type SessionData, login } from "./auth.server";
|
||||
|
||||
const authenticator = new Authenticator<SessionData>(sessionStorage);
|
||||
|
||||
authenticator.use(new FormStrategy(login), "email-password");
|
||||
|
||||
export default authenticator;
|
@ -1,95 +0,0 @@
|
||||
import { spawn } from "child_process";
|
||||
import { PassThrough } from "stream";
|
||||
|
||||
import logger from "~/utils/logger.server";
|
||||
import config from "~/config/config.server";
|
||||
import { Credentials, S3 } from "aws-sdk";
|
||||
import sendEmail from "~/utils/mailer.server";
|
||||
|
||||
const credentials = new Credentials({
|
||||
accessKeyId: config.aws.s3.accessKeyId,
|
||||
secretAccessKey: config.aws.s3.secretAccessKey,
|
||||
});
|
||||
|
||||
export const s3 = new S3({ region: config.aws.region, credentials });
|
||||
|
||||
export default async function backup(schedule: "daily" | "weekly" | "monthly") {
|
||||
const s3Bucket = "shellphone-backups";
|
||||
const { database, host, port, user, password } = parseDatabaseUrl(process.env.DATABASE_URL!);
|
||||
const fileName = `${schedule}-${database}.sql.gz`;
|
||||
|
||||
console.log(`Dumping database ${database}`);
|
||||
const pgDumpChild = spawn("pg_dump", [`-U${user}`, `-d${database}`], {
|
||||
env: {
|
||||
...process.env,
|
||||
PGPASSWORD: password,
|
||||
PGHOST: host,
|
||||
PGPORT: port.toString(),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
|
||||
console.log(`Compressing dump "${fileName}"`);
|
||||
const gzippedDumpStream = new PassThrough();
|
||||
const gzipChild = spawn("gzip", { stdio: ["pipe", "pipe", "inherit"] });
|
||||
gzipChild.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
return sendEmail({
|
||||
text: `${schedule} backup failed: gzip: Bad exit code (${code})`,
|
||||
html: `${schedule} backup failed: gzip: Bad exit code (${code})`,
|
||||
subject: `${schedule} backup failed: gzip: Bad exit code (${code})`,
|
||||
recipients: ["error@shellphone.app"],
|
||||
});
|
||||
}
|
||||
});
|
||||
pgDumpChild.stdout.pipe(gzipChild.stdin);
|
||||
gzipChild.stdout.pipe(gzippedDumpStream);
|
||||
|
||||
pgDumpChild.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
console.log("pg_dump failed, upload aborted");
|
||||
return sendEmail({
|
||||
text: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
|
||||
html: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
|
||||
subject: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
|
||||
recipients: ["error@shellphone.app"],
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Uploading "${fileName}" to S3 bucket "${s3Bucket}"`);
|
||||
const uploadPromise = s3
|
||||
.upload({
|
||||
Bucket: s3Bucket,
|
||||
Key: fileName,
|
||||
ACL: "private",
|
||||
ContentType: "text/plain",
|
||||
ContentEncoding: "gzip",
|
||||
Body: gzippedDumpStream,
|
||||
})
|
||||
.promise();
|
||||
|
||||
uploadPromise
|
||||
.then(() => console.log(`Successfully uploaded "${fileName}"`))
|
||||
.catch((error) => {
|
||||
logger.error(error);
|
||||
return sendEmail({
|
||||
text: `${schedule} backup failed: ${error}`,
|
||||
html: `${schedule} backup failed: ${error}`,
|
||||
subject: `${schedule} backup failed: ${error}`,
|
||||
recipients: ["error@shellphone.app"],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseDatabaseUrl(databaseUrl: string) {
|
||||
const url = new URL(databaseUrl);
|
||||
|
||||
return {
|
||||
user: url.username,
|
||||
password: url.password,
|
||||
host: url.host,
|
||||
port: Number.parseInt(url.port),
|
||||
database: url.pathname.replace(/^\//, "").replace(/\/$/, ""),
|
||||
} as const;
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import config from "~/config/config.server";
|
||||
|
||||
const { webhookId, webhookToken } = config.discord;
|
||||
|
||||
export function executeWebhook(email: string) {
|
||||
const url = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`;
|
||||
return fetch(url, {
|
||||
body: JSON.stringify({ content: `\`${email}\` just joined Shellphone's waitlist` }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "post",
|
||||
});
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import config from "~/config/config.server";
|
||||
|
||||
export async function addSubscriber(email: string) {
|
||||
const { apiKey, audienceId } = config.mailchimp;
|
||||
const region = apiKey.split("-")[1];
|
||||
const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`;
|
||||
const data = {
|
||||
email_address: email,
|
||||
status: "subscribed",
|
||||
};
|
||||
const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${base64ApiKey}`,
|
||||
};
|
||||
|
||||
return fetch(url, {
|
||||
body: JSON.stringify(data),
|
||||
headers,
|
||||
method: "post",
|
||||
});
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
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.aws.ses.fromEmail,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV !== "production" || process.env.CI) {
|
||||
return previewEmail(email);
|
||||
}
|
||||
|
||||
const transporter = createTransport({
|
||||
SES: new SES({
|
||||
region: serverConfig.aws.region,
|
||||
credentials: new Credentials({
|
||||
accessKeyId: serverConfig.aws.ses.accessKeyId,
|
||||
secretAccessKey: serverConfig.aws.ses.secretAccessKey,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
return transporter.sendMail(email);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
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 } });
|
||||
}
|
@ -84,26 +84,3 @@ export function Queue<Payload>(
|
||||
|
||||
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.debug(`registered cron job "${name}"`);
|
||||
return queue;
|
||||
};
|
||||
}
|
||||
|
@ -1,17 +1,21 @@
|
||||
import { type Session, type SessionIdStorageStrategy, createSessionStorage } from "@remix-run/node";
|
||||
import type { TwilioAccount } from "@prisma/client";
|
||||
|
||||
import serverConfig from "~/config/config.server";
|
||||
import db from "./db.server";
|
||||
import logger from "./logger.server";
|
||||
import authenticator from "~/utils/authenticator.server";
|
||||
import type { SessionData } from "~/utils/auth.server";
|
||||
|
||||
type SessionTwilioAccount = Pick<TwilioAccount, "accountSid" | "authToken">;
|
||||
export type SessionData = {
|
||||
twilio?: SessionTwilioAccount | null;
|
||||
};
|
||||
|
||||
const SECOND = 1;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
export const sessionStorage = createDatabaseSessionStorage({
|
||||
const sessionStorage = createDatabaseSessionStorage<SessionData>({
|
||||
cookie: {
|
||||
name: "__session",
|
||||
httpOnly: true,
|
||||
@ -23,25 +27,26 @@ export const sessionStorage = createDatabaseSessionStorage({
|
||||
},
|
||||
});
|
||||
|
||||
export function getSession(request: Request): Promise<Session> {
|
||||
export function getSession(request: Request): Promise<Session<SessionData>> {
|
||||
return sessionStorage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export const { commitSession, destroySession, getSession: __getSession } = sessionStorage;
|
||||
export const { commitSession } = sessionStorage;
|
||||
|
||||
function createDatabaseSessionStorage({ cookie }: Pick<SessionIdStorageStrategy, "cookie">) {
|
||||
return createSessionStorage({
|
||||
function createDatabaseSessionStorage<Data extends SessionData = SessionData>({
|
||||
cookie,
|
||||
}: Pick<SessionIdStorageStrategy, "cookie">) {
|
||||
return createSessionStorage<Data>({
|
||||
cookie,
|
||||
async createData(sessionData, expiresAt) {
|
||||
let user;
|
||||
const sessionAuthData: SessionData = sessionData[authenticator.sessionKey];
|
||||
if (sessionAuthData) {
|
||||
user = { connect: { id: sessionAuthData.user.id } };
|
||||
let twilioAccount;
|
||||
if (sessionData.twilio) {
|
||||
twilioAccount = { connect: { accountSid: sessionData.twilio.accountSid } };
|
||||
}
|
||||
const { id } = await db.session.create({
|
||||
data: {
|
||||
expiresAt,
|
||||
user,
|
||||
twilioAccount,
|
||||
data: JSON.stringify(sessionData),
|
||||
},
|
||||
});
|
||||
|
@ -1,10 +0,0 @@
|
||||
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");
|
||||
}
|
@ -2,9 +2,7 @@ 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 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"]>(
|
||||
|
Reference in New Issue
Block a user