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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Defined in `src/content/config.ts` using Astro's content layer with loaders:
**Redirect Flow:**
1. User hits short URL (e.g., `/go`)
2. Middleware finds redirect config
3. Redirects to `/redirect?source=/go&dest=/contact&type=temporary&...` (307)
3. Redirects to `/redirect?source=/go&...` (307). The interstitial resolves the destination for `source` itself (`resolveRedirect()` in `src/data/promoRedirects.ts`) and never reads a destination from the query string, so it can't be used as an open redirect; unknown sources go to `/`.
4. Interstitial page (`src/pages/redirect.astro`) tracks event via GA4/Datadog
5. Client-side JS (`src/scripts/redirect-analytics.ts`) fires analytics then redirects

Expand Down
21 changes: 20 additions & 1 deletion src/data/promoRedirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import { getCollection } from 'astro:content';
import type { Redirect } from './redirects';
import { getRedirect, type Redirect } from './redirects';
import { getTodayPacific, getPacificDateComponents, createPacificEndOfDay } from '../utils/date-helpers';

/**
Expand Down Expand Up @@ -167,3 +167,22 @@ export async function getPromoRedirect(path: string): Promise<Redirect | null> {

return null;
}

/**
* Resolve a short path to its configured redirect: promo redirects (campaigns/coupons)
* first, then static redirects. Used by both the middleware and the /redirect
* interstitial so the two always agree on where a short link goes.
*/
export async function resolveRedirect(path: string): Promise<Redirect | null> {
try {
const promo = await getPromoRedirect(path);
if (promo) return promo;
} catch (error) {
// If promo redirects fail (e.g., content collections not available), fall through
if (import.meta.env.DEV) {
console.warn('[Redirect] Could not check promo redirects:', error);
}
}

return getRedirect(path);
}
49 changes: 5 additions & 44 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { defineMiddleware } from 'astro:middleware';
import { getRedirect } from './data/redirects.js';
import { getPromoRedirect } from './data/promoRedirects.js';
import { resolveRedirect } from './data/promoRedirects.js';

/**
* Middleware to handle link shortener redirects
Expand Down Expand Up @@ -35,57 +34,19 @@ export const onRequest = defineMiddleware(async (context, next) => {
console.log('[Redirect Middleware] Checking path:', pathname);
}

// Check promo redirects first (from campaigns/coupons)
let redirect = null;
try {
redirect = await getPromoRedirect(pathname);
if (redirect && import.meta.env.DEV) {
console.log('[Redirect Middleware] Found promo redirect:', pathname, '->', redirect.destination);
}
} catch (error) {
// If promo redirects fail (e.g., content collections not available), fall through
if (import.meta.env.DEV) {
console.warn('[Redirect Middleware] Could not check promo redirects:', error);
}
}

// Fall back to static redirects if no promo redirect found
if (!redirect) {
redirect = getRedirect(pathname);
}
// Check promo redirects (from campaigns/coupons), then static redirects
const redirect = await resolveRedirect(pathname);

if (redirect) {
if (import.meta.env.DEV) {
console.log('[Redirect Middleware] Found redirect:', pathname, '->', redirect.destination);
}

// Build destination URL
let destination = redirect.destination;

// If destination is relative, preserve query params from original request
if (!destination.startsWith('http://') && !destination.startsWith('https://')) {
const searchParams = context.url.searchParams.toString();
if (searchParams) {
// Check if destination already has query params
const separator = destination.includes('?') ? '&' : '?';
destination = destination + separator + searchParams;
}
}

// Get referrer from request headers
const referrer = context.request.headers.get('referer') || '';

// Build redirect interstitial URL with analytics parameters
// Build redirect interstitial URL. Only the source path is passed; the
// interstitial resolves destination/type/category from the redirect config itself.
// Use trailing slash to match Astro's default behavior
const redirectUrl = new URL('/redirect/', context.url.origin);
redirectUrl.searchParams.set('source', pathname);
redirectUrl.searchParams.set('dest', destination);
redirectUrl.searchParams.set('type', redirect.permanent ? 'permanent' : 'temporary');

// Add redirect category if available
if (redirect.category) {
redirectUrl.searchParams.set('category', redirect.category);
}

// Add hardcoded UTM parameters from redirect config (if any)
// These will be merged with any user-provided UTM params
Expand Down
30 changes: 14 additions & 16 deletions src/pages/redirect.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@
// Must be server-rendered to access request headers
export const prerender = false;

// Get redirect parameters from URL
import { resolveRedirect } from '~/data/promoRedirects';

// Only `source` is read from the query string. The destination, type, and category
// come from the redirect config (promo + static) so this page can't be used as an
// open redirect (or to run a `javascript:` URL) via a crafted destination.
const url = Astro.url;
const sourcePath = url.searchParams.get('source') || '';
const destinationUrl = url.searchParams.get('dest') || '';
const redirectType = (url.searchParams.get('type') === 'permanent' ? 'permanent' : 'temporary') as 'temporary' | 'permanent';
const redirectCategory = url.searchParams.get('category') || undefined;
const redirect = await resolveRedirect(sourcePath);
const destinationUrl = redirect?.destination || '';
const redirectType = (redirect?.permanent ? 'permanent' : 'temporary') as 'temporary' | 'permanent';
const redirectCategory = redirect?.category;

// Get referrer - handle both server-side (headers) and client-side (document.referrer)
let referrer = '';
Expand All @@ -21,8 +26,8 @@ try {
referrer = '';
}

// Validate required parameters
const isValid = sourcePath && destinationUrl;
// Validate required parameters (configured destinations must still be http(s))
const isValid = Boolean(redirect) && /^(https?:\/\/|\/(?!\/))/i.test(destinationUrl);

// Debug logging in development
if (import.meta.env.DEV) {
Expand All @@ -37,15 +42,8 @@ if (import.meta.env.DEV) {
});
}

// Build full destination URL if relative
let fullDestination = destinationUrl;
if (!destinationUrl.startsWith('http://') && !destinationUrl.startsWith('https://')) {
const baseUrl = new URL(Astro.url.origin);
fullDestination = new URL(destinationUrl, baseUrl).toString();
}

// Parse destination URL to merge query params
const destUrl = new URL(fullDestination);
// Build full destination URL (relative destinations resolve against this site)
const destUrl = new URL(isValid ? destinationUrl : '/', Astro.url.origin);
const destParams = new URLSearchParams(destUrl.search);

// Preserve any additional query params from redirect URL (UTM params, etc.)
Expand All @@ -59,7 +57,7 @@ url.searchParams.forEach((value, key) => {

// Rebuild destination URL with merged query params
destUrl.search = destParams.toString();
fullDestination = destUrl.toString();
const fullDestination = destUrl.toString();

// Debug logging for final destination in development
if (import.meta.env.DEV) {
Expand Down
Loading