From 68eb54995ff9b4f3bc39ac245b2668663878530c Mon Sep 17 00:00:00 2001 From: Bernard Ngandu <31113941+bernard-ng@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:55:36 +0200 Subject: [PATCH] Refactor point value object and add observability --- client/package-lock.json | 58 + client/package.json | 1 + client/src/App.tsx | 115 +- client/src/components/ui/toast.tsx | 116 + client/src/components/ui/toaster.tsx | 37 + client/src/components/ui/use-toast.ts | 129 + client/src/hooks/useHotspotFeed.ts | 220 +- client/src/hooks/useLeafletHeatmap.ts | 25 +- client/src/hooks/useUserLocation.ts | 4 +- client/src/locales/en/common.json | 7 + client/src/locales/fr/common.json | 7 + client/src/types/api.ts | 25 +- server/.env | 24 + server/compose.override.yaml | 7 + server/compose.yaml | 30 + server/composer.json | 7 + server/composer.lock | 2078 ++++++++++++++++- server/config/bundles.php | 3 + server/config/packages/doctrine.yaml | 6 + server/config/packages/mercure.yaml | 8 + server/config/packages/messenger.yaml | 18 + server/config/packages/monolog.yaml | 79 + server/config/packages/prod/sentry.yaml | 6 + server/config/packages/rate_limiter.yaml | 8 + server/config/packages/validator.yaml | 11 + server/config/services.yaml | 4 + server/migrations/Version20251010102708.php | 36 + server/src/Controller/SignalController.php | 109 +- server/src/DataFixtures/SignalFixtures.php | 7 +- server/src/Dto/SignalPayload.php | 14 - server/src/Entity/Signal.php | 25 +- .../src/Exception/InvalidPointException.php | 38 + .../Exception/MissingClientKeyException.php | 17 + server/src/Exception/PointTooFarException.php | 18 + .../SubmissionRateLimitedException.php | 24 + server/src/Message/SignalCreatedMessage.php | 12 + .../SignalCreatedMessageHandler.php | 67 + server/src/Payload/SignalPayload.php | 16 + server/src/Service/ClientKeyProvider.php | 59 + .../src/Service/PointProximityValidator.php | 52 + server/src/Service/SignalSnapshotBuilder.php | 33 +- server/src/Service/SignalSnapshotService.php | 60 + .../src/Service/SignalSubmissionService.php | 95 + server/src/ValueObject/Point.php | 66 + server/symfony.lock | 57 + .../tests/Functional/SignalControllerTest.php | 82 +- 46 files changed, 3691 insertions(+), 229 deletions(-) create mode 100644 client/src/components/ui/toast.tsx create mode 100644 client/src/components/ui/toaster.tsx create mode 100644 client/src/components/ui/use-toast.ts create mode 100644 server/compose.override.yaml create mode 100644 server/compose.yaml create mode 100644 server/config/packages/mercure.yaml create mode 100644 server/config/packages/messenger.yaml create mode 100644 server/config/packages/monolog.yaml create mode 100644 server/config/packages/prod/sentry.yaml create mode 100644 server/config/packages/rate_limiter.yaml create mode 100644 server/config/packages/validator.yaml create mode 100644 server/migrations/Version20251010102708.php delete mode 100644 server/src/Dto/SignalPayload.php create mode 100644 server/src/Exception/InvalidPointException.php create mode 100644 server/src/Exception/MissingClientKeyException.php create mode 100644 server/src/Exception/PointTooFarException.php create mode 100644 server/src/Exception/SubmissionRateLimitedException.php create mode 100644 server/src/Message/SignalCreatedMessage.php create mode 100644 server/src/MessageHandler/SignalCreatedMessageHandler.php create mode 100644 server/src/Payload/SignalPayload.php create mode 100644 server/src/Service/ClientKeyProvider.php create mode 100644 server/src/Service/PointProximityValidator.php create mode 100644 server/src/Service/SignalSnapshotService.php create mode 100644 server/src/Service/SignalSubmissionService.php create mode 100644 server/src/ValueObject/Point.php diff --git a/client/package-lock.json b/client/package-lock.json index f1a105c..0657b05 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "i18next": "^25.5.3", @@ -1156,6 +1157,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1241,6 +1276,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.41", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.41.tgz", diff --git a/client/package.json b/client/package.json index 2a2bb8c..5e344bb 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "i18next": "^25.5.3", diff --git a/client/src/App.tsx b/client/src/App.tsx index 4f006f2..aa25579 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,5 @@ -import { useCallback, useMemo, useState } from 'react' -import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Layers, Loader2, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react' import { useTranslation } from 'react-i18next' import { AppHeader } from '@/components/layout/AppHeader' @@ -23,12 +23,39 @@ import { useHotspotFeed } from '@/hooks/useHotspotFeed' import { useLeafletHeatmap, type TileProvider } from '@/hooks/useLeafletHeatmap' import { useUserLocation } from '@/hooks/useUserLocation' import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils' -import type { LatLng } from '@/types/api' +import type { Point } from '@/types/api' +import { Toaster } from '@/components/ui/toaster' +import { useToast } from '@/components/ui/use-toast' const RADIUS_KM = 1 +interface LocationGateProps { + title: string + message: string + actionLabel: string + onRetry: () => void + isLoading: boolean +} + +function LocationGate({ title, message, actionLabel, onRetry, isLoading }: LocationGateProps) { + return ( +
+
+
+

{title}

+

{message}

+
+ +
+
+ ) +} + export default function App() { - const [pendingSpot, setPendingSpot] = useState(null) + const [pendingSpot, setPendingSpot] = useState(null) const [isConfirmOpen, setIsConfirmOpen] = useState(false) const [isConfirming, setIsConfirming] = useState(false) const [tileProvider, setTileProvider] = useState('openstreetmap') @@ -44,6 +71,7 @@ export default function App() { } return window.innerWidth < 768 }) + const { t, i18n } = useTranslation() const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US' const distanceFormatter = useMemo( @@ -63,6 +91,13 @@ export default function App() { [t], ) + const { + location: userLocation, + error: locationError, + isRequesting: isRequestingLocation, + refresh: refreshLocation, + } = useUserLocation() + const { status, error, @@ -73,9 +108,7 @@ export default function App() { selectVisibleLatestByUser, myLatestPoint, lastUpdated, - } = useHotspotFeed() - - const { location: userLocation, error: locationError, isRequesting: isRequestingLocation } = useUserLocation() + } = useHotspotFeed({ userLocation: userLocation ?? null }) const visibleDensity = useMemo( () => selectVisibleDensity(userLocation ?? null), @@ -102,7 +135,7 @@ export default function App() { if (!myLatestPoint) { return null } - if (userLocation && distanceInKm(userLocation, myLatestPoint) > RADIUS_KM) { + if (userLocation && distanceInKm(userLocation, myLatestPoint.signalLocation) > RADIUS_KM) { return null } return myLatestPoint @@ -135,12 +168,27 @@ export default function App() { tileProvider, }) + const { toast } = useToast() + + useEffect(() => { + if (!error) { + return + } + const description = error.detail ?? t(error.key, error.values ?? {}) + toast({ + variant: 'destructive', + title: t('errors.title'), + description, + duration: 6000, + }) + }, [error, t, toast]) + const handleConfirmSignal = useCallback(async () => { if (!pendingSpot) { return } setIsConfirming(true) - const result = await submitPoint(pendingSpot.lat, pendingSpot.lng) + const result = await submitPoint(pendingSpot) setIsConfirming(false) if (result.success) { setIsConfirmOpen(false) @@ -164,7 +212,7 @@ export default function App() { const handleFocusMySignal = useCallback(() => { if (myVisibleSignal) { - focusOn({ lat: myVisibleSignal.lat, lng: myVisibleSignal.lng }, 15) + focusOn({ lat: myVisibleSignal.signalLocation.lat, lng: myVisibleSignal.signalLocation.lng }, 15) } }, [focusOn, myVisibleSignal]) @@ -209,12 +257,12 @@ export default function App() { .slice(0, 8) .map((point) => { const coordinates = t('common.coordinates', { - lat: formatCoordinate(point.lat, locale), - lng: formatCoordinate(point.lng, locale), + lat: formatCoordinate(point.signalLocation.lat, locale), + lng: formatCoordinate(point.signalLocation.lng, locale), }) const distanceLabel = userLocation ? t('activityItem.distance', { - distance: `${distanceFormatter.format(distanceInKm(userLocation, point))} km`, + distance: `${distanceFormatter.format(distanceInKm(userLocation, point.signalLocation))} km`, }) : formatTimestamp(point.createdAt, locale) return { @@ -223,7 +271,7 @@ export default function App() { subtitle: t('activityItem.user', { id: point.userKey.slice(0, 4).toUpperCase() }), timestampLabel: formatRelativeTime(point.createdAt, locale), distanceLabel, - onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15), + onFocus: () => focusOn({ lat: point.signalLocation.lat, lng: point.signalLocation.lng }, 15), } }), [visiblePoints, userLocation, focusOn, distanceFormatter, t, locale], @@ -240,10 +288,27 @@ export default function App() { : 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]', ) + if (!userLocation) { + const gateTitle = t('location.gate.title') + const gateMessage = locationError ? t(locationError) : t('location.gate.description') + const gateAction = isRequestingLocation ? t('location.gate.loading') : t('location.gate.action') + + return ( + + ) + } + return ( -
- +
+ {t('dialog.confirmSignal.longitude')} {confirmationLng}°
+

{t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}

-

- {t('dialog.confirmSignal.reach', { radius: RADIUS_KM })} -

{t('dialog.confirmSignal.cancel')} - { - event.preventDefault() - handleConfirmSignal().catch(() => undefined) - }} - > + {isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')} - + + + ) } diff --git a/client/src/components/ui/toast.tsx b/client/src/components/ui/toast.tsx new file mode 100644 index 0000000..432ca88 --- /dev/null +++ b/client/src/components/ui/toast.tsx @@ -0,0 +1,116 @@ +import * as React from 'react' +import * as ToastPrimitives from '@radix-ui/react-toast' +import { cva, type VariantProps } from 'class-variance-authority' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + 'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-lg transition-all', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: 'border-destructive/60 bg-destructive text-destructive-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + {props.children} + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +export type ToastProps = React.ComponentPropsWithoutRef +export type ToastActionElement = React.ReactElement + +export { + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/client/src/components/ui/toaster.tsx b/client/src/components/ui/toaster.tsx new file mode 100644 index 0000000..8ec63b7 --- /dev/null +++ b/client/src/components/ui/toaster.tsx @@ -0,0 +1,37 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from '@/components/ui/toast' +import { useToast } from '@/components/ui/use-toast' + +export function Toaster() { + const { toasts, dismiss } = useToast() + + return ( + + {toasts.map(({ id, title, description, action, ...props }) => ( + { + if (!open) { + dismiss(id) + } + }} + > +
+ {title ? {title} : null} + {description ? {description} : null} +
+ {action} + +
+ ))} + +
+ ) +} diff --git a/client/src/components/ui/use-toast.ts b/client/src/components/ui/use-toast.ts new file mode 100644 index 0000000..b6d02ae --- /dev/null +++ b/client/src/components/ui/use-toast.ts @@ -0,0 +1,129 @@ +import * as React from 'react' + +import type { ToastActionElement, ToastProps } from '@/components/ui/toast' + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +type ToastState = { + toasts: ToasterToast[] +} + +type ToastAction = + | { type: 'ADD_TOAST'; toast: ToasterToast } + | { type: 'UPDATE_TOAST'; toast: Partial & { id: string } } + | { type: 'DISMISS_TOAST'; toastId?: string } + | { type: 'REMOVE_TOAST'; toastId?: string } + +const TOAST_LIMIT = 5 +const TOAST_REMOVE_DELAY = 1000 + +const toastTimeouts = new Map>() + +const listeners = new Set<(state: ToastState) => void>() + +let memoryState: ToastState = { toasts: [] } + +function dispatch(action: ToastAction) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +function reducer(state: ToastState, action: ToastAction): ToastState { + switch (action.type) { + case 'ADD_TOAST': { + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + } + case 'UPDATE_TOAST': { + return { + ...state, + toasts: state.toasts.map((toast) => + toast.id === action.toast.id ? { ...toast, ...action.toast } : toast, + ), + } + } + case 'DISMISS_TOAST': { + const { toastId } = action + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((toast) => + toast.id === toastId || toastId === undefined + ? { ...toast, open: false } + : toast, + ), + } + } + case 'REMOVE_TOAST': { + if (action.toastId === undefined) { + return { ...state, toasts: [] } + } + return { + ...state, + toasts: state.toasts.filter((toast) => toast.id !== action.toastId), + } + } + default: + return state + } +} + +function addToRemoveQueue(toastId: string) { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ type: 'REMOVE_TOAST', toastId }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +function genId() { + return Math.random().toString(36).slice(2, 10) +} + +export function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.add(setState) + return () => { + listeners.delete(setState) + } + }, []) + + return { + ...state, + toast: (props: Omit) => { + const id = genId() + dispatch({ type: 'ADD_TOAST', toast: { ...props, id, open: true } }) + return id + }, + dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), + } +} + +export const toast = (props: Omit) => { + const id = genId() + dispatch({ type: 'ADD_TOAST', toast: { ...props, id, open: true } }) + return id +} diff --git a/client/src/hooks/useHotspotFeed.ts b/client/src/hooks/useHotspotFeed.ts index c817dda..31595f6 100644 --- a/client/src/hooks/useHotspotFeed.ts +++ b/client/src/hooks/useHotspotFeed.ts @@ -1,15 +1,40 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { distanceInKm } from '@/lib/utils' -import type { ApiDensityCell, ApiPoint, ApiSnapshot, FeedStatus, LatLng } from '@/types/api' +import type { + ApiDensityCell, + ApiPoint, + ApiSnapshot, + FeedStatus, + Point, + SnapshotEventPayload, +} from '@/types/api' const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals' const SNAPSHOT_LIMIT = 750 -const DEFAULT_REFRESH_MS = 7000 +const MERCURE_HUB = import.meta.env.VITE_MERCURE_HUB ?? 'http://localhost:3000/.well-known/mercure' +const MERCURE_TOPIC = import.meta.env.VITE_MERCURE_TOPIC ?? 'https://points-of-interest.local/signals' const VISIBLE_RADIUS_KM = 1 +type ProblemDetails = { + detail?: unknown +} + +function extractProblemDetail(payload: unknown): string | null { + if (payload && typeof payload === 'object') { + const { detail } = payload as ProblemDetails + if (typeof detail === 'string' && detail.trim().length > 0) { + return detail + } + } + return null +} + interface UseHotspotFeedOptions { - autoRefreshMs?: number + userLocation: Point | null + snapshotLimit?: number + mercureHub?: string + mercureTopic?: string } interface SubmitResult { @@ -19,9 +44,15 @@ interface SubmitResult { export interface FeedError { key: string values?: Record + detail?: string } -export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) { +export function useHotspotFeed({ + userLocation, + snapshotLimit = SNAPSHOT_LIMIT, + mercureHub = MERCURE_HUB, + mercureTopic = MERCURE_TOPIC, +}: UseHotspotFeedOptions) { const [status, setStatus] = useState('loading') const [error, setError] = useState(null) const [rawPoints, setRawPoints] = useState([]) @@ -32,14 +63,51 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo const statusRef = useRef('loading') const initialLoadRef = useRef(true) + const eventSourceRef = useRef(null) const setStatusSafe = useCallback((next: FeedStatus) => { statusRef.current = next setStatus(next) }, []) + const resetState = useCallback(() => { + setRawPoints([]) + setRawDensity([]) + setRawLatestByUser([]) + setLastUpdated(null) + setClientKey(null) + setError(null) + eventSourceRef.current?.close() + eventSourceRef.current = null + initialLoadRef.current = true + setStatusSafe('loading') + }, [setStatusSafe]) + + const applySnapshot = useCallback( + (snapshot: ApiSnapshot, options?: { preserveClientKey?: boolean }) => { + setRawPoints(snapshot.points) + setRawDensity(snapshot.density) + setRawLatestByUser(snapshot.latestByUser) + if (!options?.preserveClientKey) { + setClientKey(snapshot.clientKey ?? null) + } + setLastUpdated(snapshot.updatedAt ?? new Date().toISOString()) + setError(null) + initialLoadRef.current = false + if (statusRef.current !== 'posting') { + setStatusSafe('idle') + } + }, + [setStatusSafe], + ) + const fetchSnapshot = useCallback( async (options?: { silent?: boolean }) => { + if (!userLocation) { + resetState() + return + } + const silent = options?.silent ?? false const previousStatus = statusRef.current const isInitial = initialLoadRef.current @@ -53,26 +121,24 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo } try { - const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' }) + const response = await fetch(`${API_BASE}?limit=${snapshotLimit}`, { cache: 'no-store' }) if (!response.ok) { - throw new Error('feed-unavailable') + const payload = await response.json().catch(() => null) + const detail = extractProblemDetail(payload) + setError({ key: 'errors.feedUnavailable', detail: detail ?? undefined }) + if (initialLoadRef.current) { + setStatusSafe('error') + } else if (previousStatus !== 'posting') { + setStatusSafe('idle') + } + return } const data: ApiSnapshot = await response.json() - setRawPoints(data.points ?? []) - setRawDensity(data.density ?? []) - setRawLatestByUser(data.latestByUser ?? []) - setClientKey(data.clientKey ?? null) - setLastUpdated(data.updatedAt ?? new Date().toISOString()) - setError(null) - initialLoadRef.current = false - - const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle' - setStatusSafe(nextStatus) + applySnapshot(data) } catch (error) { - const message = error instanceof Error ? error.message : null - const key = message === 'feed-unavailable' ? 'errors.feedUnavailable' : 'errors.feedUnknown' - setError({ key }) + const detail = error instanceof Error && error.message ? error.message : null + setError({ key: 'errors.feedUnknown', detail: detail ?? undefined }) if (initialLoadRef.current) { setStatusSafe('error') } else if (previousStatus !== 'posting') { @@ -80,39 +146,92 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo } } }, - [setStatusSafe], + [userLocation, snapshotLimit, resetState, setStatusSafe, applySnapshot], ) - useEffect(() => { - fetchSnapshot().catch(() => undefined) - if (!autoRefreshMs) { + const connectToStream = useCallback(() => { + if (!userLocation) { return } - const interval = window.setInterval(() => { - fetchSnapshot({ silent: true }).catch(() => undefined) - }, autoRefreshMs) + try { + const url = new URL(mercureHub) + url.searchParams.append('topic', mercureTopic) + + const eventSource = new EventSource(url.toString()) + eventSource.onmessage = (event) => { + try { + const payload: SnapshotEventPayload = JSON.parse(event.data) + if (payload?.type === 'snapshot' && payload.payload) { + applySnapshot(payload.payload, { preserveClientKey: true }) + } + } catch (parseError) { + console.error('Failed to parse stream payload', parseError) + } + } + + eventSource.onerror = () => { + if (statusRef.current !== 'loading') { + setError({ key: 'errors.feedUnknown' }) + setStatusSafe('error') + } + } + + eventSourceRef.current = eventSource + } catch (connectionError) { + console.error('Unable to subscribe to live updates', connectionError) + setError({ key: 'errors.feedUnavailable' }) + setStatusSafe('error') + } + }, [applySnapshot, mercureHub, mercureTopic, setStatusSafe, userLocation]) + + useEffect(() => { + if (!userLocation) { + resetState() + return undefined + } + + fetchSnapshot().catch(() => undefined) + connectToStream() return () => { - window.clearInterval(interval) + eventSourceRef.current?.close() + eventSourceRef.current = null } - }, [autoRefreshMs, fetchSnapshot]) + }, [connectToStream, fetchSnapshot, resetState, userLocation]) const submitPoint = useCallback( - async (lat: number, lng: number): Promise => { + async (target: Point): Promise => { + if (!userLocation) { + setError({ + key: 'errors.submitWithReason', + values: { message: 'Location required before submitting.' }, + detail: 'Location required before submitting.', + }) + setStatusSafe('error') + return { success: false } + } + setStatusSafe('posting') try { const response = await fetch(API_BASE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ lat, lng }), + body: JSON.stringify({ + signalLocation: target, + userLocation, + }), }) if (!response.ok) { const payload = await response.json().catch(() => null) - const message = payload?.message as string | undefined - if (message) { - setError({ key: 'errors.submitWithReason', values: { message } }) + const detail = extractProblemDetail(payload) + if (detail) { + setError({ + key: 'errors.submitWithReason', + values: { message: detail }, + detail, + }) } else { setError({ key: 'errors.submitUnavailable' }) } @@ -120,14 +239,13 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo return { success: false } } - await fetchSnapshot({ silent: true }) setError(null) setStatusSafe('idle') return { success: true } } catch (error) { - const message = error instanceof Error ? error.message : null - if (message) { - setError({ key: 'errors.submitWithReason', values: { message } }) + const detail = error instanceof Error && error.message ? error.message : null + if (detail) { + setError({ key: 'errors.submitWithReason', values: { message: detail }, detail }) } else { setError({ key: 'errors.submitUnknown' }) } @@ -135,13 +253,13 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo return { success: false } } }, - [fetchSnapshot, setStatusSafe], + [setStatusSafe, userLocation], ) const hasClientKey = Boolean(clientKey) - const filterWithinRadius = useCallback( - (collection: T[], origin: LatLng | null) => { + const filterDensityWithinRadius = useCallback( + (collection: ApiDensityCell[], origin: Point | null) => { if (!origin) { return [] } @@ -150,19 +268,29 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo [], ) + const filterPointsWithinRadius = useCallback( + (collection: ApiPoint[], origin: Point | null) => { + if (!origin) { + return [] + } + return collection.filter((item) => distanceInKm(origin, item.signalLocation) <= VISIBLE_RADIUS_KM) + }, + [], + ) + const selectVisibleDensity = useCallback( - (origin: LatLng | null) => filterWithinRadius(rawDensity, origin), - [filterWithinRadius, rawDensity], + (origin: Point | null) => filterDensityWithinRadius(rawDensity, origin), + [filterDensityWithinRadius, rawDensity], ) const selectVisiblePoints = useCallback( - (origin: LatLng | null) => filterWithinRadius(rawPoints, origin), - [filterWithinRadius, rawPoints], + (origin: Point | null) => filterPointsWithinRadius(rawPoints, origin), + [filterPointsWithinRadius, rawPoints], ) const selectVisibleLatestByUser = useCallback( - (origin: LatLng | null) => filterWithinRadius(rawLatestByUser, origin), - [filterWithinRadius, rawLatestByUser], + (origin: Point | null) => filterPointsWithinRadius(rawLatestByUser, origin), + [filterPointsWithinRadius, rawLatestByUser], ) const myLatestPoint = useMemo(() => { diff --git a/client/src/hooks/useLeafletHeatmap.ts b/client/src/hooks/useLeafletHeatmap.ts index 6daed31..7dc70ac 100644 --- a/client/src/hooks/useLeafletHeatmap.ts +++ b/client/src/hooks/useLeafletHeatmap.ts @@ -9,7 +9,7 @@ import L, { } from 'leaflet' import 'leaflet.heat' -import type { ApiDensityCell, LatLng } from '@/types/api' +import type { ApiDensityCell, Point } from '@/types/api' type HeatPoint = [number, number, number?] @@ -24,18 +24,24 @@ type LeafletWithHeat = typeof L & { interface UseLeafletHeatmapParams { heatCells: ApiDensityCell[] - userLocation: LatLng | null - onRequestSpot?: (position: LatLng) => void + userLocation: Point | null + onRequestSpot?: (position: Point) => void tileProvider: TileProvider } interface UseLeafletHeatmapResult { mapContainerRef: MutableRefObject - focusOn: (position: LatLng, zoom?: number) => void + focusOn: (position: Point, zoom?: number) => void fitToHeat: () => void map: LeafletMap | null } +type TileSource = { + readonly url: string + readonly attribution: string + readonly options?: TileLayerOptions +} + const TILE_SOURCES = { openstreetmap: { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', @@ -49,11 +55,11 @@ const TILE_SOURCES = { zoomOffset: -1, } satisfies TileLayerOptions, }, -} as const +} as const satisfies Record export type TileProvider = keyof typeof TILE_SOURCES -const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 } +const INITIAL_VIEW: Point = { lat: 20, lng: 0 } const DEFAULT_ZOOM = 3 export function useLeafletHeatmap({ @@ -74,12 +80,13 @@ export function useLeafletHeatmap({ const createTileLayer = useCallback( (provider: TileProvider) => { const leaflet = L as LeafletWithHeat - const source = TILE_SOURCES[provider] + const source: TileSource = TILE_SOURCES[provider] + const options = source.options ?? {} return leaflet.tileLayer(source.url, { attribution: source.attribution, crossOrigin: true, maxZoom: 19, - ...(source.options ?? {}), + ...options, }) }, [], @@ -248,7 +255,7 @@ export function useLeafletHeatmap({ } }, [userLocation]) - const focusOn = useCallback((position: LatLng, zoom = 14) => { + const focusOn = useCallback((position: Point, zoom = 14) => { const map = mapRef.current if (!map) { return diff --git a/client/src/hooks/useUserLocation.ts b/client/src/hooks/useUserLocation.ts index 376d00e..fd3f085 100644 --- a/client/src/hooks/useUserLocation.ts +++ b/client/src/hooks/useUserLocation.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import type { LatLng } from '@/types/api' +import type { Point } from '@/types/api' function geolocationErrorMessage(error: GeolocationPositionError): string { switch (error.code) { @@ -16,7 +16,7 @@ function geolocationErrorMessage(error: GeolocationPositionError): string { } export function useUserLocation() { - const [location, setLocation] = useState(null) + const [location, setLocation] = useState(null) const [error, setError] = useState(null) const [isRequesting, setIsRequesting] = useState(false) const watchIdRef = useRef(null) diff --git a/client/src/locales/en/common.json b/client/src/locales/en/common.json index d9a55fb..65394a8 100644 --- a/client/src/locales/en/common.json +++ b/client/src/locales/en/common.json @@ -99,6 +99,12 @@ "timeout": "Timed out while fetching your location.", "generic": "Failed to retrieve your location.", "unsupported": "Geolocation is not supported in this browser." + }, + "gate": { + "title": "Share your location to continue", + "description": "We need your location to personalise the live map feed.", + "action": "Try location again", + "loading": "Requesting location…" } }, "activityItem": { @@ -128,6 +134,7 @@ "french": "Français" }, "errors": { + "title": "Something went wrong", "feedUnavailable": "Unable to reach the hotspot feed.", "feedUnknown": "Unknown error while loading hotspots.", "submitUnavailable": "Unable to store your signal.", diff --git a/client/src/locales/fr/common.json b/client/src/locales/fr/common.json index e8d4978..21bc045 100644 --- a/client/src/locales/fr/common.json +++ b/client/src/locales/fr/common.json @@ -99,6 +99,12 @@ "timeout": "Délai dépassé lors de la récupération de votre localisation.", "generic": "Échec de la récupération de votre localisation.", "unsupported": "La géolocalisation n'est pas prise en charge par ce navigateur." + }, + "gate": { + "title": "Partagez votre position pour continuer", + "description": "Nous avons besoin de votre localisation pour personnaliser la carte en direct.", + "action": "Relancer la localisation", + "loading": "Demande de localisation…" } }, "activityItem": { @@ -128,6 +134,7 @@ "french": "Français" }, "errors": { + "title": "Une erreur est survenue", "feedUnavailable": "Impossible d'atteindre le flux de signaux.", "feedUnknown": "Erreur inconnue lors du chargement des signaux.", "submitUnavailable": "Impossible d'enregistrer votre signal.", diff --git a/client/src/types/api.ts b/client/src/types/api.ts index 637334a..255e05f 100644 --- a/client/src/types/api.ts +++ b/client/src/types/api.ts @@ -1,9 +1,14 @@ export type FeedStatus = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing' +export interface Point { + lat: number + lng: number +} + export interface ApiPoint { id: number - lat: number - lng: number + signalLocation: Point + userLocation: Point createdAt: string userKey: string } @@ -16,17 +21,17 @@ export interface ApiDensityCell { export interface ApiSnapshot { clientKey?: string - points?: ApiPoint[] - density?: ApiDensityCell[] - latestByUser?: ApiPoint[] - totals?: { + points: ApiPoint[] + density: ApiDensityCell[] + latestByUser: ApiPoint[] + totals: { points: number contributors: number } - updatedAt?: string + updatedAt: string } -export type LatLng = { - lat: number - lng: number +export interface SnapshotEventPayload { + type: 'snapshot' + payload: ApiSnapshot } diff --git a/server/.env b/server/.env index 4243c9e..f81eec7 100644 --- a/server/.env +++ b/server/.env @@ -35,3 +35,27 @@ DATABASE_URL="sqlite:///%kernel.project_dir%/var/points.sqlite" ###> nelmio/cors-bundle ### CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###< nelmio/cors-bundle ### + +###> symfony/mercure-bundle ### +# See https://symfony.com/doc/current/mercure.html#configuration +# The URL of the Mercure hub, used by the app to publish updates (can be a local URL) +MERCURE_URL=http://mercure:3000/.well-known/mercure +# The public URL of the Mercure hub, used by the browser to connect +MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure +# The secret used to sign the JWTs +MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" +# Topic used for live signal updates +MERCURE_SIGNALS_TOPIC=https://points-of-interest.local/signals +###< symfony/mercure-bundle ### + +###> symfony/messenger ### +# Choose one of the transports below +# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages +# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +###< symfony/messenger ### + +###> sentry/sentry-symfony ### +# Provide the DSN when deploying to production to enable error tracking +SENTRY_DSN= +###< sentry/sentry-symfony ### diff --git a/server/compose.override.yaml b/server/compose.override.yaml new file mode 100644 index 0000000..355576e --- /dev/null +++ b/server/compose.override.yaml @@ -0,0 +1,7 @@ + +services: +###> symfony/mercure-bundle ### + mercure: + ports: + - "3000:80" +###< symfony/mercure-bundle ### diff --git a/server/compose.yaml b/server/compose.yaml new file mode 100644 index 0000000..7379e84 --- /dev/null +++ b/server/compose.yaml @@ -0,0 +1,30 @@ + +services: +###> symfony/mercure-bundle ### + mercure: + image: dunglas/mercure + restart: unless-stopped + environment: + SERVER_NAME: ':80' + MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + # Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive + MERCURE_EXTRA_DIRECTIVES: | + cors_origins http://127.0.0.1:8000 http://localhost:8000 http://localhost:5173 http://127.0.0.1:5173 + # Comment the following line to disable the development mode + command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile + healthcheck: + test: ["CMD", "curl", "-f", "https://localhost/healthz"] + timeout: 5s + retries: 5 + start_period: 60s + volumes: + - mercure_data:/data + - mercure_config:/config +###< symfony/mercure-bundle ### + +volumes: +###> symfony/mercure-bundle ### + mercure_data: + mercure_config: +###< symfony/mercure-bundle ### diff --git a/server/composer.json b/server/composer.json index 4750b74..e5389ec 100644 --- a/server/composer.json +++ b/server/composer.json @@ -13,14 +13,21 @@ "doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/orm": "^3.5", "nelmio/cors-bundle": "^2.5", + "sentry/sentry-symfony": "^5.6", "symfony/console": "7.3.*", "symfony/dotenv": "7.3.*", "symfony/flex": "^2", "symfony/framework-bundle": "7.3.*", + "symfony/http-client": "^7.3", + "symfony/mercure-bundle": "^0.3.9", + "symfony/messenger": "^7.3", + "symfony/monolog-bundle": "^3.10", "symfony/property-access": "7.3.*", "symfony/property-info": "7.3.*", + "symfony/rate-limiter": "^7.3", "symfony/runtime": "7.3.*", "symfony/serializer": "^7.3", + "symfony/validator": "^7.3", "symfony/yaml": "7.3.*" }, "config": { diff --git a/server/composer.lock b/server/composer.lock index 92d8f44..7d61f07 100644 --- a/server/composer.lock +++ b/server/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f70c6b9e063d78d2ada8ba72836ea674", + "content-hash": "6a8621d55b15988cb9bfd8c4d6494281", "packages": [ { "name": "doctrine/collections", @@ -1298,6 +1298,422 @@ }, "time": "2025-01-24T11:45:48+00:00" }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "lcobucci/clock", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "fb533e093fd61321bfcbac08b131ce805fe183d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/fb533e093fd61321bfcbac08b131ce805fe183d3", + "reference": "fb533e093fd61321bfcbac08b131ce805fe183d3", + "shasum": "" + }, + "require": { + "php": "^8.0", + "stella-maris/clock": "^0.1.4" + }, + "require-dev": { + "infection/infection": "^0.26", + "lcobucci/coding-standard": "^8.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2022-04-19T19:34:17+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "55564265fddf810504110bd68ca311932324b0e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/55564265fddf810504110bd68ca311932324b0e9", + "reference": "55564265fddf810504110bd68ca311932324b0e9", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-openssl": "*", + "lcobucci/clock": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "infection/infection": "^0.20", + "lcobucci/coding-standard": "^6.0", + "mikey179/vfsstream": "^1.6", + "phpbench/phpbench": "^0.17", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/php-invoker": "^3.1", + "phpunit/phpunit": "^9.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2021-09-28T19:18:28+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, { "name": "nelmio/cors-bundle", "version": "2.5.0", @@ -1409,6 +1825,54 @@ }, "time": "2021-02-03T23:26:27+00:00" }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -1512,6 +1976,170 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/link", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/link.git", + "reference": "84b159194ecfd7eaa472280213976e96415433f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/link/zipball/84b159194ecfd7eaa472280213976e96415433f7", + "reference": "84b159194ecfd7eaa472280213976e96415433f7", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "suggest": { + "fig/link-util": "Provides some useful PSR-13 utilities" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Link\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for HTTP links", + "homepage": "https://github.com/php-fig/link", + "keywords": [ + "http", + "http-link", + "link", + "psr", + "psr-13", + "rest" + ], + "support": { + "source": "https://github.com/php-fig/link/tree/2.0.1" + }, + "time": "2021-03-11T23:00:27+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -1562,6 +2190,288 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "sentry/sentry", + "version": "4.16.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "c5b086e4235762da175034bc463b0d31cbb38d2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/c5b086e4235762da175034bc463b0d31cbb38d2e", + "reference": "c5b086e4235762da175034bc463b0d31cbb38d2e", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "monolog/monolog": "^1.6|^2.0|^3.0", + "phpbench/phpbench": "^1.0", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5|^9.6", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", + "vimeo/psalm": "^4.17" + }, + "suggest": { + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.16.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2025-09-22T13:38:03+00:00" + }, + { + "name": "sentry/sentry-symfony", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-symfony.git", + "reference": "9867751f5091b55d7e3a223f48d88e228132e073" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/9867751f5091b55d7e3a223f48d88e228132e073", + "reference": "9867751f5091b55d7e3a223f48d88e228132e073", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2.1.1", + "jean85/pretty-package-versions": "^1.5||^2.0", + "php": "^7.2||^8.0", + "sentry/sentry": "^4.16.0", + "symfony/cache-contracts": "^1.1||^2.4||^3.0", + "symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/polyfill-php80": "^1.22", + "symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0" + }, + "require-dev": { + "doctrine/dbal": "^2.13||^3.3||^4.0", + "doctrine/doctrine-bundle": "^2.6", + "friendsofphp/php-cs-fixer": "^2.19||^3.40", + "masterminds/html5": "^2.8", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "1.12.5", + "phpstan/phpstan-phpunit": "1.4.0", + "phpstan/phpstan-symfony": "1.4.10", + "phpunit/phpunit": "^8.5.40||^9.6.21", + "symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/monolog-bundle": "^3.4", + "symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0", + "symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0", + "symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0", + "vimeo/psalm": "^4.3||^5.16.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.", + "symfony/cache": "Allow distributed tracing of cache pools using Sentry.", + "symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry." + }, + "type": "symfony-bundle", + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Sentry\\SentryBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "Symfony integration for Sentry (http://getsentry.com)", + "homepage": "http://getsentry.com", + "keywords": [ + "errors", + "logging", + "sentry", + "symfony" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-symfony/issues", + "source": "https://github.com/getsentry/sentry-symfony/tree/5.6.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2025-09-24T13:41:01+00:00" + }, + { + "name": "stella-maris/clock", + "version": "0.1.7", + "source": { + "type": "git", + "url": "https://github.com/stella-maris-solutions/clock.git", + "reference": "fa23ce16019289a18bb3446fdecd45befcdd94f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stella-maris-solutions/clock/zipball/fa23ce16019289a18bb3446fdecd45befcdd94f8", + "reference": "fa23ce16019289a18bb3446fdecd45befcdd94f8", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0", + "psr/clock": "^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "StellaMaris\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Heigl", + "role": "Maintainer" + } + ], + "description": "A pre-release of the proposed PSR-20 Clock-Interface", + "homepage": "https://gitlab.com/stella-maris/clock", + "keywords": [ + "clock", + "datetime", + "point in time", + "psr20" + ], + "support": { + "source": "https://github.com/stella-maris-solutions/clock/tree/0.1.7" + }, + "time": "2022-11-25T16:15:06+00:00" + }, { "name": "symfony/cache", "version": "v7.3.4", @@ -1740,6 +2650,80 @@ ], "time": "2025-03-13T15:25:07+00:00" }, + { + "name": "symfony/clock", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, { "name": "symfony/config", "version": "v7.3.4", @@ -2868,6 +3852,184 @@ ], "time": "2025-09-17T05:51:54+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T10:12:26+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.3.4", @@ -3069,6 +4231,500 @@ ], "time": "2025-09-27T12:32:17+00:00" }, + { + "name": "symfony/mercure", + "version": "v0.6.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure.git", + "reference": "304cf84609ef645d63adc65fc6250292909a461b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure/zipball/304cf84609ef645d63adc65fc6250292909a461b", + "reference": "304cf84609ef645d63adc65fc6250292909a461b", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/deprecation-contracts": "^2.0|^3.0|^4.0", + "symfony/http-client": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-foundation": "^4.4|^5.0|^6.0|^7.0", + "symfony/polyfill-php80": "^1.22", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", + "symfony/stopwatch": "^4.4|^5.0|^6.0|^7.0", + "twig/twig": "^2.0|^3.0|^4.0" + }, + "suggest": { + "symfony/stopwatch": "Integration with the profiler performances" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/dunglas/mercure", + "name": "dunglas/mercure" + }, + "branch-alias": { + "dev-main": "0.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mercure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Mercure Component", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure/issues", + "source": "https://github.com/symfony/mercure/tree/v0.6.5" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure", + "type": "tidelift" + } + ], + "time": "2024-04-08T12:51:34+00:00" + }, + { + "name": "symfony/mercure-bundle", + "version": "v0.3.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure-bundle.git", + "reference": "77435d740b228e9f5f3f065b6db564f85f2cdb64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/77435d740b228e9f5f3f065b6db564f85f2cdb64", + "reference": "77435d740b228e9f5f3f065b6db564f85f2cdb64", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "php": ">=7.1.3", + "symfony/config": "^4.4|^5.0|^6.0|^7.0", + "symfony/dependency-injection": "^4.4|^5.4|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/mercure": "^0.6.1", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.3.7|^5.0|^6.0|^7.0", + "symfony/stopwatch": "^4.3.7|^5.0|^6.0|^7.0", + "symfony/ux-turbo": "*", + "symfony/var-dumper": "^4.3.7|^5.0|^6.0|^7.0" + }, + "suggest": { + "symfony/messenger": "To use the Messenger integration" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MercureBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MercureBundle", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure-bundle/issues", + "source": "https://github.com/symfony/mercure-bundle/tree/v0.3.9" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle", + "type": "tidelift" + } + ], + "time": "2024-05-31T09:07:18+00:00" + }, + { + "name": "symfony/messenger", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "d9e04339404ba2dcd04c24172125516dc0e06c35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/d9e04339404ba2dcd04c24172125516dc0e06c35", + "reference": "d9e04339404ba2dcd04c24172125516dc0e06c35", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/lock": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-13T11:49:31+00:00" + }, + { + "name": "symfony/monolog-bridge", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "7acf2abe23e5019451399ba69fc8ed3d61d4d8f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/7acf2abe23e5019451399ba69fc8ed3d61d4d8f0", + "reference": "7acf2abe23e5019451399ba69fc8ed3d61d4d8f0", + "shasum": "" + }, + "require": { + "monolog/monolog": "^3", + "php": ">=8.2", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/security-core": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/mailer": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Monolog\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Monolog with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/monolog-bridge/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-24T16:45:39+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "shasum": "" + }, + "require": { + "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", + "php": ">=7.2.5", + "symfony/config": "^5.4 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^6.3 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MonologBundle", + "homepage": "https://symfony.com", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/symfony/monolog-bundle/issues", + "source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-11-06T17:08:13+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-05T10:16:07+00:00" + }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.33.0", @@ -3651,6 +5307,163 @@ ], "time": "2025-09-15T13:55:54+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-26T08:57:56+00:00" + }, + { + "name": "symfony/rate-limiter", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "7e855541d302ba752f86fb0e97932e7969fe9c04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/7e855541d302ba752f86fb0e97932e7969fe9c04", + "reference": "7e855541d302ba752f86fb0e97932e7969fe9c04", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/options-resolver": "^7.3" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-07T08:17:57+00:00" + }, { "name": "symfony/routing", "version": "v7.3.4", @@ -4157,6 +5970,84 @@ ], "time": "2025-09-11T14:36:48+00:00" }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-27T08:32:26+00:00" + }, { "name": "symfony/type-info", "version": "v7.3.4", @@ -4240,6 +6131,108 @@ ], "time": "2025-09-11T15:33:27+00:00" }, + { + "name": "symfony/validator", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "5e29a348b5fac2227b6938a54db006d673bb813a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/5e29a348b5fac2227b6938a54db006d673bb813a", + "reference": "5e29a348b5fac2227b6938a54db006d673bb813a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php83": "^1.27", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<7.0", + "symfony/expression-language": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<6.4", + "symfony/property-info": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/string": "^6.4|^7.0", + "symfony/translation": "^6.4.3|^7.0.3", + "symfony/type-info": "^7.1.8", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:32:27+00:00" + }, { "name": "symfony/var-dumper", "version": "v7.3.4", @@ -4408,6 +6401,89 @@ ], "time": "2025-09-11T10:12:26+00:00" }, + { + "name": "symfony/web-link", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-link.git", + "reference": "7697f74fce67555665339423ce453cc8216a98ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-link/zipball/7697f74fce67555665339423ce453cc8216a98ff", + "reference": "7697f74fce67555665339423ce453cc8216a98ff", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/link": "^1.1|^2.0" + }, + "conflict": { + "symfony/http-kernel": "<6.4" + }, + "provide": { + "psr/link-implementation": "1.0|2.0" + }, + "require-dev": { + "symfony/http-kernel": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\WebLink\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages links between resources", + "homepage": "https://symfony.com", + "keywords": [ + "dns-prefetch", + "http", + "http2", + "link", + "performance", + "prefetch", + "preload", + "prerender", + "psr13", + "push" + ], + "support": { + "source": "https://github.com/symfony/web-link/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-05-19T13:28:18+00:00" + }, { "name": "symfony/yaml", "version": "v7.3.3", diff --git a/server/config/bundles.php b/server/config/bundles.php index 1c8dfe8..39274c3 100644 --- a/server/config/bundles.php +++ b/server/config/bundles.php @@ -6,4 +6,7 @@ return [ Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], + Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], + Sentry\SentryBundle\SentryBundle::class => ['prod' => true], ]; diff --git a/server/config/packages/doctrine.yaml b/server/config/packages/doctrine.yaml index 25138b9..f43116d 100644 --- a/server/config/packages/doctrine.yaml +++ b/server/config/packages/doctrine.yaml @@ -24,6 +24,12 @@ doctrine: dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: App + AppValueObject: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/ValueObject' + prefix: 'App\ValueObject' + alias: AppValueObject controller_resolver: auto_mapping: false diff --git a/server/config/packages/mercure.yaml b/server/config/packages/mercure.yaml new file mode 100644 index 0000000..f2a7395 --- /dev/null +++ b/server/config/packages/mercure.yaml @@ -0,0 +1,8 @@ +mercure: + hubs: + default: + url: '%env(MERCURE_URL)%' + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_SECRET)%' + publish: '*' diff --git a/server/config/packages/messenger.yaml b/server/config/packages/messenger.yaml new file mode 100644 index 0000000..9214804 --- /dev/null +++ b/server/config/packages/messenger.yaml @@ -0,0 +1,18 @@ +framework: + messenger: + # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. + # failure_transport: failed + + transports: + sync: 'sync://' + + routing: + App\Message\SignalCreatedMessage: sync + +# when@test: +# framework: +# messenger: +# transports: +# # replace with your transport name here (e.g., my_transport: 'in-memory://') +# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test +# async: 'in-memory://' diff --git a/server/config/packages/monolog.yaml b/server/config/packages/monolog.yaml new file mode 100644 index 0000000..b69d8d8 --- /dev/null +++ b/server/config/packages/monolog.yaml @@ -0,0 +1,79 @@ +monolog: + channels: + - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists + - signals + +when@dev: + monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + channels: ["!event"] + signals: + type: stream + path: "%kernel.logs_dir%/signals_%kernel.environment%.log" + level: debug + channels: [signals] + # uncomment to get logging in your browser + # you may have to allow bigger header sizes in your Web server configuration + #firephp: + # type: firephp + # level: info + #chromephp: + # type: chromephp + # level: info + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] + +when@test: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!event"] + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + signals: + type: stream + path: "%kernel.logs_dir%/signals_%kernel.environment%.log" + level: debug + channels: [signals] + +when@prod: + monolog: + handlers: + main: + type: stream + path: php://stderr + level: info + formatter: monolog.formatter.json + channels: ["!event"] + signals: + type: stream + path: php://stderr + level: info + formatter: monolog.formatter.json + channels: [signals] + sentry: + type: sentry + level: error + hub_id: sentry + channels: ["!event", "!doctrine", "!console"] + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine"] + deprecation: + type: stream + channels: [deprecation] + path: php://stderr + formatter: monolog.formatter.json diff --git a/server/config/packages/prod/sentry.yaml b/server/config/packages/prod/sentry.yaml new file mode 100644 index 0000000..b7edb78 --- /dev/null +++ b/server/config/packages/prod/sentry.yaml @@ -0,0 +1,6 @@ +sentry: + dsn: '%env(SENTRY_DSN)%' + register_error_listener: true + options: + environment: '%kernel.environment%' + traces_sample_rate: 0.0 diff --git a/server/config/packages/rate_limiter.yaml b/server/config/packages/rate_limiter.yaml new file mode 100644 index 0000000..df4887a --- /dev/null +++ b/server/config/packages/rate_limiter.yaml @@ -0,0 +1,8 @@ +framework: + rate_limiter: + signal_submission: + policy: 'token_bucket' + limit: 5 + rate: + interval: '1 minute' + amount: 1 diff --git a/server/config/packages/validator.yaml b/server/config/packages/validator.yaml new file mode 100644 index 0000000..dd47a6a --- /dev/null +++ b/server/config/packages/validator.yaml @@ -0,0 +1,11 @@ +framework: + validation: + # Enables validator auto-mapping support. + # For instance, basic validation constraints will be inferred from Doctrine's metadata. + #auto_mapping: + # App\Entity\: [] + +when@test: + framework: + validation: + not_compromised_password: false diff --git a/server/config/services.yaml b/server/config/services.yaml index f1113c2..36c6ed4 100644 --- a/server/config/services.yaml +++ b/server/config/services.yaml @@ -4,6 +4,9 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + app.signal_snapshot_limit: 750 + app.max_signal_distance_km: 1 + app.signal_stream_topic: '%env(string:MERCURE_SIGNALS_TOPIC)%' services: # default configuration for services in *this* file @@ -23,3 +26,4 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + diff --git a/server/migrations/Version20251010102708.php b/server/migrations/Version20251010102708.php new file mode 100644 index 0000000..cd7aad2 --- /dev/null +++ b/server/migrations/Version20251010102708.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE __temp__signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, user_lat DOUBLE PRECISION NOT NULL, user_lng DOUBLE PRECISION NOT NULL, signal_lat DOUBLE PRECISION NOT NULL, signal_lng DOUBLE PRECISION NOT NULL)'); + $this->addSql('INSERT INTO __temp__signals (id, user_key, created_at, user_lat, user_lng, signal_lat, signal_lng) SELECT id, user_key, created_at, lat, lng, lat, lng FROM signals'); + $this->addSql('DROP TABLE signals'); + $this->addSql('ALTER TABLE __temp__signals RENAME TO signals'); + $this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)'); + $this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE TABLE __temp__signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, created_at DATETIME NOT NULL)'); + $this->addSql('INSERT INTO __temp__signals (id, user_key, lat, lng, created_at) SELECT id, user_key, signal_lat, signal_lng, created_at FROM signals'); + $this->addSql('DROP TABLE signals'); + $this->addSql('ALTER TABLE __temp__signals RENAME TO signals'); + $this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)'); + $this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)'); + } +} diff --git a/server/src/Controller/SignalController.php b/server/src/Controller/SignalController.php index 50db74f..96aace0 100644 --- a/server/src/Controller/SignalController.php +++ b/server/src/Controller/SignalController.php @@ -4,14 +4,13 @@ declare(strict_types=1); namespace App\Controller; -use App\Dto\SignalPayload; -use App\Entity\Signal; -use App\Repository\SignalRepository; -use App\Service\SignalSnapshotBuilder; -use DateTimeImmutable; -use DateTimeZone; +use App\Payload\SignalPayload; +use App\Service\ClientKeyProvider; +use App\Service\SignalSnapshotService; +use App\Service\SignalSubmissionService; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Routing\Annotation\Route; @@ -20,104 +19,46 @@ use Symfony\Component\Routing\Annotation\Route; class SignalController { public function __construct( - private readonly SignalRepository $signals, - private readonly SignalSnapshotBuilder $snapshotBuilder, + private readonly SignalSnapshotService $snapshotService, + private readonly SignalSubmissionService $submissionService, + private readonly ClientKeyProvider $clientKeyProvider, ) { } #[Route(path: '', name: 'api_signals_index', methods: ['GET'])] public function index(Request $request, #[MapQueryParameter('limit', flags: FILTER_NULL_ON_FAILURE)] ?int $limit = null): JsonResponse { - $limit = $this->normalizeLimit($limit); + $clientKey = $this->clientKeyProvider->fromRequest($request); + $snapshot = $this->snapshotService->buildSnapshot($limit, $clientKey); - $clientKey = $this->hashIp($this->extractClientIp($request)); - - $signals = $this->signals->findRecent($limit); - $snapshot = $this->snapshotBuilder->build($signals); - - $payload = [ - 'clientKey' => $clientKey, - 'points' => $snapshot['points'], - 'density' => $snapshot['density'], - 'latestByUser' => $snapshot['latestByUser'], - 'totals' => $snapshot['totals'], - 'updatedAt' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format(DATE_ATOM), - ]; - - return new JsonResponse($payload); + return new JsonResponse($snapshot); } #[Route(path: '', name: 'api_signals_store', methods: ['POST'])] - public function store(#[MapRequestPayload(validationFailedStatusCode: JsonResponse::HTTP_UNPROCESSABLE_ENTITY)] SignalPayload $payload, Request $request): JsonResponse + public function store(Request $request, #[MapRequestPayload] SignalPayload $payload): JsonResponse { - $lat = $payload->lat; - $lng = $payload->lng; + $clientKey = $this->clientKeyProvider->fromRequest($request); + $signal = $this->submissionService->submit($clientKey, $payload); - if (! is_finite($lat) || ! is_finite($lng)) { - return $this->errorResponse('invalid_coordinates', 'Latitude and longitude must be numbers.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY); - } - - if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) { - return $this->errorResponse('out_of_bounds', 'Latitude or longitude out of range.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY); - } - - $clientIp = $this->extractClientIp($request); - $clientKey = $this->hashIp($clientIp); - - $signal = new Signal() - ->setUserKey($clientKey) - ->setLat($lat) - ->setLng($lng) - ->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC'))); - - $this->signals->save($signal); + $signalLocation = $signal->getSignalLocation(); + $userLocation = $signal->getUserLocation(); return new JsonResponse([ 'status' => 'stored', 'clientKey' => $clientKey, 'point' => [ 'id' => $signal->getId(), - 'lat' => $signal->getLat(), - 'lng' => $signal->getLng(), + 'signalLocation' => [ + 'lat' => $signalLocation->getLat(), + 'lng' => $signalLocation->getLng(), + ], + 'userLocation' => [ + 'lat' => $userLocation->getLat(), + 'lng' => $userLocation->getLng(), + ], 'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM), 'userKey' => $signal->getUserKey(), ], - ], JsonResponse::HTTP_CREATED); - } - - private function errorResponse(string $error, string $message, int $statusCode): JsonResponse - { - return new JsonResponse([ - 'error' => $error, - 'message' => $message, - ], $statusCode); - } - - private function normalizeLimit(?int $limit): int - { - $limit ??= 750; - if ($limit < 1 || $limit > 5000) { - return 750; - } - - return $limit; - } - - private function extractClientIp(Request $request): string - { - $forwarded = $request->headers->get('X-Forwarded-For'); - if ($forwarded !== null && $forwarded !== '') { - $parts = array_filter(array_map('trim', explode(',', $forwarded))); - if ($parts !== []) { - return $parts[0]; - } - } - - return $request->getClientIp() ?? '0.0.0.0'; - } - - private function hashIp(string $ip): string - { - return substr(hash('sha256', $ip), 0, 12); + ], Response::HTTP_CREATED); } } diff --git a/server/src/DataFixtures/SignalFixtures.php b/server/src/DataFixtures/SignalFixtures.php index 3cb2714..28e9d15 100644 --- a/server/src/DataFixtures/SignalFixtures.php +++ b/server/src/DataFixtures/SignalFixtures.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\DataFixtures; +use App\ValueObject\Point; use App\Entity\Signal; use DateInterval; use DateTimeImmutable; @@ -38,10 +39,12 @@ class SignalFixtures extends Fixture ]; foreach ($coordinates as $config) { + $signalLocation = Point::fromLatLng($config['lat'], $config['lng']); + $signal = new Signal() ->setUserKey($config['user']) - ->setLat($config['lat']) - ->setLng($config['lng']) + ->setUserLocation(Point::fromLatLng($config['lat'], $config['lng'])) + ->setSignalLocation($signalLocation) ->setCreatedAt($baseTime->add(new DateInterval(sprintf('PT%dM', $config['offset'])))); $manager->persist($signal); diff --git a/server/src/Dto/SignalPayload.php b/server/src/Dto/SignalPayload.php deleted file mode 100644 index 5c9c9a6..0000000 --- a/server/src/Dto/SignalPayload.php +++ /dev/null @@ -1,14 +0,0 @@ -lat; + return $this->userLocation; } - public function setLat(float $lat): self + public function setUserLocation(Point $userLocation): self { - $this->lat = $lat; + $this->userLocation = $userLocation; return $this; } - public function getLng(): float + public function getSignalLocation(): Point { - return $this->lng; + return $this->signalLocation; } - public function setLng(float $lng): self + public function setSignalLocation(Point $signalLocation): self { - $this->lng = $lng; + $this->signalLocation = $signalLocation; return $this; } diff --git a/server/src/Exception/InvalidPointException.php b/server/src/Exception/InvalidPointException.php new file mode 100644 index 0000000..4b15d87 --- /dev/null +++ b/server/src/Exception/InvalidPointException.php @@ -0,0 +1,38 @@ + 'invalid_coordinates'])] +final class InvalidPointException extends \RuntimeException +{ + private function __construct(string $message) + { + parent::__construct($message); + } + + public static function invalidNumber(): self + { + return new self('Latitude and longitude must be finite numbers.'); + } + + public static function latitudeOutOfRange(float $lat): self + { + return new self(sprintf('Latitude %.6f is out of bounds.', $lat)); + } + + public static function longitudeOutOfRange(float $lng): self + { + return new self(sprintf('Longitude %.6f is out of bounds.', $lng)); + } + + public static function missingCoordinates(): self + { + return new self('Missing latitude or longitude.'); + } +} diff --git a/server/src/Exception/MissingClientKeyException.php b/server/src/Exception/MissingClientKeyException.php new file mode 100644 index 0000000..64103b1 --- /dev/null +++ b/server/src/Exception/MissingClientKeyException.php @@ -0,0 +1,17 @@ + 'missing_client_key'])] +final class MissingClientKeyException extends \RuntimeException +{ + public function __construct() + { + parent::__construct('Unable to determine your network address.'); + } +} diff --git a/server/src/Exception/PointTooFarException.php b/server/src/Exception/PointTooFarException.php new file mode 100644 index 0000000..9138ed7 --- /dev/null +++ b/server/src/Exception/PointTooFarException.php @@ -0,0 +1,18 @@ + 'point_too_far'])] +final class PointTooFarException extends \RuntimeException +{ + public function __construct(float $maximumDistanceKm) + { + parent::__construct(sprintf('The submitted point must be within %.2fkm of your location.', $maximumDistanceKm)); + } +} diff --git a/server/src/Exception/SubmissionRateLimitedException.php b/server/src/Exception/SubmissionRateLimitedException.php new file mode 100644 index 0000000..36b7ec8 --- /dev/null +++ b/server/src/Exception/SubmissionRateLimitedException.php @@ -0,0 +1,24 @@ + 'submission_rate_limited'])] +final class SubmissionRateLimitedException extends \RuntimeException +{ + public function __construct(?DateTimeInterface $retryAfter) + { + $message = 'Too many submissions from your network. Please wait before trying again.'; + if ($retryAfter !== null) { + $message .= sprintf(' You can retry after %s.', $retryAfter->format(DATE_ATOM)); + } + + parent::__construct($message); + } +} diff --git a/server/src/Message/SignalCreatedMessage.php b/server/src/Message/SignalCreatedMessage.php new file mode 100644 index 0000000..41a9623 --- /dev/null +++ b/server/src/Message/SignalCreatedMessage.php @@ -0,0 +1,12 @@ +signals->findRecent($this->snapshotLimit); + $snapshot = $this->snapshotBuilder->build($recentSignals); + + $payload = [ + 'type' => 'snapshot', + 'payload' => [ + 'points' => $snapshot['points'], + 'density' => $snapshot['density'], + 'latestByUser' => $snapshot['latestByUser'], + 'totals' => $snapshot['totals'], + 'updatedAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM), + ], + ]; + + $data = json_encode($payload, JSON_THROW_ON_ERROR); + + $update = new Update( + topics: $this->topic, + data: $data, + ); + + try { + $this->hub->publish($update); + } catch (Throwable $exception) { + $this->logger?->error('Failed to publish signal update to Mercure.', [ + 'exception' => $exception, + ]); + } + } +} diff --git a/server/src/Payload/SignalPayload.php b/server/src/Payload/SignalPayload.php new file mode 100644 index 0000000..d874603 --- /dev/null +++ b/server/src/Payload/SignalPayload.php @@ -0,0 +1,16 @@ +extractClientIp($request); + + $clientKey = $this->hashIp($ip); + $this->logger->debug('Generated client key for request.', [ + 'client_key' => $clientKey, + ]); + + return $clientKey; + } + + private function extractClientIp(Request $request): string + { + $forwarded = $request->headers->get('X-Forwarded-For'); + if ($forwarded !== null && $forwarded !== '') { + $parts = array_filter(array_map('trim', explode(',', $forwarded))); + if ($parts !== []) { + $this->logger->info('Resolved client address from forwarded header.', [ + 'resolution' => 'forwarded', + ]); + return $parts[0]; + } + } + + $ip = $request->getClientIp(); + if (is_string($ip) && $ip !== '') { + $this->logger->info('Resolved client address from remote address.', [ + 'resolution' => 'remote', + ]); + return $ip; + } + + $this->logger->critical('Failed to resolve client address for key generation.'); + throw new MissingClientKeyException(); + } + + private function hashIp(string $ip): string + { + return substr(hash('sha256', $ip), 0, 12); + } +} diff --git a/server/src/Service/PointProximityValidator.php b/server/src/Service/PointProximityValidator.php new file mode 100644 index 0000000..5389515 --- /dev/null +++ b/server/src/Service/PointProximityValidator.php @@ -0,0 +1,52 @@ +distanceInKm($userLocation, $signalLocation); + + $this->logger->debug('Calculated proximity between user and signal.', [ + 'distance_km' => $distance, + 'max_distance_km' => $this->maximumDistanceKm, + ]); + + if ($distance > $this->maximumDistanceKm) { + $this->logger->warning('Rejected signal because it is beyond the allowed proximity.', [ + 'distance_km' => $distance, + 'max_distance_km' => $this->maximumDistanceKm, + ]); + throw new PointTooFarException($this->maximumDistanceKm); + } + } + + private function distanceInKm(Point $a, Point $b): float + { + $lat1 = deg2rad($a->getLat()); + $lat2 = deg2rad($b->getLat()); + $deltaLat = deg2rad($b->getLat() - $a->getLat()); + $deltaLng = deg2rad($b->getLng() - $a->getLng()); + + $haversine = sin($deltaLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($deltaLng / 2) ** 2; + $c = 2 * atan2(sqrt($haversine), sqrt(1 - $haversine)); + + return 6371 * $c; + } +} diff --git a/server/src/Service/SignalSnapshotBuilder.php b/server/src/Service/SignalSnapshotBuilder.php index 821d453..b2986b5 100644 --- a/server/src/Service/SignalSnapshotBuilder.php +++ b/server/src/Service/SignalSnapshotBuilder.php @@ -12,9 +12,21 @@ class SignalSnapshotBuilder * @param list $signals * * @return array{ - * points: list, + * points: list, * density: list, - * latestByUser: list, + * latestByUser: list, * totals: array{points: int, contributors: int} * } */ @@ -25,18 +37,27 @@ class SignalSnapshotBuilder $latestByUser = []; foreach ($signals as $signal) { + $signalLocation = $signal->getSignalLocation(); + $userLocation = $signal->getUserLocation(); + $point = [ 'id' => (int) $signal->getId(), - 'lat' => $signal->getLat(), - 'lng' => $signal->getLng(), + 'signalLocation' => [ + 'lat' => $signalLocation->getLat(), + 'lng' => $signalLocation->getLng(), + ], + 'userLocation' => [ + 'lat' => $userLocation->getLat(), + 'lng' => $userLocation->getLng(), + ], 'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM), 'userKey' => $signal->getUserKey(), ]; $points[] = $point; - $bucketLat = round($signal->getLat(), 3); - $bucketLng = round($signal->getLng(), 3); + $bucketLat = round($signalLocation->getLat(), 3); + $bucketLng = round($signalLocation->getLng(), 3); $bucketKey = $bucketLat . ':' . $bucketLng; if (! isset($densityBuckets[$bucketKey])) { diff --git a/server/src/Service/SignalSnapshotService.php b/server/src/Service/SignalSnapshotService.php new file mode 100644 index 0000000..c976264 --- /dev/null +++ b/server/src/Service/SignalSnapshotService.php @@ -0,0 +1,60 @@ +normalizeLimit($limit); + $signals = $this->signals->findRecent($resolvedLimit); + $snapshot = $this->snapshotBuilder->build($signals); + + $this->logger->info('Built signal snapshot for client.', [ + 'client_key' => $clientKey, + 'requested_limit' => $limit, + 'applied_limit' => $resolvedLimit, + 'point_count' => count($snapshot['points']), + ]); + + return [ + 'clientKey' => $clientKey, + 'points' => $snapshot['points'], + 'density' => $snapshot['density'], + 'latestByUser' => $snapshot['latestByUser'], + 'totals' => $snapshot['totals'], + 'updatedAt' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format(DATE_ATOM), + ]; + } + + private function normalizeLimit(?int $limit): int + { + if ($limit === null) { + return $this->snapshotLimit; + } + + if ($limit < 1) { + return $this->snapshotLimit; + } + + return min($limit, $this->snapshotLimit); + } +} diff --git a/server/src/Service/SignalSubmissionService.php b/server/src/Service/SignalSubmissionService.php new file mode 100644 index 0000000..22a7818 --- /dev/null +++ b/server/src/Service/SignalSubmissionService.php @@ -0,0 +1,95 @@ +enforceRateLimit($clientKey); + $signalLocation = $payload->signalLocation; + $userLocation = $payload->userLocation; + + $this->proximityValidator->assertWithinRange($userLocation, $signalLocation); + + $this->logger->info('Storing new signal submission.', [ + 'client_key' => $clientKey, + 'signal_location' => [ + 'lat' => round($signalLocation->getLat(), 5), + 'lng' => round($signalLocation->getLng(), 5), + ], + 'user_location' => [ + 'lat' => round($userLocation->getLat(), 5), + 'lng' => round($userLocation->getLng(), 5), + ], + ]); + + $signal = (new Signal()) + ->setUserKey($clientKey) + ->setUserLocation($userLocation) + ->setSignalLocation($signalLocation) + ->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC'))); + + $this->signals->save($signal); + + $signalId = $signal->getId(); + if ($signalId !== null) { + $this->logger->debug('Dispatching signal created message.', [ + 'signal_id' => $signalId, + ]); + $this->bus->dispatch(new SignalCreatedMessage($signalId)); + $this->logger->info('Signal stored successfully.', [ + 'signal_id' => $signalId, + 'client_key' => $clientKey, + ]); + } + + return $signal; + } + + private function enforceRateLimit(string $clientKey): void + { + $rateLimiter = $this->submissionLimiter->create($clientKey); + $limit = $rateLimiter->consume(1); + + if (! $limit->isAccepted()) { + $retryAfter = $limit->getRetryAfter(); + $this->logger->warning('Signal submission rejected due to rate limiting.', [ + 'client_key' => $clientKey, + 'retry_after' => $retryAfter?->format(DATE_ATOM), + ]); + throw new SubmissionRateLimitedException($limit->getRetryAfter()); + } + + $this->logger->debug('Signal submission passed rate limiting.', [ + 'client_key' => $clientKey, + 'remaining_tokens' => $limit->getRemainingTokens(), + ]); + } + +} diff --git a/server/src/ValueObject/Point.php b/server/src/ValueObject/Point.php new file mode 100644 index 0000000..cd8802d --- /dev/null +++ b/server/src/ValueObject/Point.php @@ -0,0 +1,66 @@ +assertValid($lat, $lng); + + $this->lat = $lat; + $this->lng = $lng; + } + + public static function fromArray(array $coordinates): self + { + if (! isset($coordinates['lat'], $coordinates['lng'])) { + throw InvalidPointException::missingCoordinates(); + } + + return new self((float) $coordinates['lat'], (float) $coordinates['lng']); + } + + public static function fromLatLng(float $lat, float $lng): self + { + return new self($lat, $lng); + } + + public function getLat(): float + { + return $this->lat; + } + + public function getLng(): float + { + return $this->lng; + } + + private function assertValid(float $lat, float $lng): void + { + if (! is_finite($lat) || ! is_finite($lng)) { + throw InvalidPointException::invalidNumber(); + } + + if ($lat < -90 || $lat > 90) { + throw InvalidPointException::latitudeOutOfRange($lat); + } + + if ($lng < -180 || $lng > 180) { + throw InvalidPointException::longitudeOutOfRange($lng); + } + } +} diff --git a/server/symfony.lock b/server/symfony.lock index 52b09e0..413f423 100644 --- a/server/symfony.lock +++ b/server/symfony.lock @@ -86,6 +86,15 @@ "bin/phpunit" ] }, + "sentry/sentry-symfony": { + "version": "5.6", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "5.0", + "ref": "b6cb4b34429dadecd7187852123be19d628fa37a" + } + }, "symfony/console": { "version": "7.3", "recipe": { @@ -131,6 +140,42 @@ ".editorconfig" ] }, + "symfony/mercure-bundle": { + "version": "0.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.3", + "ref": "528285147494380298f8f991ee8c47abebaf79db" + }, + "files": [ + "config/packages/mercure.yaml" + ] + }, + "symfony/messenger": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "ba1ac4e919baba5644d31b57a3284d6ba12d52ee" + }, + "files": [ + "config/packages/messenger.yaml" + ] + }, + "symfony/monolog-bundle": { + "version": "3.10", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.7", + "ref": "aff23899c4440dd995907613c1dd709b6f59503f" + }, + "files": [ + "config/packages/monolog.yaml" + ] + }, "symfony/property-info": { "version": "7.3", "recipe": { @@ -155,5 +200,17 @@ "config/packages/routing.yaml", "config/routes.yaml" ] + }, + "symfony/validator": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd" + }, + "files": [ + "config/packages/validator.yaml" + ] } } diff --git a/server/tests/Functional/SignalControllerTest.php b/server/tests/Functional/SignalControllerTest.php index 37e7a97..b1299c7 100644 --- a/server/tests/Functional/SignalControllerTest.php +++ b/server/tests/Functional/SignalControllerTest.php @@ -48,7 +48,9 @@ class SignalControllerTest extends WebTestCase public function testIndexReturnsRecentSignals(): void { - $this->client->request('GET', '/api/signals'); + $this->client->request('GET', '/api/signals', server: [ + 'HTTP_ACCEPT' => 'application/json', + ]); self::assertResponseIsSuccessful(); @@ -60,12 +62,14 @@ class SignalControllerTest extends WebTestCase self::assertCount(3, $payload['points']); self::assertSame(3, $payload['totals']['points']); self::assertSame(2, $payload['totals']['contributors']); - self::assertSame(-11.6852, $payload['points'][0]['lat']); + self::assertSame(-11.6852, $payload['points'][0]['signalLocation']['lat']); } public function testIndexRespectsLimitQueryParameter(): void { - $this->client->request('GET', '/api/signals?limit=1'); + $this->client->request('GET', '/api/signals?limit=1', server: [ + 'HTTP_ACCEPT' => 'application/json', + ]); self::assertResponseIsSuccessful(); @@ -80,13 +84,20 @@ class SignalControllerTest extends WebTestCase public function testStorePersistsNewSignal(): void { $body = [ - 'lat' => -11.6901, - 'lng' => 27.4959, + 'signalLocation' => [ + 'lat' => -11.6901, + 'lng' => 27.4959, + ], + 'userLocation' => [ + 'lat' => -11.6899, + 'lng' => 27.4962, + ], ]; - $this->client->request('POST', '/api/signals', [], [], [ + $this->client->request('POST', '/api/signals', server: [ 'CONTENT_TYPE' => 'application/json', - ], json_encode($body, JSON_THROW_ON_ERROR)); + 'HTTP_ACCEPT' => 'application/json', + ], content: json_encode($body, JSON_THROW_ON_ERROR)); self::assertResponseStatusCodeSame(Response::HTTP_CREATED); @@ -94,8 +105,10 @@ class SignalControllerTest extends WebTestCase self::assertIsString($content); $payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertSame('stored', $payload['status']); - self::assertSame($body['lat'], $payload['point']['lat']); - self::assertSame($body['lng'], $payload['point']['lng']); + self::assertSame($body['signalLocation']['lat'], $payload['point']['signalLocation']['lat']); + self::assertSame($body['signalLocation']['lng'], $payload['point']['signalLocation']['lng']); + self::assertSame($body['userLocation']['lat'], $payload['point']['userLocation']['lat']); + self::assertSame($body['userLocation']['lng'], $payload['point']['userLocation']['lng']); $repository = $this->entityManager->getRepository(Signal::class); $signals = $repository->findAll(); @@ -105,19 +118,58 @@ class SignalControllerTest extends WebTestCase public function testStoreRejectsInvalidCoordinates(): void { $body = [ - 'lat' => 181, - 'lng' => 10, + 'signalLocation' => [ + 'lat' => 181, + 'lng' => 10, + ], + 'userLocation' => [ + 'lat' => 10, + 'lng' => 10, + ], ]; - $this->client->request('POST', '/api/signals', [], [], [ + $this->client->request('POST', '/api/signals', server: [ 'CONTENT_TYPE' => 'application/json', - ], json_encode($body, JSON_THROW_ON_ERROR)); + 'HTTP_ACCEPT' => 'application/json', + ], content: json_encode($body, JSON_THROW_ON_ERROR)); self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); - $content = $this->client->getResponse()->getContent(); + $response = $this->client->getResponse(); + self::assertSame('invalid_coordinates', $response->headers->get('x-error-code')); + + $content = $response->getContent(); self::assertIsString($content); $payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR); - self::assertSame('out_of_bounds', $payload['error']); + self::assertSame('Latitude 181.000000 is out of bounds.', $payload['detail'] ?? null); + } + + public function testStoreRejectsPointTooFar(): void + { + $body = [ + 'signalLocation' => [ + 'lat' => -11.6901, + 'lng' => 27.4959, + ], + 'userLocation' => [ + 'lat' => -11.7005, + 'lng' => 27.4804, + ], + ]; + + $this->client->request('POST', '/api/signals', server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ], content: json_encode($body, JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + + $response = $this->client->getResponse(); + self::assertSame('point_too_far', $response->headers->get('x-error-code')); + + $content = $response->getContent(); + self::assertIsString($content); + $payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + self::assertSame('The submitted point must be within 1.00km of your location.', $payload['detail'] ?? null); } }