Files
medassist-ng/backend/src/routes/planner.ts
T
Daniel Volz cb1810586d security: fix CodeQL vulnerabilities (SSRF, XSS, rate limiting)
- Add URL validation to prevent SSRF attacks on notification endpoints
  - Block private IPs (10.x, 172.16-31.x, 192.168.x, 169.254.x)
  - Block localhost and internal hostnames
  - Only allow HTTP/HTTPS protocols
- Add HTML escaping for medication names in email templates (XSS)
- Add stricter rate limiting for auth routes (5 req/15min for login/register)
- Add SSRF protection tests (405 tests total)
2025-12-30 11:52:00 +01:00

462 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyInstance } from "fastify";
import nodemailer from "nodemailer";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
import { getDateLocale, getTranslations, t, type Language } from "../i18n/translations.js";
import type { AuthUser } from "../types/fastify.js";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
// Escape HTML to prevent XSS in email templates
function escapeHtml(text: string): string {
const htmlEscapes: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
return text.replace(/[&<>"']/g, char => htmlEscapes[char] || char);
}
type PlannerRow = {
medicationId: number;
medicationName: string;
totalPills: number;
plannerUsage: number;
blisterSize: number;
blistersNeeded: number;
fullBlisters: number;
loosePills: number;
enough: boolean;
};
type SendEmailBody = {
email: string;
from: string;
until: string;
rows: PlannerRow[];
language?: Language; // Optional: passed from frontend for unauthenticated requests
};
type LowStockItem = {
name: string;
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
};
type ReminderEmailBody = {
email: string;
lowStock: LowStockItem[];
language?: Language; // Optional: passed from frontend for unauthenticated requests
};
export async function plannerRoutes(app: FastifyInstance) {
// Add auth hook for all planner routes
app.addHook("preHandler", requireAuth);
// Helper to get user ID from request
async function getUserId(request: any): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as AuthUser | null;
if (!authUser?.id) {
throw new Error("User not authenticated");
}
return authUser.id;
}
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
const { email, from, until, rows, language: bodyLanguage } = request.body;
if (!email || !rows || rows.length === 0) {
return reply.status(400).send({ error: "Missing email or planner data" });
}
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (!smtpHost || !smtpUser) {
return reply.status(400).send({ error: "SMTP not configured" });
}
// Get locale from user settings or use the language passed in the body
let language: Language = bodyLanguage || "en";
const authUser = request.user as unknown as AuthUser | null;
if (authUser?.id) {
const userSettings = await loadUserSettings(authUser.id);
language = userSettings.language;
}
const locale = getDateLocale(language);
// Format dates for display
const fromDate = new Date(from).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
const untilDate = new Date(until).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
// Build HTML table with horizontal scroll for mobile
const tableRows = rows
.map(
(row) => `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${escapeHtml(row.medicationName)}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.totalPills}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.plannerUsage}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.blistersNeeded} × ${row.blisterSize}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.fullBlisters}${row.loosePills > 0 ? ` (+${row.loosePills})` : ""}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
row.enough
? "background: #d1fae5; color: #065f46;"
: "background: #fee2e2; color: #991b1b;"
}">
${row.enough ? "✓ OK" : "✗ Out of Stock"}
</span>
</td>
</tr>
`
)
.join("");
const outOfStockCount = rows.filter((r) => !r.enough).length;
const summaryText =
outOfStockCount > 0
? `⚠️ ${outOfStockCount} medication${outOfStockCount > 1 ? "s" : ""} will be out of stock during this period.`
: "✓ All medications have sufficient supply for this period.";
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">MedAssist-ng - Demand Calculator</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">Supply overview from <strong>${fromDate}</strong> to <strong>${untilDate}</strong></p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; ${
outOfStockCount > 0
? "background: #fef2f2; border: 1px solid #fecaca;"
: "background: #f0fdf4; border: 1px solid #bbf7d0;"
}">
<p style="margin: 0; color: ${outOfStockCount > 0 ? "#991b1b" : "#166534"}; font-weight: 500; font-size: 13px;">
${summaryText}
</p>
</div>
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 550px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Stock</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Usage</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Needed</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Available</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Status</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist-ng Medication Planner</p>
</div>
</div>
`;
const plainText = `MedAssist-ng - Demand Calculator
Supply overview from ${fromDate} to ${untilDate}
${summaryText}
${rows.map((r) => `${r.medicationName}: ${r.totalPills} pills in stock, ${r.plannerUsage} pills needed, ${r.fullBlisters} blisters available${r.loosePills > 0 ? ` (+${r.loosePills} loose)` : ""} (${r.blistersNeeded} needed) - ${r.enough ? "Enough" : "OUT OF STOCK"}`).join("\n")}
---
Sent from MedAssist-ng Medication Planner`;
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `MedAssist-ng - Supply Overview (${fromDate} - ${untilDate})`,
text: plainText,
html,
});
return reply.send({ success: true, message: "Email sent successfully" });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
}
});
// Reminder notification for low stock medications (supports email and push)
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
const { email, lowStock, language: bodyLanguage } = request.body;
if (!lowStock || lowStock.length === 0) {
return reply.status(400).send({ error: "Missing low stock data" });
}
// Load user settings
const userId = await getUserId(request);
const userSettings = await loadUserSettings(userId);
const notificationSettings = {
emailEnabled: userSettings.emailEnabled,
shoutrrrEnabled: userSettings.shoutrrrEnabled,
shoutrrrUrl: userSettings.shoutrrrUrl || "",
};
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
// Separate empty from low stock medications
const emptyMeds = lowStock.filter(r => r.medsLeft <= 0);
const lowMeds = lowStock.filter(r => r.medsLeft > 0);
// Send email if enabled
if (notificationSettings.emailEnabled && email) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (smtpHost && smtpUser) {
// Build subject line based on what we have
let subjectText: string;
if (emptyMeds.length > 0 && lowMeds.length > 0) {
subjectText = `🚨 ${emptyMeds.length} Empty, ⚠️ ${lowMeds.length} Running Low`;
} else if (emptyMeds.length > 0) {
subjectText = `🚨 ${emptyMeds.length} Medication${emptyMeds.length > 1 ? "s" : ""} Empty`;
} else {
subjectText = `⚠️ ${lowMeds.length} Medication${lowMeds.length > 1 ? "s" : ""} Running Low`;
}
// Build alert box based on what we have
let alertHtml: string;
if (emptyMeds.length > 0 && lowMeds.length > 0) {
alertHtml = `
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fef2f2; border: 1px solid #dc2626;">
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
</p>
</div>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
</p>
</div>`;
} else if (emptyMeds.length > 0) {
alertHtml = `
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #dc2626;">
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
</p>
</div>`;
} else {
alertHtml = `
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
</p>
</div>`;
}
// Build table rows with status indicator
const buildTableRow = (row: LowStockItem) => {
const isEmpty = row.medsLeft <= 0;
const statusIcon = isEmpty ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : "white";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${escapeHtml(row.name)}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : (row.depletionDate ?? "-")}</td>
</tr>`;
};
const tableRows = lowStock.map(buildTableRow).join("");
// Build description text
let descriptionText: string;
if (emptyMeds.length > 0 && lowMeds.length > 0) {
descriptionText = "The following medications need to be reordered:";
} else if (emptyMeds.length > 0) {
descriptionText = "The following medications are EMPTY and need to be reordered immediately:";
} else {
descriptionText = "The following medications are running low and need to be reordered:";
}
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${descriptionText}</p>
${alertHtml}
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist-ng Medication Planner</p>
</div>
</div>
`;
// Build plain text with sections
let plainTextContent: string;
if (emptyMeds.length > 0 && lowMeds.length > 0) {
plainTextContent = `🚨 EMPTY (reorder immediately):
${emptyMeds.map((r) => `${r.name}`).join("\n")}
⚠️ RUNNING LOW (reorder soon):
${lowMeds.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining`).join("\n")}`;
} else if (emptyMeds.length > 0) {
plainTextContent = `🚨 EMPTY (reorder immediately):
${emptyMeds.map((r) => `${r.name}`).join("\n")}`;
} else {
plainTextContent = `⚠️ Running low:
${lowMeds.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}`;
}
const plainText = `MedAssist-ng - Reorder Reminder
${plainTextContent}
---
Sent from MedAssist-ng Medication Planner`;
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `MedAssist-ng - ${subjectText}`,
text: plainText,
html,
});
results.email = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Email: ${errorMessage}`);
}
}
}
// Send push notification if enabled
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
// Get translations based on user language (default to 'en')
const tr = getTranslations((userSettings.language as Language) || "en");
// Build clear title
const titleParts: string[] = [];
if (emptyMeds.length > 0) {
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
}
if (lowMeds.length > 0) {
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low}`);
}
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
// Build clear message with sections
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection}:`);
emptyMeds.forEach(r => messageParts.push(`${r.name}`));
}
if (lowMeds.length > 0) {
if (emptyMeds.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowSection}:`);
lowMeds.forEach(r => messageParts.push(`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`));
}
const message = messageParts.join("\n");
try {
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message);
if (pushResult.success) {
results.push = true;
} else {
results.errors.push(`Push: ${pushResult.error}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Push: ${errorMessage}`);
}
}
// Update the reminder state to record this notification was sent
if (results.email || results.push) {
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
updateReminderSentTime("stock", channel);
// Also update user settings in database so frontend can display the info
await updateUserReminderSentTime(userId, "stock", channel);
}
// Build response message
const sentChannels: string[] = [];
if (results.email) sentChannels.push("email");
if (results.push) sentChannels.push("push");
if (sentChannels.length > 0) {
return reply.send({
success: true,
message: `Reminder sent via ${sentChannels.join(" and ")}`
});
} else if (results.errors.length > 0) {
return reply.status(500).send({ error: results.errors.join("; ") });
} else {
return reply.status(400).send({ error: "No notification channels configured" });
}
});
}