feat: geo-device-service, Weather по геопозиции, Discover вкладка GooSeek
Geo Device Service: - Новый сервис определения геопозиции, устройства, браузера - geoip-lite, ua-parser-js, CORS - GET/POST /api/context Frontend: - /api/geo-context — прокси к geo-device, fallback при недоступности - geoDevice.ts — fetchContextWithClient, fetchContextWithGeolocation - Weather: геопозиция через geo-device + GeoJS + ipwhois fallback - Weather API: поддержка city (геокодинг Open-Meteo) - Discover: вкладка GooSeek по умолчанию Документация: - MICROSERVICES.md — секция 3.9 Geo Device Service Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
78
apps/frontend/src/app/api/geo-context/route.ts
Normal file
78
apps/frontend/src/app/api/geo-context/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GEO_DEVICE_URL =
|
||||
process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002';
|
||||
|
||||
const fallbackContext = (userAgent: string) => ({
|
||||
geo: null,
|
||||
device: { type: 'unknown' as const },
|
||||
browser: { name: 'Unknown', version: '', full: '' },
|
||||
os: { name: 'Unknown', version: '', full: '' },
|
||||
client: {},
|
||||
userAgent,
|
||||
requestedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const forwarded = req.headers.get('x-forwarded-for');
|
||||
const realIP = req.headers.get('x-real-ip');
|
||||
|
||||
const res = await fetch(`${GEO_DEVICE_URL}/api/context`, {
|
||||
headers: {
|
||||
'x-forwarded-for': forwarded ?? '',
|
||||
'x-real-ip': realIP ?? '',
|
||||
'user-agent': req.headers.get('user-agent') ?? '',
|
||||
'accept-language': req.headers.get('accept-language') ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
fallbackContext(req.headers.get('user-agent') ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
fallbackContext(req.headers.get('user-agent') ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const forwarded = req.headers.get('x-forwarded-for');
|
||||
const realIP = req.headers.get('x-real-ip');
|
||||
|
||||
const res = await fetch(`${GEO_DEVICE_URL}/api/context`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-forwarded-for': forwarded ?? '',
|
||||
'x-real-ip': realIP ?? '',
|
||||
'user-agent': req.headers.get('user-agent') ?? '',
|
||||
'accept-language': req.headers.get('accept-language') ?? '',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
fallbackContext(req.headers.get('user-agent') ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
fallbackContext(req.headers.get('user-agent') ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,50 @@
|
||||
const geocodeCity = async (city: string): Promise<{ lat: number; lng: number; name: string } | null> => {
|
||||
const res = await fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`,
|
||||
);
|
||||
const data = await res.json();
|
||||
const result = data.results?.[0];
|
||||
if (!result?.latitude || !result?.longitude) return null;
|
||||
return {
|
||||
lat: result.latitude,
|
||||
lng: result.longitude,
|
||||
name: result.name ?? city,
|
||||
};
|
||||
};
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
try {
|
||||
const body: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
city?: string;
|
||||
measureUnit: 'Imperial' | 'Metric';
|
||||
} = await req.json();
|
||||
|
||||
if (!body.lat || !body.lng) {
|
||||
let lat: number;
|
||||
let lng: number;
|
||||
let cityName: string | undefined = body.city;
|
||||
|
||||
if (body.lat != null && body.lng != null) {
|
||||
lat = body.lat;
|
||||
lng = body.lng;
|
||||
} else if (body.city?.trim()) {
|
||||
const geo = await geocodeCity(body.city.trim());
|
||||
if (!geo) {
|
||||
return Response.json({ message: 'City not found.' }, { status: 404 });
|
||||
}
|
||||
lat = geo.lat;
|
||||
lng = geo.lng;
|
||||
cityName = geo.name;
|
||||
} else {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Invalid request.',
|
||||
},
|
||||
{ message: 'Invalid request. Provide lat/lng or city.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}¤t=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
|
||||
body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit'
|
||||
}${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`,
|
||||
);
|
||||
@@ -159,7 +187,7 @@ export const POST = async (req: Request) => {
|
||||
break;
|
||||
}
|
||||
|
||||
return Response.json(weather);
|
||||
return Response.json({ ...weather, city: cityName });
|
||||
} catch (err) {
|
||||
console.error('An error occurred while getting home widgets', err);
|
||||
return Response.json(
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface Discover {
|
||||
}
|
||||
|
||||
const topics: { key: string; display: string }[] = [
|
||||
{
|
||||
display: 'GooSeek',
|
||||
key: 'gooseek',
|
||||
},
|
||||
{
|
||||
display: 'Tech & Science',
|
||||
key: 'tech',
|
||||
@@ -39,7 +43,7 @@ const topics: { key: string; display: string }[] = [
|
||||
|
||||
const Page = () => {
|
||||
const [discover, setDiscover] = useState<Discover[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
|
||||
const [setupRequired, setSetupRequired] = useState(false);
|
||||
|
||||
@@ -78,6 +82,12 @@ const Page = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTopic === 'gooseek') {
|
||||
setLoading(false);
|
||||
setDiscover(null);
|
||||
setSetupRequired(false);
|
||||
return;
|
||||
}
|
||||
fetchArticles(activeTopic);
|
||||
}, [activeTopic]);
|
||||
|
||||
@@ -133,6 +143,8 @@ const Page = () => {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : activeTopic === 'gooseek' ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4" />
|
||||
) : setupRequired ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 px-4 text-center">
|
||||
<div className="rounded-full p-4 bg-cyan-300/10 dark:bg-cyan-300/5">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Cloud, Sun, CloudRain, CloudSnow, Wind } from 'lucide-react';
|
||||
import { Wind } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchContextWithGeolocation } from '@/lib/geoDevice';
|
||||
|
||||
const WeatherWidget = () => {
|
||||
const [data, setData] = useState({
|
||||
@@ -14,103 +15,155 @@ const WeatherWidget = () => {
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const getApproxLocation = async () => {
|
||||
const res = await fetch('https://ipwhois.app/json/');
|
||||
const data = await res.json();
|
||||
|
||||
return {
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
city: data.city,
|
||||
};
|
||||
};
|
||||
|
||||
const getLocation = async (
|
||||
callback: (location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city: string;
|
||||
}) => void,
|
||||
const fetchWeather = async (
|
||||
lat: number,
|
||||
lng: number,
|
||||
city: string,
|
||||
) => {
|
||||
if (navigator.geolocation) {
|
||||
const result = await navigator.permissions.query({
|
||||
name: 'geolocation',
|
||||
});
|
||||
const res = await fetch('/api/weather', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lat,
|
||||
lng,
|
||||
city: city || undefined,
|
||||
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.state === 'granted') {
|
||||
navigator.geolocation.getCurrentPosition(async (position) => {
|
||||
const res = await fetch(
|
||||
`https://api-bdc.io/data/reverse-geocode-client?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}&localityLanguage=en`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
const weatherData = await res.json();
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
callback({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
city: data.locality,
|
||||
});
|
||||
});
|
||||
} else if (result.state === 'prompt') {
|
||||
callback(await getApproxLocation());
|
||||
navigator.geolocation.getCurrentPosition((position) => {});
|
||||
} else if (result.state === 'denied') {
|
||||
callback(await getApproxLocation());
|
||||
}
|
||||
} else {
|
||||
callback(await getApproxLocation());
|
||||
if (res.status !== 200) {
|
||||
throw new Error(weatherData.message ?? 'Weather fetch failed');
|
||||
}
|
||||
|
||||
setData({
|
||||
temperature: weatherData.temperature,
|
||||
condition: weatherData.condition,
|
||||
location: weatherData.city ?? city ?? 'Unknown',
|
||||
humidity: weatherData.humidity,
|
||||
windSpeed: weatherData.windSpeed,
|
||||
icon: weatherData.icon,
|
||||
temperatureUnit: weatherData.temperatureUnit,
|
||||
windSpeedUnit: weatherData.windSpeedUnit,
|
||||
});
|
||||
};
|
||||
|
||||
const updateWeather = async () => {
|
||||
getLocation(async (location) => {
|
||||
const res = await fetch(`/api/weather`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
lat: location.latitude,
|
||||
lng: location.longitude,
|
||||
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
|
||||
}),
|
||||
});
|
||||
setError(false);
|
||||
setLoading(true);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error('Error fetching weather data');
|
||||
setLoading(false);
|
||||
return;
|
||||
try {
|
||||
let context: Awaited<ReturnType<typeof fetchContextWithGeolocation>> | null =
|
||||
null;
|
||||
try {
|
||||
context = await fetchContextWithGeolocation();
|
||||
} catch {
|
||||
// geo-device не запущен (503) — пропускаем
|
||||
}
|
||||
|
||||
setData({
|
||||
temperature: data.temperature,
|
||||
condition: data.condition,
|
||||
location: location.city,
|
||||
humidity: data.humidity,
|
||||
windSpeed: data.windSpeed,
|
||||
icon: data.icon,
|
||||
temperatureUnit: data.temperatureUnit,
|
||||
windSpeedUnit: data.windSpeedUnit,
|
||||
});
|
||||
if (context?.geo?.latitude != null && context.geo.longitude != null) {
|
||||
const city =
|
||||
context.geo.city ||
|
||||
(await reverseGeocode(context.geo.latitude, context.geo.longitude));
|
||||
await fetchWeather(
|
||||
context.geo.latitude,
|
||||
context.geo.longitude,
|
||||
city,
|
||||
);
|
||||
} else {
|
||||
await tryIpFallback();
|
||||
}
|
||||
} catch {
|
||||
await tryIpFallback();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const reverseGeocode = async (
|
||||
lat: number,
|
||||
lng: number,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api-bdc.io/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`,
|
||||
);
|
||||
const d = await res.json();
|
||||
return d?.locality ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const tryIpFallback = async () => {
|
||||
const providers: Array<{
|
||||
url: string;
|
||||
getCoords: (d: Record<string, unknown>) => { lat: number; lng: number; city: string } | null;
|
||||
}> = [
|
||||
{
|
||||
url: 'https://get.geojs.io/v1/ip/geo.json',
|
||||
getCoords: (d) => {
|
||||
const lat = Number(d.latitude);
|
||||
const lng = Number(d.longitude);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
city: String(d.city ?? d.region ?? '').trim() || 'Unknown',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
url: 'https://ipwhois.app/json/',
|
||||
getCoords: (d) => {
|
||||
const lat = Number(d.latitude);
|
||||
const lng = Number(d.longitude);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
city: String(d.city ?? '').trim() || 'Unknown',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of providers) {
|
||||
try {
|
||||
const res = await fetch(p.url);
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
const coords = p.getCoords(d);
|
||||
if (coords) {
|
||||
await fetchWeather(coords.lat, coords.lng, coords.city || 'Unknown');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// следующий провайдер
|
||||
}
|
||||
}
|
||||
setError(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateWeather();
|
||||
const intervalId = setInterval(updateWeather, 30 * 1000);
|
||||
const intervalId = setInterval(updateWeather, 30 * 60 * 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3">
|
||||
{loading ? (
|
||||
{error ? (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-black/50 dark:text-white/50">
|
||||
Weather unavailable
|
||||
</div>
|
||||
) : loading ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center w-16 min-w-16 max-w-16 h-full animate-pulse">
|
||||
<div className="h-10 w-10 rounded-full bg-light-200 dark:bg-dark-200 mb-2" />
|
||||
|
||||
131
apps/frontend/src/lib/geoDevice.ts
Normal file
131
apps/frontend/src/lib/geoDevice.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Клиент для Geo Device Service.
|
||||
* Собирает данные с клиента и отправляет на сервис для полного контекста.
|
||||
*/
|
||||
|
||||
const GEO_CONTEXT_API = '/api/geo-context';
|
||||
|
||||
export interface GeoDeviceContext {
|
||||
geo: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
region: string;
|
||||
city: string;
|
||||
timezone: string;
|
||||
source: string;
|
||||
} | null;
|
||||
device: {
|
||||
type: 'desktop' | 'mobile' | 'tablet' | 'wearable' | 'smarttv' | 'unknown';
|
||||
vendor?: string;
|
||||
model?: string;
|
||||
};
|
||||
browser: { name: string; version: string; full: string };
|
||||
os: { name: string; version: string; full: string };
|
||||
client: Record<string, unknown>;
|
||||
ip?: string;
|
||||
userAgent: string;
|
||||
acceptLanguage?: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
const collectClientData = (): {
|
||||
geo?: { latitude: number; longitude: number; city?: string; country?: string; timezone?: string };
|
||||
client?: {
|
||||
screenWidth?: number;
|
||||
screenHeight?: number;
|
||||
viewportWidth?: number;
|
||||
viewportHeight?: number;
|
||||
devicePixelRatio?: number;
|
||||
timezone?: string;
|
||||
language?: string;
|
||||
languages?: string[];
|
||||
platform?: string;
|
||||
hardwareConcurrency?: number;
|
||||
deviceMemory?: number;
|
||||
cookieEnabled?: boolean;
|
||||
doNotTrack?: string | null;
|
||||
};
|
||||
} => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
const nav = navigator;
|
||||
const screen = window.screen;
|
||||
|
||||
return {
|
||||
client: {
|
||||
screenWidth: screen?.width,
|
||||
screenHeight: screen?.height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
language: nav.language,
|
||||
languages: nav.languages ? [...nav.languages] : undefined,
|
||||
platform: nav.platform,
|
||||
hardwareConcurrency: (nav as Navigator & { hardwareConcurrency?: number }).hardwareConcurrency,
|
||||
deviceMemory: (nav as Navigator & { deviceMemory?: number }).deviceMemory,
|
||||
cookieEnabled: nav.cookieEnabled,
|
||||
doNotTrack: nav.doNotTrack ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Получить контекст (только серверные данные, вызывается с API route).
|
||||
*/
|
||||
export const fetchContext = async (): Promise<GeoDeviceContext> => {
|
||||
const res = await fetch(GEO_CONTEXT_API);
|
||||
if (!res.ok) throw new Error('Failed to fetch context');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Получить полный контекст с данными клиента (вызывать из браузера).
|
||||
*/
|
||||
export const fetchContextWithClient = async (): Promise<GeoDeviceContext> => {
|
||||
const clientData = collectClientData();
|
||||
|
||||
const res = await fetch(GEO_CONTEXT_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(clientData),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to fetch context');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* С геолокацией из браузера (если пользователь разрешил).
|
||||
*/
|
||||
export const fetchContextWithGeolocation = async (): Promise<GeoDeviceContext> => {
|
||||
const clientData = collectClientData();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
fetchContextWithClient().then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const geo = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
fetch(GEO_CONTEXT_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...clientData, geo }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
},
|
||||
() => fetchContextWithClient().then(resolve).catch(reject),
|
||||
);
|
||||
});
|
||||
};
|
||||
5
apps/geo-device-service/.gitignore
vendored
Normal file
5
apps/geo-device-service/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
57
apps/geo-device-service/README.md
Normal file
57
apps/geo-device-service/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Geo Device Service
|
||||
|
||||
Сервис определения геопозиции, устройства и браузера пользователя.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Геолокация по IP** — offline lookup через geoip-lite (MaxMind GeoLite)
|
||||
- **Device** — тип (desktop/mobile/tablet), vendor, model
|
||||
- **Browser** — имя, версия
|
||||
- **OS** — операционная система и версия
|
||||
- **Client context** — при POST с body: screen size, timezone, language, hardwareConcurrency, deviceMemory, doNotTrack
|
||||
|
||||
## API
|
||||
|
||||
### GET /api/context
|
||||
|
||||
Возвращает контекст на основе IP и заголовков запроса.
|
||||
|
||||
### POST /api/context
|
||||
|
||||
Принимает дополнительные данные от клиента (geo из Geolocation API, размер экрана, timezone и т.д.) и объединяет с серверными данными.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"geo": {
|
||||
"latitude": 55.7558,
|
||||
"longitude": 37.6173,
|
||||
"city": "Moscow",
|
||||
"country": "Russia",
|
||||
"timezone": "Europe/Moscow"
|
||||
},
|
||||
"client": {
|
||||
"screenWidth": 1920,
|
||||
"screenHeight": 1080,
|
||||
"viewportWidth": 1920,
|
||||
"viewportHeight": 969,
|
||||
"devicePixelRatio": 2,
|
||||
"timezone": "Europe/Moscow",
|
||||
"language": "en",
|
||||
"languages": ["en", "ru"],
|
||||
"platform": "MacIntel",
|
||||
"hardwareConcurrency": 8,
|
||||
"deviceMemory": 8,
|
||||
"cookieEnabled": true,
|
||||
"doNotTrack": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# или
|
||||
PORT=4002 npm start
|
||||
```
|
||||
23
apps/geo-device-service/package.json
Normal file
23
apps/geo-device-service/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "geo-device-service",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Сервис определения геопозиции, устройства и браузера пользователя",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "npx tsx watch src/index.ts",
|
||||
"start": "npx tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"geoip-lite": "^1.4.9",
|
||||
"ua-parser-js": "^1.0.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
20
apps/geo-device-service/src/index.ts
Normal file
20
apps/geo-device-service/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import contextRouter from './routes/context.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: true }));
|
||||
const PORT = process.env.PORT || 4002;
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.use('/api', contextRouter);
|
||||
|
||||
app.get('/health', (_, res) => res.json({ status: 'ok', service: 'geo-device' }));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Geo Device Service: http://localhost:${PORT}`);
|
||||
console.log(` Context (GET): /api/context`);
|
||||
console.log(` Context (POST): /api/context (with client data in body)`);
|
||||
});
|
||||
69
apps/geo-device-service/src/lib/context.ts
Normal file
69
apps/geo-device-service/src/lib/context.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { GeoDeviceContext, GeoLocation, ClientContext } from '../types.js';
|
||||
import { lookupByIP } from './geo.js';
|
||||
import { parseUserAgent } from './device.js';
|
||||
|
||||
const getClientIP = (req: { headers: Record<string, string | string[] | undefined> }): string => {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0].trim();
|
||||
}
|
||||
if (Array.isArray(forwarded)) {
|
||||
return (forwarded[0] ?? '').split(',')[0].trim();
|
||||
}
|
||||
return (req.headers['x-real-ip'] as string) ?? '';
|
||||
};
|
||||
|
||||
export const buildContext = (
|
||||
req: { headers: Record<string, string | string[] | undefined>; socket?: { remoteAddress?: string } },
|
||||
clientData?: Partial<{
|
||||
geo: Partial<GeoLocation>;
|
||||
client: Partial<ClientContext>;
|
||||
}>,
|
||||
): GeoDeviceContext => {
|
||||
const ip = getClientIP(req) || req.socket?.remoteAddress?.replace(/^::ffff:/, '') || '';
|
||||
const userAgent = (req.headers['user-agent'] as string) ?? '';
|
||||
const acceptLanguage = (req.headers['accept-language'] as string) ?? undefined;
|
||||
|
||||
const { device, browser, os } = parseUserAgent(userAgent);
|
||||
|
||||
let geo: GeoLocation | null = clientData?.geo
|
||||
? {
|
||||
latitude: clientData.geo.latitude ?? 0,
|
||||
longitude: clientData.geo.longitude ?? 0,
|
||||
country: clientData.geo.country ?? '',
|
||||
countryCode: clientData.geo.countryCode ?? '',
|
||||
region: clientData.geo.region ?? '',
|
||||
city: clientData.geo.city ?? '',
|
||||
timezone: clientData.geo.timezone ?? '',
|
||||
source: 'client',
|
||||
}
|
||||
: lookupByIP(ip);
|
||||
|
||||
const client: ClientContext = {
|
||||
screenWidth: clientData?.client?.screenWidth,
|
||||
screenHeight: clientData?.client?.screenHeight,
|
||||
viewportWidth: clientData?.client?.viewportWidth,
|
||||
viewportHeight: clientData?.client?.viewportHeight,
|
||||
devicePixelRatio: clientData?.client?.devicePixelRatio,
|
||||
timezone: clientData?.client?.timezone,
|
||||
language: clientData?.client?.language,
|
||||
languages: clientData?.client?.languages,
|
||||
platform: clientData?.client?.platform,
|
||||
hardwareConcurrency: clientData?.client?.hardwareConcurrency,
|
||||
deviceMemory: clientData?.client?.deviceMemory,
|
||||
cookieEnabled: clientData?.client?.cookieEnabled,
|
||||
doNotTrack: clientData?.client?.doNotTrack,
|
||||
};
|
||||
|
||||
return {
|
||||
geo,
|
||||
device,
|
||||
browser,
|
||||
os,
|
||||
client,
|
||||
ip: ip || undefined,
|
||||
userAgent,
|
||||
acceptLanguage,
|
||||
requestedAt: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
35
apps/geo-device-service/src/lib/device.ts
Normal file
35
apps/geo-device-service/src/lib/device.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import UAParser from 'ua-parser-js';
|
||||
import type { DeviceInfo, BrowserInfo, OSInfo } from '../types.js';
|
||||
|
||||
export const parseUserAgent = (ua: string): {
|
||||
device: DeviceInfo;
|
||||
browser: BrowserInfo;
|
||||
os: OSInfo;
|
||||
} => {
|
||||
const parser = new UAParser(ua);
|
||||
const result = parser.getResult();
|
||||
|
||||
const deviceType = result.device.type as DeviceInfo['type'] | undefined;
|
||||
const type: DeviceInfo['type'] =
|
||||
deviceType && ['mobile', 'tablet', 'wearable', 'smarttv', 'desktop'].includes(deviceType)
|
||||
? deviceType
|
||||
: 'desktop';
|
||||
|
||||
return {
|
||||
device: {
|
||||
type,
|
||||
vendor: result.device.vendor || undefined,
|
||||
model: result.device.model || undefined,
|
||||
},
|
||||
browser: {
|
||||
name: result.browser.name ?? 'Unknown',
|
||||
version: result.browser.version ?? '',
|
||||
full: [result.browser.name, result.browser.version].filter(Boolean).join(' '),
|
||||
},
|
||||
os: {
|
||||
name: result.os.name ?? 'Unknown',
|
||||
version: result.os.version ?? '',
|
||||
full: [result.os.name, result.os.version].filter(Boolean).join(' '),
|
||||
},
|
||||
};
|
||||
};
|
||||
22
apps/geo-device-service/src/lib/geo.ts
Normal file
22
apps/geo-device-service/src/lib/geo.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import geoip from 'geoip-lite';
|
||||
import type { GeoLocation } from '../types.js';
|
||||
|
||||
export const lookupByIP = (ip: string): GeoLocation | null => {
|
||||
if (!ip || ip === '::1' || ip === '127.0.0.1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = geoip.lookup(ip);
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
latitude: result.ll?.[0] ?? 0,
|
||||
longitude: result.ll?.[1] ?? 0,
|
||||
country: result.country ?? '',
|
||||
countryCode: result.country ?? '',
|
||||
region: result.region ?? '',
|
||||
city: result.city ?? '',
|
||||
timezone: result.timezone ?? '',
|
||||
source: 'ip',
|
||||
};
|
||||
};
|
||||
54
apps/geo-device-service/src/routes/context.ts
Normal file
54
apps/geo-device-service/src/routes/context.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { buildContext } from '../lib/context.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface ClientBody {
|
||||
geo?: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
client?: {
|
||||
screenWidth?: number;
|
||||
screenHeight?: number;
|
||||
viewportWidth?: number;
|
||||
viewportHeight?: number;
|
||||
devicePixelRatio?: number;
|
||||
timezone?: string;
|
||||
language?: string;
|
||||
languages?: string[];
|
||||
platform?: string;
|
||||
hardwareConcurrency?: number;
|
||||
deviceMemory?: number;
|
||||
cookieEnabled?: boolean;
|
||||
doNotTrack?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/context', (req: Request, res: Response) => {
|
||||
try {
|
||||
const context = buildContext(req);
|
||||
res.json(context);
|
||||
} catch (err) {
|
||||
console.error('Error building context:', err);
|
||||
res.status(500).json({ error: 'Failed to build context' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/context', (req: Request<object, object, ClientBody>, res: Response) => {
|
||||
try {
|
||||
const clientData = req.body;
|
||||
const context = buildContext(req, clientData);
|
||||
res.json(context);
|
||||
} catch (err) {
|
||||
console.error('Error building context:', err);
|
||||
res.status(500).json({ error: 'Failed to build context' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
56
apps/geo-device-service/src/types.ts
Normal file
56
apps/geo-device-service/src/types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface GeoLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
region: string;
|
||||
city: string;
|
||||
timezone: string;
|
||||
source: 'ip' | 'client' | 'unknown';
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
type: 'desktop' | 'mobile' | 'tablet' | 'wearable' | 'smarttv' | 'unknown';
|
||||
vendor?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface BrowserInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
full: string;
|
||||
}
|
||||
|
||||
export interface OSInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
full: string;
|
||||
}
|
||||
|
||||
export interface ClientContext {
|
||||
screenWidth?: number;
|
||||
screenHeight?: number;
|
||||
viewportWidth?: number;
|
||||
viewportHeight?: number;
|
||||
devicePixelRatio?: number;
|
||||
timezone?: string;
|
||||
language?: string;
|
||||
languages?: string[];
|
||||
platform?: string;
|
||||
hardwareConcurrency?: number;
|
||||
deviceMemory?: number;
|
||||
cookieEnabled?: boolean;
|
||||
doNotTrack?: string | null;
|
||||
}
|
||||
|
||||
export interface GeoDeviceContext {
|
||||
geo: GeoLocation | null;
|
||||
device: DeviceInfo;
|
||||
browser: BrowserInfo;
|
||||
os: OSInfo;
|
||||
client: ClientContext;
|
||||
ip?: string;
|
||||
userAgent: string;
|
||||
acceptLanguage?: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
13
apps/geo-device-service/tsconfig.json
Normal file
13
apps/geo-device-service/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -233,6 +233,29 @@
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Geo Device Service (реализован)
|
||||
|
||||
**Назначение:** Определение геопозиции пользователя, типа устройства, браузера и ОС.
|
||||
|
||||
**Расположение:** `apps/geo-device-service/`
|
||||
|
||||
**API:**
|
||||
- `GET /api/context` — контекст по IP и заголовкам (User-Agent, Accept-Language)
|
||||
- `POST /api/context` — контекст с дополнительными данными от клиента (screen, timezone, Geolocation API)
|
||||
|
||||
**Данные:**
|
||||
- **geo** — latitude, longitude, city, country, timezone (geoip-lite по IP или из body)
|
||||
- **device** — type (desktop/mobile/tablet), vendor, model
|
||||
- **browser** — name, version (ua-parser-js)
|
||||
- **os** — name, version
|
||||
- **client** — screenWidth, viewportHeight, timezone, language, hardwareConcurrency, deviceMemory, doNotTrack (из POST body)
|
||||
|
||||
**Запуск:** `npm run dev:geo` или `PORT=4002 npm run start -w geo-device-service`
|
||||
|
||||
**Интеграция:** Frontend вызывает `/api/geo-context` (проксирует к сервису), клиент — `fetchContextWithClient()` из `@/lib/geoDevice`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Матрица зависимостей
|
||||
|
||||
| Сервис | От кого получает вызовы | Кого вызывает |
|
||||
@@ -262,6 +285,7 @@ gooseek/
|
||||
│ ├── uploads-service/ # UploadManager, UploadStore
|
||||
│ ├── storage-service/ # DB, config
|
||||
│ ├── llm-proxy/ # Models registry, providers
|
||||
│ ├── geo-device-service/ # Геопозиция, устройство, браузер
|
||||
│ ├── shared-types/ # Общие типы, DTO
|
||||
│ └── shared-utils/ # formatHistory, splitText, computeSimilarity
|
||||
├── docker-compose.yaml
|
||||
|
||||
199
package-lock.json
generated
199
package-lock.json
generated
@@ -352,6 +352,21 @@
|
||||
"@napi-rs/canvas": "^0.1.87"
|
||||
}
|
||||
},
|
||||
"apps/geo-device-service": {
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"geoip-lite": "^1.4.9",
|
||||
"ua-parser-js": "^1.0.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"apps/posts-mcs": {
|
||||
"name": "posts-microservice",
|
||||
"version": "1.0.0",
|
||||
@@ -3664,6 +3679,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ua-parser-js": {
|
||||
"version": "0.7.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz",
|
||||
"integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -4433,6 +4455,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
|
||||
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.14"
|
||||
}
|
||||
},
|
||||
"node_modules/async-function": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
||||
@@ -4841,7 +4872,6 @@
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -5073,7 +5103,6 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
@@ -5245,7 +5274,6 @@
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
@@ -5331,6 +5359,23 @@
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -6806,6 +6851,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
@@ -7174,7 +7228,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
@@ -7333,6 +7386,72 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/geo-device-service": {
|
||||
"resolved": "apps/geo-device-service",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/geoip-lite": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.10.tgz",
|
||||
"integrity": "sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"async": "2.1 - 2.6.4",
|
||||
"chalk": "4.1 - 4.1.2",
|
||||
"iconv-lite": "0.4.13 - 0.6.3",
|
||||
"ip-address": "5.8.9 - 5.9.4",
|
||||
"lazy": "1.0.11",
|
||||
"rimraf": "2.5.2 - 2.7.1",
|
||||
"yauzl": "2.9.2 - 2.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geoip-lite/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/geoip-lite/node_modules/rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/geoip-lite/node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -7619,7 +7738,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7876,7 +7994,6 @@
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
@@ -7916,6 +8033,26 @@
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "5.9.4",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz",
|
||||
"integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"lodash": "^4.17.15",
|
||||
"sprintf-js": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address/node_modules/sprintf-js": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
|
||||
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -8504,6 +8641,12 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsbn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
|
||||
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
@@ -8670,6 +8813,15 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/lazy": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz",
|
||||
"integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ldap-authentication": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/ldap-authentication/-/ldap-authentication-3.3.6.tgz",
|
||||
@@ -8781,6 +8933,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -9046,7 +9204,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@@ -9461,7 +9618,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -12069,7 +12225,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
@@ -13115,6 +13270,32 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.41",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"format:write": "prettier . --write",
|
||||
"docker:build": "docker build -f docker/Dockerfile -t gooseek:latest .",
|
||||
"docker:up": "docker compose -f docker/docker-compose.yaml up -d",
|
||||
"docker:down": "docker compose -f docker/docker-compose.yaml down"
|
||||
"docker:down": "docker compose -f docker/docker-compose.yaml down",
|
||||
"dev:geo": "npm run dev -w geo-device-service"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.2.5"
|
||||
|
||||
Reference in New Issue
Block a user