Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.
Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
|
|
// ── Interfaces ──────────────────────────────────────────────────────────
|
|
|
|
export interface WeatherResult {
|
|
temp: number;
|
|
temp_max?: number;
|
|
temp_min?: number;
|
|
main: string;
|
|
description: string;
|
|
type: string;
|
|
sunrise?: string | null;
|
|
sunset?: string | null;
|
|
precipitation_sum?: number;
|
|
precipitation_probability_max?: number;
|
|
wind_max?: number;
|
|
hourly?: HourlyEntry[];
|
|
error?: string;
|
|
}
|
|
|
|
export interface HourlyEntry {
|
|
hour: number;
|
|
temp: number;
|
|
precipitation: number;
|
|
precipitation_probability: number;
|
|
main: string;
|
|
wind: number;
|
|
humidity: number;
|
|
}
|
|
|
|
interface OpenMeteoForecast {
|
|
error?: boolean;
|
|
reason?: string;
|
|
current?: { temperature_2m: number; weathercode: number };
|
|
daily?: {
|
|
time: string[];
|
|
temperature_2m_max: number[];
|
|
temperature_2m_min: number[];
|
|
weathercode: number[];
|
|
precipitation_sum?: number[];
|
|
precipitation_probability_max?: number[];
|
|
windspeed_10m_max?: number[];
|
|
sunrise?: string[];
|
|
sunset?: string[];
|
|
};
|
|
hourly?: {
|
|
time: string[];
|
|
temperature_2m: number[];
|
|
precipitation_probability?: number[];
|
|
precipitation?: number[];
|
|
weathercode?: number[];
|
|
windspeed_10m?: number[];
|
|
relativehumidity_2m?: number[];
|
|
};
|
|
}
|
|
|
|
// ── WMO code mappings ───────────────────────────────────────────────────
|
|
|
|
const WMO_MAP: Record<number, string> = {
|
|
0: 'Clear', 1: 'Clear', 2: 'Clouds', 3: 'Clouds',
|
|
45: 'Fog', 48: 'Fog',
|
|
51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle',
|
|
61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain',
|
|
71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow',
|
|
80: 'Rain', 81: 'Rain', 82: 'Rain',
|
|
85: 'Snow', 86: 'Snow',
|
|
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm',
|
|
};
|
|
|
|
const WMO_DESCRIPTION_DE: Record<number, string> = {
|
|
0: 'Klar', 1: 'Uberwiegend klar', 2: 'Teilweise bewolkt', 3: 'Bewolkt',
|
|
45: 'Nebel', 48: 'Nebel mit Reif',
|
|
51: 'Leichter Nieselregen', 53: 'Nieselregen', 55: 'Starker Nieselregen',
|
|
56: 'Gefrierender Nieselregen', 57: 'Starker gefr. Nieselregen',
|
|
61: 'Leichter Regen', 63: 'Regen', 65: 'Starker Regen',
|
|
66: 'Gefrierender Regen', 67: 'Starker gefr. Regen',
|
|
71: 'Leichter Schneefall', 73: 'Schneefall', 75: 'Starker Schneefall', 77: 'Schneekorner',
|
|
80: 'Leichte Regenschauer', 81: 'Regenschauer', 82: 'Starke Regenschauer',
|
|
85: 'Leichte Schneeschauer', 86: 'Starke Schneeschauer',
|
|
95: 'Gewitter', 96: 'Gewitter mit Hagel', 99: 'Starkes Gewitter mit Hagel',
|
|
};
|
|
|
|
const WMO_DESCRIPTION_EN: Record<number, string> = {
|
|
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
|
|
45: 'Fog', 48: 'Rime fog',
|
|
51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
|
|
56: 'Freezing drizzle', 57: 'Heavy freezing drizzle',
|
|
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
|
|
66: 'Freezing rain', 67: 'Heavy freezing rain',
|
|
71: 'Light snowfall', 73: 'Snowfall', 75: 'Heavy snowfall', 77: 'Snow grains',
|
|
80: 'Light rain showers', 81: 'Rain showers', 82: 'Heavy rain showers',
|
|
85: 'Light snow showers', 86: 'Heavy snow showers',
|
|
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Severe thunderstorm with hail',
|
|
};
|
|
|
|
// ── Cache management ────────────────────────────────────────────────────
|
|
|
|
const weatherCache = new Map<string, { data: WeatherResult; expiresAt: number }>();
|
|
const CACHE_MAX_ENTRIES = 1000;
|
|
const CACHE_PRUNE_TARGET = 500;
|
|
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of weatherCache) {
|
|
if (now > entry.expiresAt) weatherCache.delete(key);
|
|
}
|
|
if (weatherCache.size > CACHE_MAX_ENTRIES) {
|
|
const entries = [...weatherCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
|
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
|
|
toDelete.forEach(([key]) => weatherCache.delete(key));
|
|
}
|
|
}, CACHE_CLEANUP_INTERVAL);
|
|
|
|
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
|
|
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
|
|
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
export function cacheKey(lat: string, lng: string, date?: string): string {
|
|
const rlat = parseFloat(lat).toFixed(2);
|
|
const rlng = parseFloat(lng).toFixed(2);
|
|
return `${rlat}_${rlng}_${date || 'current'}`;
|
|
}
|
|
|
|
function getCached(key: string): WeatherResult | null {
|
|
const entry = weatherCache.get(key);
|
|
if (!entry) return null;
|
|
if (Date.now() > entry.expiresAt) {
|
|
weatherCache.delete(key);
|
|
return null;
|
|
}
|
|
return entry.data;
|
|
}
|
|
|
|
function setCache(key: string, data: WeatherResult, ttlMs: number): void {
|
|
weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs });
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
export function estimateCondition(tempAvg: number, precipMm: number): string {
|
|
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
|
|
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
|
|
if (precipMm > 0.3) return 'Clouds';
|
|
return tempAvg > 15 ? 'Clear' : 'Clouds';
|
|
}
|
|
|
|
// ── getWeather ──────────────────────────────────────────────────────────
|
|
|
|
export async function getWeather(
|
|
lat: string,
|
|
lng: string,
|
|
date: string | undefined,
|
|
lang: string,
|
|
): Promise<WeatherResult> {
|
|
const ck = cacheKey(lat, lng, date);
|
|
|
|
if (date) {
|
|
const cached = getCached(ck);
|
|
if (cached) return cached;
|
|
|
|
const targetDate = new Date(date);
|
|
const now = new Date();
|
|
const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
|
|
|
// Forecast range (-1 .. +16 days)
|
|
if (diffDays >= -1 && diffDays <= 16) {
|
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
|
|
const response = await fetch(url);
|
|
const data = await response.json() as OpenMeteoForecast;
|
|
|
|
if (!response.ok || data.error) {
|
|
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error');
|
|
}
|
|
|
|
const dateStr = targetDate.toISOString().slice(0, 10);
|
|
const idx = (data.daily?.time || []).indexOf(dateStr);
|
|
|
|
if (idx !== -1) {
|
|
const code = data.daily!.weathercode[idx];
|
|
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
|
|
|
const result: WeatherResult = {
|
|
temp: Math.round((data.daily!.temperature_2m_max[idx] + data.daily!.temperature_2m_min[idx]) / 2),
|
|
temp_max: Math.round(data.daily!.temperature_2m_max[idx]),
|
|
temp_min: Math.round(data.daily!.temperature_2m_min[idx]),
|
|
main: WMO_MAP[code] || 'Clouds',
|
|
description: descriptions[code] || '',
|
|
type: 'forecast',
|
|
};
|
|
|
|
setCache(ck, result, TTL_FORECAST_MS);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Climate / archive fallback (far-future dates)
|
|
if (diffDays > -1) {
|
|
const month = targetDate.getMonth() + 1;
|
|
const day = targetDate.getDate();
|
|
const refYear = targetDate.getFullYear() - 1;
|
|
const startDate = new Date(refYear, month - 1, day - 2);
|
|
const endDate = new Date(refYear, month - 1, day + 2);
|
|
const startStr = startDate.toISOString().slice(0, 10);
|
|
const endStr = endDate.toISOString().slice(0, 10);
|
|
|
|
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
|
|
const response = await fetch(url);
|
|
const data = await response.json() as OpenMeteoForecast;
|
|
|
|
if (!response.ok || data.error) {
|
|
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error');
|
|
}
|
|
|
|
const daily = data.daily;
|
|
if (!daily || !daily.time || daily.time.length === 0) {
|
|
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
|
|
}
|
|
|
|
let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0;
|
|
for (let i = 0; i < daily.time.length; i++) {
|
|
if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) {
|
|
sumMax += daily.temperature_2m_max[i];
|
|
sumMin += daily.temperature_2m_min[i];
|
|
sumPrecip += daily.precipitation_sum![i] || 0;
|
|
count++;
|
|
}
|
|
}
|
|
|
|
if (count === 0) {
|
|
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
|
|
}
|
|
|
|
const avgMax = sumMax / count;
|
|
const avgMin = sumMin / count;
|
|
const avgTemp = (avgMax + avgMin) / 2;
|
|
const avgPrecip = sumPrecip / count;
|
|
const main = estimateCondition(avgTemp, avgPrecip);
|
|
|
|
const result: WeatherResult = {
|
|
temp: Math.round(avgTemp),
|
|
temp_max: Math.round(avgMax),
|
|
temp_min: Math.round(avgMin),
|
|
main,
|
|
description: '',
|
|
type: 'climate',
|
|
};
|
|
|
|
setCache(ck, result, TTL_CLIMATE_MS);
|
|
return result;
|
|
}
|
|
|
|
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
|
|
}
|
|
|
|
// No date supplied -> current weather
|
|
const cached = getCached(ck);
|
|
if (cached) return cached;
|
|
|
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,weathercode&timezone=auto`;
|
|
const response = await fetch(url);
|
|
const data = await response.json() as OpenMeteoForecast;
|
|
|
|
if (!response.ok || data.error) {
|
|
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error');
|
|
}
|
|
|
|
const code = data.current!.weathercode;
|
|
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
|
|
|
const result: WeatherResult = {
|
|
temp: Math.round(data.current!.temperature_2m),
|
|
main: WMO_MAP[code] || 'Clouds',
|
|
description: descriptions[code] || '',
|
|
type: 'current',
|
|
};
|
|
|
|
setCache(ck, result, TTL_CURRENT_MS);
|
|
return result;
|
|
}
|
|
|
|
// ── getDetailedWeather ──────────────────────────────────────────────────
|
|
|
|
export async function getDetailedWeather(
|
|
lat: string,
|
|
lng: string,
|
|
date: string,
|
|
lang: string,
|
|
): Promise<WeatherResult> {
|
|
const ck = `detailed_${cacheKey(lat, lng, date)}`;
|
|
|
|
const cached = getCached(ck);
|
|
if (cached) return cached;
|
|
|
|
const targetDate = new Date(date);
|
|
const now = new Date();
|
|
const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
|
const dateStr = targetDate.toISOString().slice(0, 10);
|
|
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
|
|
|
// Climate / archive path (> 16 days out)
|
|
if (diffDays > 16) {
|
|
const refYear = targetDate.getFullYear() - 1;
|
|
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
|
|
|
|
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
|
|
+ `&start_date=${refDateStr}&end_date=${refDateStr}`
|
|
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
|
|
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
|
|
+ `&timezone=auto`;
|
|
const response = await fetch(url);
|
|
const data = await response.json() as OpenMeteoForecast;
|
|
|
|
if (!response.ok || data.error) {
|
|
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error');
|
|
}
|
|
|
|
const daily = data.daily;
|
|
const hourly = data.hourly;
|
|
if (!daily || !daily.time || daily.time.length === 0) {
|
|
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
|
|
}
|
|
|
|
const idx = 0;
|
|
const code = daily.weathercode?.[idx];
|
|
const avgMax = daily.temperature_2m_max[idx];
|
|
const avgMin = daily.temperature_2m_min[idx];
|
|
|
|
const hourlyData: HourlyEntry[] = [];
|
|
if (hourly?.time) {
|
|
for (let i = 0; i < hourly.time.length; i++) {
|
|
const hour = new Date(hourly.time[i]).getHours();
|
|
const hCode = hourly.weathercode?.[i];
|
|
hourlyData.push({
|
|
hour,
|
|
temp: Math.round(hourly.temperature_2m[i]),
|
|
precipitation: hourly.precipitation?.[i] || 0,
|
|
precipitation_probability: 0,
|
|
main: WMO_MAP[hCode!] || 'Clouds',
|
|
wind: Math.round(hourly.windspeed_10m?.[i] || 0),
|
|
humidity: hourly.relativehumidity_2m?.[i] || 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
let sunrise: string | null = null, sunset: string | null = null;
|
|
if (daily.sunrise?.[idx]) sunrise = daily.sunrise[idx].split('T')[1]?.slice(0, 5);
|
|
if (daily.sunset?.[idx]) sunset = daily.sunset[idx].split('T')[1]?.slice(0, 5);
|
|
|
|
const result: WeatherResult = {
|
|
type: 'climate',
|
|
temp: Math.round((avgMax + avgMin) / 2),
|
|
temp_max: Math.round(avgMax),
|
|
temp_min: Math.round(avgMin),
|
|
main: WMO_MAP[code!] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0),
|
|
description: descriptions[code!] || '',
|
|
precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10,
|
|
wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0),
|
|
sunrise,
|
|
sunset,
|
|
hourly: hourlyData,
|
|
};
|
|
|
|
setCache(ck, result, TTL_CLIMATE_MS);
|
|
return result;
|
|
}
|
|
|
|
// Forecast path (<= 16 days)
|
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}`
|
|
+ `&hourly=temperature_2m,precipitation_probability,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
|
|
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
|
|
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
|
|
|
|
const response = await fetch(url);
|
|
const data = await response.json() as OpenMeteoForecast;
|
|
|
|
if (!response.ok || data.error) {
|
|
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error');
|
|
}
|
|
|
|
const daily = data.daily;
|
|
const hourly = data.hourly;
|
|
|
|
if (!daily || !daily.time || daily.time.length === 0) {
|
|
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
|
|
}
|
|
|
|
const dayIdx = 0;
|
|
const code = daily.weathercode[dayIdx];
|
|
|
|
const formatTime = (isoStr: string) => {
|
|
if (!isoStr) return '';
|
|
const d = new Date(isoStr);
|
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
};
|
|
|
|
const hourlyData: HourlyEntry[] = [];
|
|
if (hourly && hourly.time) {
|
|
for (let i = 0; i < hourly.time.length; i++) {
|
|
const h = new Date(hourly.time[i]).getHours();
|
|
hourlyData.push({
|
|
hour: h,
|
|
temp: Math.round(hourly.temperature_2m[i]),
|
|
precipitation_probability: hourly.precipitation_probability![i] || 0,
|
|
precipitation: hourly.precipitation![i] || 0,
|
|
main: WMO_MAP[hourly.weathercode![i]] || 'Clouds',
|
|
wind: Math.round(hourly.windspeed_10m![i] || 0),
|
|
humidity: Math.round(hourly.relativehumidity_2m![i] || 0),
|
|
});
|
|
}
|
|
}
|
|
|
|
const result: WeatherResult = {
|
|
type: 'forecast',
|
|
temp: Math.round((daily.temperature_2m_max[dayIdx] + daily.temperature_2m_min[dayIdx]) / 2),
|
|
temp_max: Math.round(daily.temperature_2m_max[dayIdx]),
|
|
temp_min: Math.round(daily.temperature_2m_min[dayIdx]),
|
|
main: WMO_MAP[code] || 'Clouds',
|
|
description: descriptions[code] || '',
|
|
sunrise: formatTime(daily.sunrise![dayIdx]),
|
|
sunset: formatTime(daily.sunset![dayIdx]),
|
|
precipitation_sum: daily.precipitation_sum![dayIdx] || 0,
|
|
precipitation_probability_max: daily.precipitation_probability_max![dayIdx] || 0,
|
|
wind_max: Math.round(daily.windspeed_10m_max![dayIdx] || 0),
|
|
hourly: hourlyData,
|
|
};
|
|
|
|
setCache(ck, result, TTL_FORECAST_MS);
|
|
return result;
|
|
}
|
|
|
|
// ── ApiError ────────────────────────────────────────────────────────────
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
}
|
|
}
|