From 57cd10f2125d4e82b6068f95170eb40a69cc3902 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 7 Jul 2026 11:06:05 -0700 Subject: [PATCH] feat(redirect): serve a friendly 410 page for expired links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expired short links previously returned the same bare 404 JSON as nonexistent ones. Now they return HTTP 410 Gone with a lightweight, unbranded 'This link has expired' page (noindex, Powered by LinkForty footer). - Distinguish expired from never-existed: when the active-link lookup misses, a secondary check finds links whose expires_at has passed — accurate even after a housekeeping job flips is_active off. - Enforce expiry on cache hits too: a link cached shortly before its expiration no longer keeps redirecting for up to the cache TTL. - Export isLinkExpired() and generateExpiredLinkHTML() with unit tests. --- src/routes/redirect.test.ts | 39 +++++++++++++++++++++ src/routes/redirect.ts | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/routes/redirect.test.ts b/src/routes/redirect.test.ts index 612c31b..fbd5306 100644 --- a/src/routes/redirect.test.ts +++ b/src/routes/redirect.test.ts @@ -3,6 +3,8 @@ import { isIOSInAppBrowser, isAndroidInAppBrowser, pickMobileFallbackUrl, + isLinkExpired, + generateExpiredLinkHTML, } from './redirect.js'; // Real-world UA strings (truncated where helpful) for use across test cases. @@ -184,3 +186,40 @@ describe('pickMobileFallbackUrl — reporter scenario regression test', () => { expect(r?.url).toBe(URLS.webFallback); }); }); + +describe('isLinkExpired', () => { + const now = new Date('2026-07-07T12:00:00Z'); + + it('null/undefined expires_at never expires', () => { + expect(isLinkExpired(null, now)).toBe(false); + expect(isLinkExpired(undefined, now)).toBe(false); + }); + + it('future timestamp is not expired', () => { + expect(isLinkExpired('2026-07-08T12:00:00Z', now)).toBe(false); + expect(isLinkExpired(new Date('2027-01-01T00:00:00Z'), now)).toBe(false); + }); + + it('past timestamp is expired', () => { + expect(isLinkExpired('2026-07-07T11:59:59Z', now)).toBe(true); + expect(isLinkExpired(new Date('2020-01-01T00:00:00Z'), now)).toBe(true); + }); + + it('exact expiry moment counts as expired', () => { + expect(isLinkExpired('2026-07-07T12:00:00Z', now)).toBe(true); + }); + + it('unparseable timestamp fails open (not expired)', () => { + expect(isLinkExpired('not-a-date', now)).toBe(false); + }); +}); + +describe('generateExpiredLinkHTML', () => { + it('is a complete, noindexed HTML page saying the link expired', () => { + const html = generateExpiredLinkHTML(); + expect(html).toContain(''); + expect(html).toContain('This link has expired'); + expect(html).toContain('noindex'); + expect(html).toContain(''); + }); +}); diff --git a/src/routes/redirect.ts b/src/routes/redirect.ts index 5b05632..bdb8afa 100644 --- a/src/routes/redirect.ts +++ b/src/routes/redirect.ts @@ -136,6 +136,52 @@ function generateInterstitialHTML(schemeUrl: string, fallbackUrl: string, title? `; } +/** + * Whether a link's expiration timestamp has passed. Links without expires_at + * never expire. An unparseable timestamp is treated as not expired (fail open) + * so a malformed value can't take a live link down. + */ +export function isLinkExpired(expiresAt: string | Date | null | undefined, now: Date = new Date()): boolean { + if (!expiresAt) return false; + const t = new Date(expiresAt).getTime(); + return Number.isFinite(t) && t <= now.getTime(); +} + +/** + * Friendly page served (with HTTP 410 Gone) when someone opens a link past its + * expiration date. Deliberately unbranded: core is white-label and the page is + * served on customers' own short-link domains. + */ +export function generateExpiredLinkHTML(): string { + return ` + + + + +Link expired + + +
+ +

This link has expired

+

The link you followed is no longer active. If you were expecting to find something here, ask whoever shared it for an up-to-date link.

+
+
Powered by LinkForty
+`; +} + export async function redirectRoutes(fastify: FastifyInstance) { // Helper function to handle the actual redirect logic async function handleRedirect(request: any, reply: any, shortCode: string, templateSlug?: string) { @@ -188,6 +234,22 @@ export async function redirectRoutes(fastify: FastifyInstance) { const result = await db.query(query, params); if (result.rows.length === 0) { + // Distinguish "expired" from "never existed": an expired link keeps its + // expires_at even after the hourly expiration job flips is_active off, + // so this lookup stays accurate long after expiry. + const expiredCheck = templateSlug + ? await db.query( + `SELECT 1 FROM links l JOIN link_templates t ON l.template_id = t.id + WHERE l.short_code = $1 AND t.slug = $2 AND l.expires_at IS NOT NULL AND l.expires_at <= NOW()`, + [shortCode, templateSlug] + ) + : await db.query( + 'SELECT 1 FROM links WHERE short_code = $1 AND expires_at IS NOT NULL AND expires_at <= NOW()', + [shortCode] + ); + if (expiredCheck.rows.length > 0) { + return reply.status(410).type('text/html').send(generateExpiredLinkHTML()); + } return reply.status(404).send({ error: 'Link not found' }); } @@ -205,6 +267,12 @@ export async function redirectRoutes(fastify: FastifyInstance) { const link = JSON.parse(linkData); + // Enforce expiry on cache hits too — a link cached shortly before expiring + // would otherwise keep redirecting for up to the cache TTL (5 minutes). + if (isLinkExpired(link.expires_at)) { + return reply.status(410).type('text/html').send(generateExpiredLinkHTML()); + } + // Check targeting rules BEFORE redirecting if (link.targeting_rules) { const userAgent = request.headers['user-agent'] || '';