Add an in-memory sliding-window rate limiter and wire it into signup, resend-verification, reset-password, and verify-email routes. Store password reset tokens as SHA-256 hashes, raise bcrypt cost to 12, and switch verification codes to a CSPRNG. Stop echoing calendar access/refresh tokens back to API clients, bound the weather cache size, return JSON 401s for unauthenticated API calls instead of an HTML redirect, and drop the dead mock login route. v1.113.0
94 lines
4.2 KiB
TypeScript
94 lines
4.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getServerSession } from 'next-auth';
|
|
import { authOptions } from '@/lib/auth';
|
|
import { prisma } from '@/lib/prisma';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
// In-memory cache: { key: { data, fetchedAt } }
|
|
const weatherCache = new Map<string, { data: any; fetchedAt: number }>();
|
|
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
const CACHE_MAX_ENTRIES = 500; // one entry per user/date-range — bound the Map
|
|
|
|
function evictStaleEntries() {
|
|
const now = Date.now();
|
|
for (const [key, entry] of weatherCache) {
|
|
if (now - entry.fetchedAt >= CACHE_TTL_MS) weatherCache.delete(key);
|
|
}
|
|
// Still over budget after TTL eviction: drop oldest entries
|
|
// (Map iterates in insertion order).
|
|
while (weatherCache.size >= CACHE_MAX_ENTRIES) {
|
|
const oldest = weatherCache.keys().next().value;
|
|
if (oldest === undefined) break;
|
|
weatherCache.delete(oldest);
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session?.user?.email) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { email: session.user.email },
|
|
select: { weatherEnabled: true, weatherLat: true, weatherLon: true },
|
|
});
|
|
|
|
if (!user?.weatherLat || !user.weatherLon) {
|
|
return NextResponse.json({ hourly: {} });
|
|
}
|
|
|
|
const { searchParams } = new URL(request.url);
|
|
const startDate = searchParams.get('start') || new Date().toISOString().slice(0, 10);
|
|
const endDate = searchParams.get('end') || startDate;
|
|
|
|
const cacheKey = `${user.weatherLat},${user.weatherLon},${startDate},${endDate}`;
|
|
const cached = weatherCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
return NextResponse.json(cached.data);
|
|
}
|
|
|
|
try {
|
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${user.weatherLat}&longitude=${user.weatherLon}&hourly=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,wind_gusts_10m,precipitation_probability,precipitation,relative_humidity_2m,uv_index&start_date=${startDate}&end_date=${endDate}&timezone=auto`;
|
|
const res = await fetch(url, { next: { revalidate: 900 } });
|
|
|
|
if (!res.ok) {
|
|
// Silently return empty data (e.g. date out of forecast range)
|
|
return NextResponse.json({ hourly: {} });
|
|
}
|
|
|
|
const raw = await res.json();
|
|
|
|
const hourly: Record<string, {
|
|
temp: number; code: number;
|
|
feelsLike?: number; wind?: number; gusts?: number;
|
|
precipProb?: number; precip?: number; humidity?: number; uv?: number;
|
|
}> = {};
|
|
if (raw.hourly?.time) {
|
|
for (let i = 0; i < raw.hourly.time.length; i++) {
|
|
hourly[raw.hourly.time[i]] = {
|
|
temp: Math.round(raw.hourly.temperature_2m?.[i] ?? 0),
|
|
code: raw.hourly.weather_code?.[i] ?? 0,
|
|
feelsLike: raw.hourly.apparent_temperature?.[i] != null ? Math.round(raw.hourly.apparent_temperature[i]) : undefined,
|
|
wind: raw.hourly.wind_speed_10m?.[i] != null ? Math.round(raw.hourly.wind_speed_10m[i]) : undefined,
|
|
gusts: raw.hourly.wind_gusts_10m?.[i] != null ? Math.round(raw.hourly.wind_gusts_10m[i]) : undefined,
|
|
precipProb: raw.hourly.precipitation_probability?.[i] ?? undefined,
|
|
precip: raw.hourly.precipitation?.[i] != null ? Math.round(raw.hourly.precipitation[i] * 10) / 10 : undefined,
|
|
humidity: raw.hourly.relative_humidity_2m?.[i] ?? undefined,
|
|
uv: raw.hourly.uv_index?.[i] != null ? Math.round(raw.hourly.uv_index[i] * 10) / 10 : undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
const data = { hourly, timezone: raw.timezone };
|
|
evictStaleEntries();
|
|
weatherCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
|
|
|
return NextResponse.json(data);
|
|
} catch (err) {
|
|
console.error('[WEATHER] Fetch failed:', err);
|
|
return NextResponse.json({ error: 'Weather fetch failed' }, { status: 500 });
|
|
}
|
|
}
|