feat: add expiration date to share tokens and enhance error handling for expired links

This commit is contained in:
Daniel Volz
2025-12-28 17:47:49 +01:00
parent 0e52a03f7a
commit abffd66e9c
10 changed files with 182 additions and 10 deletions
+13
View File
@@ -17,7 +17,9 @@ async function main() {
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
password_hash text,
avatar_url text,
auth_provider text NOT NULL DEFAULT 'local',
oidc_subject text,
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
@@ -66,6 +68,7 @@ async function main() {
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
@@ -91,6 +94,16 @@ async function main() {
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dose_tracking (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
@@ -0,0 +1,3 @@
-- Add expiration date to share tokens (default 1 year from creation)
-- NULL means no expiration (for backwards compatibility with existing tokens)
ALTER TABLE share_tokens ADD COLUMN expires_at INTEGER;
+2 -1
View File
@@ -14,6 +14,7 @@
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false },
{ "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false },
{ "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false },
{ "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false }
{ "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false },
{ "idx": 15, "version": 1, "when": 1735400001, "tag": "0015_add_share_token_expiry", "breakpoint": false }
]
}
+1
View File
@@ -102,6 +102,7 @@ export const shareTokens = sqliteTable("share_tokens", {
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
});
// =============================================================================
+26 -2
View File
@@ -2,12 +2,15 @@ import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { medications, shareTokens, userSettings } from "../db/schema.js";
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
// Share token validity: 1 year in milliseconds
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
// =============================================================================
// Validation Schemas
// =============================================================================
@@ -45,7 +48,23 @@ export async function shareRoutes(app: FastifyInstance) {
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND"
});
}
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
// 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({
error: "Share link has expired",
code: "EXPIRED",
ownerUsername: owner?.username ?? "the owner",
takenBy: share.takenBy,
expiredAt: share.expiresAt.toISOString(),
});
}
// Get user settings for stock thresholds
@@ -133,6 +152,9 @@ export async function shareRoutes(app: FastifyInstance) {
// Generate unique token (8 bytes = 16 hex chars)
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({
@@ -140,11 +162,13 @@ export async function shareRoutes(app: FastifyInstance) {
token,
takenBy,
scheduleDays,
expiresAt,
});
return {
token,
shareUrl: `/share/${token}`,
expiresAt: expiresAt.toISOString(),
};
}
);