fix: harden share link dose operations and token reuse (#298)

* fix: harden share link dose operations and token reuse

* fix: restore share dose compatibility and add correlation helper
This commit is contained in:
Daniel Volz
2026-02-24 21:12:43 +01:00
committed by GitHub
parent f15c2dd79f
commit 63cd9ef19b
4 changed files with 220 additions and 30 deletions
+125 -7
View File
@@ -2,10 +2,11 @@ import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, shareTokens } from "../db/schema.js";
import { doseTracking, medications, shareTokens } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js";
// =============================================================================
// Validation Schemas
@@ -22,6 +23,13 @@ const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function maskToken(token: string): string {
if (token.length <= 8) return token;
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -38,6 +46,91 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
return authUser.id;
}
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
function parseDoseId(doseId: string): ParsedDoseId | null {
const match = doseIdPattern.exec(doseId);
if (!match) return null;
const medicationId = Number.parseInt(match[1], 10);
const intakeIndex = Number.parseInt(match[2], 10);
const timestampMs = Number.parseInt(match[3], 10);
const personSuffix = match[4] ? match[4].trim() : null;
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
return null;
}
return {
medicationId,
intakeIndex,
timestampMs,
personSuffix,
};
}
async function getActiveShareToken(token: string): Promise<{
share: typeof shareTokens.$inferSelect | null;
reason: "not_found" | "expired" | "ok";
}> {
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) return { share: null, reason: "not_found" };
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
return { share: null, reason: "expired" };
}
return { share, reason: "ok" };
}
async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise<boolean> {
const parsedDose = parseDoseId(doseId);
if (!parsedDose) {
return false;
}
const [medication] = await db
.select()
.from(medications)
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId)));
if (!medication) {
return false;
}
const medTakenBy = parseTakenByJson(medication.takenByJson);
const intakes = parseIntakesJson(
medication.intakesJson,
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
medication.intakeRemindersEnabled ?? false
);
if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) {
return false;
}
const intake = intakes[parsedDose.intakeIndex];
if (!intake) {
return false;
}
const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy;
if (expectedPersons.length === 0) {
return parsedDose.personSuffix === null;
}
if (!parsedDose.personSuffix) {
return true;
}
return expectedPersons.includes(parsedDose.personSuffix);
}
// =============================================================================
// Dose Tracking Routes
// =============================================================================
@@ -215,9 +308,9 @@ export async function doseRoutes(app: FastifyInstance) {
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
const { token } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`);
return reply.notFound("Share link not found");
}
@@ -252,12 +345,20 @@ export async function doseRoutes(app: FastifyInstance) {
const { doseId } = parsed.data;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected mark for token ${maskToken(token)} (reason=${reason})`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
// Check if already marked
const [existing] = await db
.select()
@@ -265,6 +366,7 @@ export async function doseRoutes(app: FastifyInstance) {
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing) {
request.log.debug(`[ShareDose] Duplicate mark ignored (owner=${share.userId}, doseId=${doseId})`);
return { success: true, message: "Already marked" };
}
@@ -276,6 +378,10 @@ export async function doseRoutes(app: FastifyInstance) {
takenSource: "manual",
});
request.log.info(
`[ShareDose] Dose marked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
);
return { success: true };
}
);
@@ -286,12 +392,20 @@ export async function doseRoutes(app: FastifyInstance) {
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
const { token, doseId } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected unmark for token ${maskToken(token)} (reason=${reason})`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in unmark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
// Check if this dose was dismissed
const [existing] = await db
.select()
@@ -300,9 +414,13 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(`[ShareDose] Unmark ignored for dismissed dose (owner=${share.userId}, doseId=${doseId})`);
} else {
// Not dismissed - delete the record entirely
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
`[ShareDose] Dose unmarked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
);
}
return { success: true };
+40 -12
View File
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -14,9 +14,6 @@ import {
personTakesMedication,
} from "../utils/scheduler-utils.js";
// Share token validity: 1 year in milliseconds
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
// =============================================================================
// Validation Schemas
// =============================================================================
@@ -25,6 +22,11 @@ const createShareSchema = z.object({
scheduleDays: z.number().int().min(1).max(365).default(30),
});
function maskToken(token: string): string {
if (token.length <= 8) return token;
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -54,6 +56,7 @@ export async function shareRoutes(app: FastifyInstance) {
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
@@ -62,6 +65,9 @@ export async function shareRoutes(app: FastifyInstance) {
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
return reply.status(410).send({
@@ -197,25 +203,47 @@ export async function shareRoutes(app: FastifyInstance) {
});
}
// Generate unique token (8 bytes = 16 hex chars)
// Keep exactly one active share link per person/user.
// If a link already exists, return the same token and only update settings.
const [existingShare] = await db
.select()
.from(shareTokens)
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
if (existingShare) {
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
request.log.info(
`[Share] Reused existing share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
);
return {
reused: true,
token: existingShare.token,
shareUrl: `/share/${existingShare.token}`,
expiresAt: null,
};
}
const token = randomBytes(8).toString("hex");
// Set expiration date (1 year from now)
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
// Create share token
await db.insert(shareTokens).values({
userId: userId,
userId,
token,
takenBy,
scheduleDays,
expiresAt,
expiresAt: null,
});
request.log.info(
`[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
);
return {
reused: false,
token,
shareUrl: `/share/${token}`,
expiresAt: expiresAt.toISOString(),
expiresAt: null,
};
}
);
+37 -11
View File
@@ -4,6 +4,8 @@
import { useCallback, useState } from "react";
import type { Medication } from "../types";
import { withCorrelation } from "../utils/correlation";
import { log } from "../utils/logger";
export interface UseShareReturn {
showShareDialog: boolean;
@@ -45,36 +47,57 @@ export function useShare(): UseShareReturn {
const allPeople = meds.flatMap((m) => m.takenBy || []);
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
setSharePeople(uniquePeople);
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
if (uniquePeople.length > 0) {
setShareSelectedPerson(uniquePeople[0]);
}
}, []);
const generateShareLink = useCallback(async () => {
if (!shareSelectedPerson) return;
if (!shareSelectedPerson) {
log.warn("[ShareDialog] Attempted to generate link without selected person");
return;
}
setShareGenerating(true);
setShareCopied(false);
try {
const res = await fetch("/api/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays,
}),
});
const { correlationId, init } = withCorrelation(
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays,
}),
},
"fe-share"
);
const res = await fetch("/api/share", init);
if (res.ok) {
const data = await res.json();
const fullUrl = `${window.location.origin}/share/${data.token}`;
setShareLink(fullUrl);
log.info("[ShareDialog] Share link ready", {
person: shareSelectedPerson,
days: shareSelectedDays,
reused: Boolean(data.reused),
correlationId,
});
} else {
const err = await res.json();
log.error("[ShareDialog] Failed to generate share link", {
status: res.status,
person: shareSelectedPerson,
error: err.error,
correlationId,
});
alert(err.error || "Failed to generate share link");
}
} catch {
} catch (error) {
log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error });
alert("Failed to generate share link");
} finally {
setShareGenerating(false);
@@ -85,18 +108,21 @@ export function useShare(): UseShareReturn {
if (shareLink) {
navigator.clipboard.writeText(shareLink);
setShareCopied(true);
log.debug("[ShareDialog] Share link copied to clipboard");
setTimeout(() => setShareCopied(false), 2000);
}
}, [shareLink]);
const closeShareDialog = useCallback(() => {
if (showShareDialog) {
log.debug("[ShareDialog] Closing dialog");
window.history.back();
}
}, [showShareDialog]);
// Internal function to reset share dialog state (called by popstate handler)
const resetShareDialogState = useCallback(() => {
log.debug("[ShareDialog] Reset dialog state");
setShowShareDialog(false);
setShareLink(null);
setShareCopied(false);
+18
View File
@@ -0,0 +1,18 @@
function createCorrelationId(prefix = "fe"): string {
const randomPart = Math.random().toString(36).slice(2, 10);
return `${prefix}-${Date.now()}-${randomPart}`;
}
export function withCorrelation(init: RequestInit, prefix = "fe"): { correlationId: string; init: RequestInit } {
const correlationId = createCorrelationId(prefix);
const headers = new Headers(init.headers ?? undefined);
headers.set("x-correlation-id", correlationId);
return {
correlationId,
init: {
...init,
headers,
},
};
}