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:
home
2026-02-20 17:20:14 +03:00
parent 783569b8e7
commit 8ba3f5495a
18 changed files with 958 additions and 96 deletions

View 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') ?? ''),
);
}
}

View File

@@ -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) => { export const POST = async (req: Request) => {
try { try {
const body: { const body: {
lat: number; lat?: number;
lng: number; lng?: number;
city?: string;
measureUnit: 'Imperial' | 'Metric'; measureUnit: 'Imperial' | 'Metric';
} = await req.json(); } = 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( return Response.json(
{ { message: 'Invalid request. Provide lat/lng or city.' },
message: 'Invalid request.',
},
{ status: 400 }, { status: 400 },
); );
} }
const res = await fetch( const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}&current=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}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit' body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit'
}${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`, }${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`,
); );
@@ -159,7 +187,7 @@ export const POST = async (req: Request) => {
break; break;
} }
return Response.json(weather); return Response.json({ ...weather, city: cityName });
} catch (err) { } catch (err) {
console.error('An error occurred while getting home widgets', err); console.error('An error occurred while getting home widgets', err);
return Response.json( return Response.json(

View File

@@ -15,6 +15,10 @@ export interface Discover {
} }
const topics: { key: string; display: string }[] = [ const topics: { key: string; display: string }[] = [
{
display: 'GooSeek',
key: 'gooseek',
},
{ {
display: 'Tech & Science', display: 'Tech & Science',
key: 'tech', key: 'tech',
@@ -39,7 +43,7 @@ const topics: { key: string; display: string }[] = [
const Page = () => { const Page = () => {
const [discover, setDiscover] = useState<Discover[] | null>(null); 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 [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
const [setupRequired, setSetupRequired] = useState(false); const [setupRequired, setSetupRequired] = useState(false);
@@ -78,6 +82,12 @@ const Page = () => {
}; };
useEffect(() => { useEffect(() => {
if (activeTopic === 'gooseek') {
setLoading(false);
setDiscover(null);
setSetupRequired(false);
return;
}
fetchArticles(activeTopic); fetchArticles(activeTopic);
}, [activeTopic]); }, [activeTopic]);
@@ -133,6 +143,8 @@ const Page = () => {
/> />
</svg> </svg>
</div> </div>
) : activeTopic === 'gooseek' ? (
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4" />
) : setupRequired ? ( ) : setupRequired ? (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 px-4 text-center"> <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"> <div className="rounded-full p-4 bg-cyan-300/10 dark:bg-cyan-300/5">

View File

@@ -1,5 +1,6 @@
import { Cloud, Sun, CloudRain, CloudSnow, Wind } from 'lucide-react'; import { Wind } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { fetchContextWithGeolocation } from '@/lib/geoDevice';
const WeatherWidget = () => { const WeatherWidget = () => {
const [data, setData] = useState({ const [data, setData] = useState({
@@ -14,103 +15,155 @@ const WeatherWidget = () => {
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const getApproxLocation = async () => { const fetchWeather = async (
const res = await fetch('https://ipwhois.app/json/'); lat: number,
const data = await res.json(); lng: number,
city: string,
return {
latitude: data.latitude,
longitude: data.longitude,
city: data.city,
};
};
const getLocation = async (
callback: (location: {
latitude: number;
longitude: number;
city: string;
}) => void,
) => { ) => {
if (navigator.geolocation) { const res = await fetch('/api/weather', {
const result = await navigator.permissions.query({ method: 'POST',
name: 'geolocation', headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify({
lat,
lng,
city: city || undefined,
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
}),
});
if (result.state === 'granted') { const weatherData = await res.json();
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 data = await res.json(); if (res.status !== 200) {
throw new Error(weatherData.message ?? 'Weather fetch failed');
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());
} }
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 () => { const updateWeather = async () => {
getLocation(async (location) => { setError(false);
const res = await fetch(`/api/weather`, { setLoading(true);
method: 'POST',
body: JSON.stringify({
lat: location.latitude,
lng: location.longitude,
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
}),
});
const data = await res.json(); try {
let context: Awaited<ReturnType<typeof fetchContextWithGeolocation>> | null =
if (res.status !== 200) { null;
console.error('Error fetching weather data'); try {
setLoading(false); context = await fetchContextWithGeolocation();
return; } catch {
// geo-device не запущен (503) — пропускаем
} }
setData({ if (context?.geo?.latitude != null && context.geo.longitude != null) {
temperature: data.temperature, const city =
condition: data.condition, context.geo.city ||
location: location.city, (await reverseGeocode(context.geo.latitude, context.geo.longitude));
humidity: data.humidity, await fetchWeather(
windSpeed: data.windSpeed, context.geo.latitude,
icon: data.icon, context.geo.longitude,
temperatureUnit: data.temperatureUnit, city,
windSpeedUnit: data.windSpeedUnit, );
}); } else {
await tryIpFallback();
}
} catch {
await tryIpFallback();
} finally {
setLoading(false); 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(() => { useEffect(() => {
updateWeather(); updateWeather();
const intervalId = setInterval(updateWeather, 30 * 1000); const intervalId = setInterval(updateWeather, 30 * 60 * 1000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, []); }, []);
return ( 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"> <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="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" /> <div className="h-10 w-10 rounded-full bg-light-200 dark:bg-dark-200 mb-2" />

View 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
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

View 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
```

View 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"
}
}

View 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)`);
});

View 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(),
};
};

View 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(' '),
},
};
};

View 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',
};
};

View 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;

View 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;
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}

View File

@@ -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. Матрица зависимостей ## 4. Матрица зависимостей
| Сервис | От кого получает вызовы | Кого вызывает | | Сервис | От кого получает вызовы | Кого вызывает |
@@ -262,6 +285,7 @@ gooseek/
│ ├── uploads-service/ # UploadManager, UploadStore │ ├── uploads-service/ # UploadManager, UploadStore
│ ├── storage-service/ # DB, config │ ├── storage-service/ # DB, config
│ ├── llm-proxy/ # Models registry, providers │ ├── llm-proxy/ # Models registry, providers
│ ├── geo-device-service/ # Геопозиция, устройство, браузер
│ ├── shared-types/ # Общие типы, DTO │ ├── shared-types/ # Общие типы, DTO
│ └── shared-utils/ # formatHistory, splitText, computeSimilarity │ └── shared-utils/ # formatHistory, splitText, computeSimilarity
├── docker-compose.yaml ├── docker-compose.yaml

199
package-lock.json generated
View File

@@ -352,6 +352,21 @@
"@napi-rs/canvas": "^0.1.87" "@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": { "apps/posts-mcs": {
"name": "posts-microservice", "name": "posts-microservice",
"version": "1.0.0", "version": "1.0.0",
@@ -3664,6 +3679,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -4433,6 +4455,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/async-function": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -4841,7 +4872,6 @@
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -5073,7 +5103,6 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.1.0", "ansi-styles": "^4.1.0",
@@ -5245,7 +5274,6 @@
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": { "node_modules/concat-stream": {
@@ -5331,6 +5359,23 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6806,6 +6851,15 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/fetch-blob": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -7174,7 +7228,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fsevents": { "node_modules/fsevents": {
@@ -7333,6 +7386,72 @@
"node": ">= 0.4" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -7619,7 +7738,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -7876,7 +7994,6 @@
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "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.", "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", "license": "ISC",
"dependencies": { "dependencies": {
"once": "^1.3.0", "once": "^1.3.0",
@@ -7916,6 +8033,26 @@
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -8504,6 +8641,12 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/json-bigint": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
@@ -8670,6 +8813,15 @@
"node": ">=0.10" "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": { "node_modules/ldap-authentication": {
"version": "3.3.6", "version": "3.3.6",
"resolved": "https://registry.npmjs.org/ldap-authentication/-/ldap-authentication-3.3.6.tgz", "resolved": "https://registry.npmjs.org/ldap-authentication/-/ldap-authentication-3.3.6.tgz",
@@ -8781,6 +8933,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9046,7 +9204,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@@ -9461,7 +9618,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -12069,7 +12225,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
@@ -13115,6 +13270,32 @@
"node": ">=14.17" "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": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",

View File

@@ -15,7 +15,8 @@
"format:write": "prettier . --write", "format:write": "prettier . --write",
"docker:build": "docker build -f docker/Dockerfile -t gooseek:latest .", "docker:build": "docker build -f docker/Dockerfile -t gooseek:latest .",
"docker:up": "docker compose -f docker/docker-compose.yaml up -d", "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": { "devDependencies": {
"prettier": "^3.2.5" "prettier": "^3.2.5"