Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import trips from "routes/trips/index.js";
import account from "routes/account.js";
import profile from "routes/profile.js";
import auth from "routes/auth.js";
import support from "routes/support.js";
import contact from "routes/contact.js";
import taxonomy from "routes/taxonomy.js";
import region from "routes/region.js";
import ebirdProxy from "routes/ebird-proxy.js";
Expand All @@ -25,7 +25,7 @@ app.route("/v1/profile", profile);
app.route("/v1/account", account);
app.route("/v1/trips", trips);
app.route("/v1/auth", auth);
app.route("/v1/support", support);
app.route("/v1/contact", contact);
app.route("/v1/taxonomy", taxonomy);
app.route("/v1/region", region);
app.route("/v1/ebird-proxy", ebirdProxy);
Expand All @@ -37,9 +37,9 @@ app.notFound((c) => {
});

app.onError((err, c) => {
const message = err instanceof Error ? err.message : "Internal Server Error";
const status = err instanceof HTTPException ? err.status : 500;
return c.json({ message }, status);
if (err instanceof HTTPException) return c.json({ message: err.message }, err.status);
console.error(err);
return c.json({ message: "Something went wrong. Please try again." }, 500);
});

serve(
Expand Down
32 changes: 31 additions & 1 deletion backend/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
export const RESET_TOKEN_EXPIRATION = 12; // hours
export const OPENBIRDING_API_URL = process.env.OPENBIRDING_API_URL;
export const SHARE_CODE_TTL_MINUTES = 10;

export const IS_DEV = process.env.NODE_ENV !== "production";

export const OTP_EXPIRATION_MINUTES = 10;
export const OTP_MAX_ATTEMPTS = 5;

export const INVITE_EXPIRATION_DAYS = 7;

export const MAGIC_LINK_EXPIRATION_DAYS = 7;

export const SESSION_INACTIVITY_DAYS = 365;
export const SESSION_REFRESH_THRESHOLD_HOURS = 24;

type RateRule = { limit: number; windowMs: number };

const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;

export const RATE_LIMITS: Record<string, RateRule[]> = {
requestCodeEmail: [
{ limit: 2, windowMs: 30 * SECOND },
{ limit: 5, windowMs: HOUR },
],
requestCodeIp: [{ limit: 10, windowMs: HOUR }],
verifyCodeEmail: [{ limit: 10, windowMs: 10 * MINUTE }],
verifyCodeIp: [{ limit: 20, windowMs: 10 * MINUTE }],
otpNotReceivedEmail: [{ limit: 1, windowMs: 10 * MINUTE }],
otpNotReceivedIp: [{ limit: 10, windowMs: HOUR }],
redeemMagicLinkIp: [{ limit: 20, windowMs: 10 * MINUTE }],
};
11 changes: 8 additions & 3 deletions backend/lib/db.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import Trip from "models/Trip.js";
import Profile from "models/Profile.js";
import User from "models/User.js";
import Invite from "models/Invite.js";
import Participant from "models/Participant.js";
import TripShareToken from "models/TripShareToken.js";
import IntegrationToken from "models/IntegrationToken.js";
import Session from "models/Session.js";
import OtpCode from "models/OtpCode.js";
import MagicLink from "models/MagicLink.js";
import RateLimit from "models/RateLimit.js";
import Log from "models/Log.js";
import mongoose from "mongoose";

let isConnected = false;
Expand Down Expand Up @@ -44,4 +49,4 @@ export async function connect() {
}
}

export { Trip, Profile, Invite, Participant, TripShareToken };
export { Trip, User, Invite, Participant, IntegrationToken, Session, OtpCode, MagicLink, RateLimit, Log };
57 changes: 49 additions & 8 deletions backend/lib/email.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Resend } from "resend";
import { RESET_TOKEN_EXPIRATION } from "lib/config.js";
import { HTTPException } from "hono/http-exception";
import { OTP_EXPIRATION_MINUTES, INVITE_EXPIRATION_DAYS, IS_DEV } from "lib/config.js";
import { sendNtfyNotification } from "lib/notify.js";

const QUOTA_NOTIFY_BUCKETS = [0.5, 0.7, 0.8, 0.9, 0.95, 1.0];
const RESEND_DAILY_LIMIT = 100;
const RESEND_MONTHLY_LIMIT = 3000;

const resend = new Resend(process.env.RESEND_API_KEY);

Expand All @@ -10,14 +16,49 @@ type Props = {
replyTo?: string;
};

const notifyQuotaUsage = async (label: string, used: number, limit: number) => {
if (!Number.isFinite(used)) return;

const usedBefore = used - 1;
const crossed = QUOTA_NOTIFY_BUCKETS.find((bucket) => {
const threshold = Math.ceil(limit * bucket);
return usedBefore < threshold && used >= threshold;
});
if (crossed === undefined) return;

await sendNtfyNotification(
"⚠️ BirdPlan email quota",
`${label} email quota crossed ${Math.round(crossed * 100)}% — ${used}/${limit} sent.`,
);
};

const notifyQuotas = async (headers: Record<string, string> | null) => {
await notifyQuotaUsage("Daily", Number(headers?.["x-resend-daily-quota"]), RESEND_DAILY_LIMIT);
await notifyQuotaUsage("Monthly", Number(headers?.["x-resend-monthly-quota"]), RESEND_MONTHLY_LIMIT);
};

export const sendEmail = async ({ to, subject, html, replyTo }: Props) => {
await resend.emails.send({
if (IS_DEV) {
console.log(`\n📧 [dev] email not sent\n to: ${to}\n subject: ${subject}\n body: ${html}\n`);
return;
}

const { error, headers } = await resend.emails.send({
from: "BirdPlan.app <support@birdplan.app>",
to,
subject,
html,
replyTo,
});

await notifyQuotas(headers);

if (error) {
console.error(`[resend] failed to send email to ${to}: ${error.name} — ${error.message}`);
throw new HTTPException(503, {
message: "We're unable to send emails right now. Please try again in a few minutes.",
});
}
};

type inviteEmailProps = {
Expand All @@ -31,20 +72,20 @@ export const sendInviteEmail = async ({ tripName, fromName, email, url }: invite
await sendEmail({
to: email,
subject: `${fromName} has invited you to join ${tripName}`,
html: `Hello,<br /><br />${fromName} invited to join their trip called '${tripName}'.<br /><br /><a href=${url}>Accept Invite</a>`,
html: `Hello,<br /><br />${fromName} invited you to join their trip called '${tripName}'.<br /><br /><a href="${url}">Accept Invite</a><br /><br />This invite expires in ${INVITE_EXPIRATION_DAYS} days.`,
replyTo: email,
});
};

type resetEmailProps = {
type otpEmailProps = {
email: string;
url: string;
code: string;
};

export const sendResetEmail = async ({ email, url }: resetEmailProps) => {
export const sendOtpEmail = async ({ email, code }: otpEmailProps) => {
await sendEmail({
to: email,
subject: "Reset your BirdPlan.app password",
html: `Hello,<br /><br />Click the link below to reset your BirdPlan.app password.<br /><br /><a href="${url}">Reset Password</a><br /><br />This link will expire in ${RESET_TOKEN_EXPIRATION} hours. If you did not request a password reset, please ignore this email.`,
subject: `${code} is your BirdPlan.app sign-in code`,
html: `Hello,<br /><br />Your BirdPlan.app sign-in code is:<br /><br /><div style="font-size:28px;font-weight:bold;letter-spacing:4px;">${code}</div><br />This code expires in ${OTP_EXPIRATION_MINUTES} minutes. If you didn't request it, you can safely ignore this email.`,
});
};
3 changes: 0 additions & 3 deletions backend/lib/firebaseAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ if (hasFirebaseConfig && !firebase.apps.length) {
});
}

export const admin = firebase;
export const auth = hasFirebaseConfig ? firebase.auth() : null;

export async function uploadMapboxImageToStorage(mapboxImageUrl: string): Promise<string | null> {
if (!hasFirebaseConfig) {
console.warn("Firebase not configured, skipping image upload");
Expand Down
18 changes: 18 additions & 0 deletions backend/lib/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { connect, Log } from "lib/db.js";

type LogInput = {
type: string;
email?: string;
userId?: string;
ip?: string;
data?: Record<string, unknown>;
};

export const logEvent = async (input: LogInput) => {
try {
await connect();
await Log.create(input);
} catch (err) {
console.error(`[log] failed to write ${input.type}: ${err instanceof Error ? err.message : err}`);
}
};
45 changes: 45 additions & 0 deletions backend/lib/magicLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import dayjs from "dayjs";
import { HTTPException } from "hono/http-exception";
import { connect, MagicLink } from "lib/db.js";
import { nanoId } from "lib/utils.js";
import { sha256, createSession } from "lib/session.js";
import { MAGIC_LINK_EXPIRATION_DAYS } from "lib/config.js";

export async function issueMagicLink(userId: string, createdByUserId?: string) {
await connect();

const token = nanoId(40);
const expiresAt = dayjs().add(MAGIC_LINK_EXPIRATION_DAYS, "day").toDate();

await MagicLink.create({
tokenHash: sha256(token),
userId,
expiresAt,
createdByUserId,
});

return { token, expiresAt };
}

type RedeemMeta = { userAgent?: string; ip?: string };

export async function redeemMagicLink(token: string, meta: RedeemMeta = {}) {
await connect();

const now = new Date();
const link = await MagicLink.findOneAndUpdate(
{ tokenHash: sha256(token), consumedAt: null, expiresAt: { $gt: now } },
{ $set: { consumedAt: now } },
{ new: true }
).lean();

if (!link) throw new HTTPException(400, { message: "This link is invalid or has expired." });

try {
const { token: sessionToken } = await createSession(link.userId, meta);
return { sessionToken, userId: link.userId };
} catch (err) {
await MagicLink.updateOne({ _id: link._id }, { $set: { consumedAt: null } });
throw err;
}
}
13 changes: 13 additions & 0 deletions backend/lib/notify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const sendNtfyNotification = async (title: string, message: string) => {
const topic = process.env.NTFY_TOPIC;
if (!topic) return;
try {
await fetch(`https://ntfy.sh/${topic}`, {
method: "POST",
headers: { Title: title },
body: message,
});
} catch (err) {
console.error(`[ntfy] failed to send notification: ${err instanceof Error ? err.message : err}`);
}
};
51 changes: 51 additions & 0 deletions backend/lib/otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import crypto from "crypto";
import dayjs from "dayjs";
import { HTTPException } from "hono/http-exception";
import { OtpCode } from "lib/db.js";
import { sha256, constantTimeEqual } from "lib/session.js";
import { sendOtpEmail } from "lib/email.js";
import { OTP_EXPIRATION_MINUTES, OTP_MAX_ATTEMPTS } from "lib/config.js";

export const generateCode = () => crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");

export async function issueOtp(email: string, ip?: string) {
await OtpCode.updateMany({ email, consumedAt: null }, { $set: { consumedAt: new Date() } });

const code = generateCode();
await OtpCode.create({
email,
codeHash: sha256(code),
expiresAt: dayjs().add(OTP_EXPIRATION_MINUTES, "minute").toDate(),
ip,
});

await sendOtpEmail({ email, code });
}

export async function verifyOtp(email: string, code: string) {
const now = new Date();

const otp = await OtpCode.findOneAndUpdate(
{ email, consumedAt: null, expiresAt: { $gt: now }, attempts: { $lt: OTP_MAX_ATTEMPTS } },
{ $inc: { attempts: 1 } },
{ sort: { createdAt: -1 }, new: true }
).lean();

if (!otp) {
const locked = await OtpCode.exists({
email,
consumedAt: null,
expiresAt: { $gt: now },
attempts: { $gte: OTP_MAX_ATTEMPTS },
});
if (locked) throw new HTTPException(400, { message: "Too many attempts. Please request a new code." });
throw new HTTPException(400, { message: "Invalid or expired code" });
}

if (!constantTimeEqual(sha256(code), otp.codeHash)) {
throw new HTTPException(400, { message: "Invalid or expired code" });
}

const consumed = await OtpCode.updateOne({ _id: otp._id, consumedAt: null }, { $set: { consumedAt: now } });
if (consumed.matchedCount === 0) throw new HTTPException(400, { message: "Invalid or expired code" });
}
Loading
Loading