Remove Livewire docs, add Vue SPA frontend docs, document log-masking runbook
- Delete `.claude/rules/livewire.md` (Livewire is vestigial – only starter-kit auth screens remain) - Add `.claude/rules/frontend.md` documenting Vue 3 SPA stack, router, composables, search-URL mirroring, and Leaflet integration - Add `docs/ops/nginx-log-masking.md` runbook for redacting lat/lng from Nginx access/error logs and PHP-FPM on IONOS VPS - Update `CLAUDE.md` and `architecture.md` to reflect Vue SPA as primary frontend, REST API as backend contract, and Livewire's reduced role - Note stale service names in legacy domain docs (`scoring.md`, `prediction.md`, `notifications.md`) now refactored into `Services/Forecasting/`, `Services/StationSearch/`, and `PlanFeatures`
This commit is contained in:
@@ -1,57 +1,118 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
|
## Shape: Vue SPA ⇄ REST API ⇄ fat Laravel Services
|
||||||
|
|
||||||
|
The frontend is a **Vue 3 single-page app** (`resources/js/`). It talks to the
|
||||||
|
backend exclusively over a **REST API** (`routes/api.php` →
|
||||||
|
`app/Http/Controllers/Api/`). Controllers stay thin; all business logic lives in
|
||||||
|
**Service classes** (`app/Services/`). Blade is reduced to a one-line SPA shell,
|
||||||
|
server-rendered legal pages, transactional emails, and the Filament admin panel.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
└── Vue SPA (resources/js, mounted on #app via app.blade.php)
|
||||||
|
└── axios (resources/js/axios.js — baseURL '/api', cookie + XSRF auth)
|
||||||
|
└── REST API (routes/api.php → Http/Controllers/Api/*)
|
||||||
|
└── Services (app/Services/*) ← all business logic
|
||||||
|
└── Models / Jobs / Events / Notifications
|
||||||
|
```
|
||||||
|
|
||||||
## Core principle: fat Services, thin everything else
|
## Core principle: fat Services, thin everything else
|
||||||
|
|
||||||
All business logic lives in Service classes. Controllers, Livewire components,
|
All business logic lives in Service classes. API controllers, console commands,
|
||||||
and console commands are thin orchestrators — they call Services and return results.
|
jobs, listeners, and Filament resources are thin orchestrators — they call
|
||||||
This keeps the app API-extractable later without a rewrite.
|
Services and return results. Never put domain logic in a controller or a Vue
|
||||||
|
component.
|
||||||
|
|
||||||
## Directory structure
|
## Directory structure (actual)
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── Console/Commands/ # Scheduler commands (PollFuelPrices, PredictOilPrices, RunScoringEngine)
|
├── Console/Commands/ # Scheduler: PollFuelPrices, FetchOilPrices, BackfillOilPrices,
|
||||||
├── Http/Controllers/ # Minimal — auth + Stripe webhook only
|
│ # RunLlmOverlay, EvaluateVolatilityRegime, ResolveForecastOutcomes,
|
||||||
├── Livewire/ # Classic two-file Livewire components
|
│ # ArchiveOldPricesCommand, ImportBeisFuelPrices, ImportPostcodes
|
||||||
├── Models/ # Eloquent models
|
├── Http/
|
||||||
├── Notifications/ # Laravel Notification classes (multi-channel)
|
│ ├── Controllers/Api/ # AuthController, StationController, StatsController, UserController
|
||||||
├── Services/ # ALL business logic lives here
|
│ ├── Controllers/ # BillingController (Stripe Checkout/Portal redirects)
|
||||||
│ ├── FuelPriceService.php # Fuel Finder API polling + storage
|
│ ├── Middleware/ # VerifyApiKey (API-key gate), RequiresFeature (tier gate)
|
||||||
│ ├── AlertScoringService.php # Fill-up timing recommendation engine
|
│ ├── Requests/ # Form requests
|
||||||
│ ├── StationTaggingService.php # Supermarket brand detection
|
│ └── Resources/Api/ # StationResource (API output shaping)
|
||||||
│ ├── NotificationDispatchService.php # Tier-aware notification routing
|
├── Actions/Fortify/ # Fortify auth actions (CreateNewUser, …)
|
||||||
│ ├── SubscriptionService.php # Cashier/tier helpers
|
├── Services/ # ALL business logic — see below
|
||||||
│ ├── PostcodeService.php # Resolves postcodes/outcodes/place names → lat/lng
|
├── Models/ # Eloquent models
|
||||||
│ ├── OilPriceService.php # FRED fetch + EWMA/LLM Brent crude prediction
|
├── Notifications/ # FuelPriceAlert + custom Channels (OneSignal, Vonage WA/SMS)
|
||||||
│ ├── LocationResult.php # DTO returned by PostcodeService
|
├── Jobs/ # DispatchUserNotificationJob, PollFuelPricesJob,
|
||||||
│ └── ApiLogger.php # Wraps all outbound HTTP calls, logs to api_logs
|
│ # SendScheduledWhatsAppJob, SendPaymentFailedReminderJob
|
||||||
└── Jobs/ # Queued jobs (dispatch notifications per user)
|
├── Events/ # PricesUpdatedEvent
|
||||||
|
├── Listeners/ # HandleStripeWebhook (Cashier WebhookReceived)
|
||||||
|
├── Filament/ # Admin panel (Resources, Widgets) + Providers/Filament
|
||||||
|
├── Enums/ # FuelType, PlanTier, …
|
||||||
|
└── Livewire/Actions/ # Logout only — Livewire is otherwise vestigial
|
||||||
|
|
||||||
resources/views/
|
app/Services/
|
||||||
├── livewire/ # Livewire Blade templates
|
├── FuelPriceService.php # Fuel Finder API polling + storage
|
||||||
└── emails/ # Mailable templates
|
├── StationTaggingService.php # Supermarket brand detection
|
||||||
|
├── PostcodeService.php # postcode/outcode/place → lat/lng (+ LocationResult DTO)
|
||||||
|
├── PlanFeatures.php # Single source of tier-entitlement truth (see tiers.md)
|
||||||
|
├── HaversineQuery.php # Distance SQL helper
|
||||||
|
├── ApiLogger.php # Wraps outbound HTTP, logs to api_logs
|
||||||
|
├── StationSearch/ # StationSearchService (+ SearchCriteria, SearchResult DTOs)
|
||||||
|
├── BrentPriceSources/ # FRED + EIA Brent sources (+ BrentPriceFetcher)
|
||||||
|
└── Forecasting/ # Weekly forecast subsystem — WeeklyForecastService,
|
||||||
|
# LlmOverlayService, BacktestRunner, OutcomeResolver,
|
||||||
|
# VolatilityRegimeService, feature/leak tooling, …
|
||||||
|
|
||||||
|
resources/
|
||||||
|
├── js/ # Vue 3 SPA — see .claude/rules/frontend.md
|
||||||
|
└── views/
|
||||||
|
├── app.blade.php # SPA shell (mounts #app)
|
||||||
|
├── legal/ # Server-rendered legal pages
|
||||||
|
├── emails/ # Mailable templates
|
||||||
|
└── livewire/auth/ # Starter-kit Volt auth screens (left untouched)
|
||||||
|
|
||||||
routes/
|
routes/
|
||||||
├── web.php # All web routes (Livewire pages)
|
├── web.php # SPA shell + catch-all, legal pages, billing redirects, server logout
|
||||||
└── api.php # Empty for now — API added later if needed
|
└── api.php # REST API consumed by the SPA (see below)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Stale service names elsewhere.** Domain rule files (`scoring.md`,
|
||||||
|
> `prediction.md`, `notifications.md`) still name services that have since been
|
||||||
|
> refactored away — e.g. `AlertScoringService`, `OilPriceService`,
|
||||||
|
> `NationalFuelPredictionService`, `NotificationDispatchService`,
|
||||||
|
> `SubscriptionService`. Those concerns now live under `Services/Forecasting/`,
|
||||||
|
> `Services/StationSearch/`, and `PlanFeatures`. Always verify a service name
|
||||||
|
> against `app/Services/` before trusting it from those files.
|
||||||
|
|
||||||
## Service class conventions
|
## Service class conventions
|
||||||
|
|
||||||
- Constructor injection only — no facade usage inside Services
|
- Constructor injection only — no facade usage inside Services
|
||||||
- Services are bound in AppServiceProvider if they need interfaces
|
|
||||||
- Each Service has one responsibility — do not merge concerns
|
- Each Service has one responsibility — do not merge concerns
|
||||||
- Return typed DTOs or Eloquent collections — never raw arrays from Services
|
- Return typed DTOs or Eloquent collections — never raw arrays from Services
|
||||||
- Services never dispatch jobs directly — that's the controller/command's job
|
- Services never dispatch jobs directly — that's the controller/command/listener's job
|
||||||
|
|
||||||
## No API yet
|
## The REST API
|
||||||
|
|
||||||
`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum
|
`routes/api.php` is the SPA's backend. Three access tiers:
|
||||||
token auth. The app is Livewire-only until subscriber count justifies a native app.
|
|
||||||
When the API is added later, it will reuse the same Service classes.
|
|
||||||
|
|
||||||
## Livewire components (classic only)
|
- **Public** (no auth): `POST /api/auth/register`, `POST /api/auth/login`,
|
||||||
|
`GET /api/auth/me`, `GET /api/fuel-types`, `GET /api/stats/live`
|
||||||
|
- **API-key** (`VerifyApiKey` + `throttle:60,1`): `GET /api/stations`,
|
||||||
|
`GET /api/stats/searches`
|
||||||
|
- **Sanctum** (`auth:sanctum`): `POST /api/auth/logout`, `/api/user/*`
|
||||||
|
(preferences, saved stations, profile, password, delete account)
|
||||||
|
|
||||||
Use two-file classic Livewire components. Do NOT use Volt single-file syntax.
|
Conventions:
|
||||||
Volt files from the starter kit (auth screens) are left as-is — do not convert them.
|
|
||||||
New components go in `app/Livewire/` with corresponding Blade in `resources/views/livewire/`.
|
- Shape responses with API Resources (`app/Http/Resources/Api/`), never raw arrays.
|
||||||
|
- The fuel **prediction is embedded in the `/api/stations` response** under the
|
||||||
|
`prediction` key — there is no standalone prediction endpoint (see `prediction.md`).
|
||||||
|
- Tier-gate routes with the `feature` middleware (`RequiresFeature`); never
|
||||||
|
inline entitlement checks (see `tiers.md`).
|
||||||
|
- Auth is cookie/session via Sanctum SPA mode (axios sends XSRF + credentials);
|
||||||
|
the API-key gate protects the expensive station search from scraping.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
The Vue SPA is documented in `.claude/rules/frontend.md`. Do **not** build new
|
||||||
|
Livewire components — Livewire/Flux remain only for the starter-kit auth screens
|
||||||
|
and the `Logout` action.
|
||||||
|
|||||||
74
.claude/rules/frontend.md
Normal file
74
.claude/rules/frontend.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Frontend — Vue 3 SPA
|
||||||
|
|
||||||
|
The entire user-facing app (landing, station search, dashboard, settings) is a
|
||||||
|
**Vue 3 single-page application** under `resources/js/`. It is served from the
|
||||||
|
Blade shell `resources/views/app.blade.php` via the SPA catch-all in
|
||||||
|
`routes/web.php` and consumes the REST API (`routes/api.php`). There is **no
|
||||||
|
Livewire in the product UI** — do not add Livewire / Volt / Alpine components for
|
||||||
|
new features. Build Vue.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Vue 3.5 with `<script setup>` (Composition API), Vue Router 4 (`createWebHistory`)
|
||||||
|
- Vite 8 (`npm run dev` / `npm run build`), `@vitejs/plugin-vue`
|
||||||
|
- Tailwind CSS v4 (`@tailwindcss/vite`)
|
||||||
|
- Leaflet for maps, iconify-icon + Lucide for icons, axios for HTTP
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/js/
|
||||||
|
├── app.js # Entry — createApp(App).use(router).mount('#app')
|
||||||
|
├── App.vue # Root — <RouterView/>, fetches the user on mount
|
||||||
|
├── axios.js # Configured axios instance (baseURL '/api', XSRF + credentials)
|
||||||
|
├── router/index.js # Routes + requiresAuth guard (redirects to /login)
|
||||||
|
├── views/
|
||||||
|
│ ├── Home.vue # Landing + station search (mirrors state to the URL query)
|
||||||
|
│ └── dashboard/ # Overview, SavedStations, Preferences, settings/{Profile,Security,Appearance}
|
||||||
|
├── components/ # LeafletMap, StationCard, StationList, PredictionCard/Full,
|
||||||
|
│ # PostSearchFilters, UpsellBanner, landing/*
|
||||||
|
├── composables/ # useAuth, useStations, useSavedStations
|
||||||
|
└── constants/ # fuelTypes.js — shared fuel-type source of truth (front + back)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Composition API + `<script setup>` only.** No Options API.
|
||||||
|
- **All server state goes through composables** that call the configured `api`
|
||||||
|
axios instance (`axios.js`) — never `fetch()` or a bare `axios` call inside a
|
||||||
|
component.
|
||||||
|
- **Auth is cookie/session (Sanctum SPA mode).** axios is configured with
|
||||||
|
`withCredentials` + `withXSRFToken`; don't add bearer-token handling.
|
||||||
|
- Route guards live in `router/index.js` (`meta.requiresAuth`). Hard navigations
|
||||||
|
(`/login`, `/logout`) use `window.location.href`, not the router.
|
||||||
|
- Keep business logic in the API / Services. Vue components render and orchestrate.
|
||||||
|
|
||||||
|
## Search & the shareable URL
|
||||||
|
|
||||||
|
`Home.vue` is the search surface. Search params (postcode **or** lat/lng,
|
||||||
|
`fuel_type`, `radius`, `sort`) are mirrored into the **URL query** via
|
||||||
|
`router.push` (`queryFromParams`) so searches are shareable and bookmarkable, and
|
||||||
|
restored on load (`paramsFromQuery`). Because the URL is shareable, **any
|
||||||
|
coordinates written to it must be coarsened** — a precise GPS pair from "Use my
|
||||||
|
location" (`HeroSearch.vue` → browser Geolocation) would otherwise broadcast the
|
||||||
|
sharer's exact position to everyone they send the link to.
|
||||||
|
|
||||||
|
## Maps (Leaflet)
|
||||||
|
|
||||||
|
`components/LeafletMap.vue` wraps Leaflet directly (Leaflet is a plain JS library,
|
||||||
|
not a Vue plugin). Station and user markers and the Google-Maps directions links
|
||||||
|
are built from `station.lat` / `station.lng`; the user marker comes from the
|
||||||
|
browser Geolocation API.
|
||||||
|
|
||||||
|
## Prediction
|
||||||
|
|
||||||
|
The fuel prediction ships **inside** the `/api/stations` response under the
|
||||||
|
`prediction` key and is rendered by `PredictionCard.vue` / `PredictionFull.vue`
|
||||||
|
(`useStations` reads `response.data.prediction`). See `prediction.md` for the
|
||||||
|
payload shape and the tier gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "resources/js/**/*.vue"
|
||||||
|
- "resources/js/**/*.js"
|
||||||
|
---
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Livewire Components
|
|
||||||
|
|
||||||
## Classic two-file components only
|
|
||||||
|
|
||||||
Do NOT use Volt single-file syntax for new components.
|
|
||||||
Volt files created by the Livewire starter kit (auth screens) are left untouched.
|
|
||||||
|
|
||||||
## Component locations
|
|
||||||
|
|
||||||
```
|
|
||||||
app/Livewire/
|
|
||||||
├── Dashboard/
|
|
||||||
│ ├── FuelRecommendation.php # Main fill-up/wait card
|
|
||||||
│ ├── NearbyStations.php # Map + station list
|
|
||||||
│ └── PriceHistory.php # 14-day trend chart
|
|
||||||
├── Account/
|
|
||||||
│ ├── NotificationSettings.php # Channel prefs + WhatsApp OTP
|
|
||||||
│ ├── SubscriptionManager.php # Upgrade/downgrade UI
|
|
||||||
│ └── FuelPreferences.php # Fuel type, postcode
|
|
||||||
└── Public/
|
|
||||||
└── PriceWatchdog.php # Public-facing local price watchdog
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component conventions
|
|
||||||
|
|
||||||
- Component properties that are shown in the view must be `public`
|
|
||||||
- Use `#[Computed]` attribute for derived values — not re-computed on every render
|
|
||||||
- Validate with `#[Validate]` attribute on properties, not in separate rules array
|
|
||||||
- Never put Service instantiation in the component — inject via method parameter or mount()
|
|
||||||
- Dispatch browser events with `$this->dispatch()` not `$this->emit()` (Livewire 3 syntax)
|
|
||||||
- Use `wire:loading` on all buttons that trigger server actions
|
|
||||||
|
|
||||||
## Alpine.js usage
|
|
||||||
|
|
||||||
Alpine.js handles local UI state only: dropdowns, modals, tab switching, copy-to-clipboard.
|
|
||||||
Do not replicate server state in Alpine — use Livewire for anything that needs PHP.
|
|
||||||
Alpine components stay inline in Blade (`x-data="{}"`), not in separate JS files unless reused 3+ times.
|
|
||||||
|
|
||||||
## Map (Leaflet.js)
|
|
||||||
|
|
||||||
Leaflet is a plain JS drop-in, not a Livewire component.
|
|
||||||
Station data is fetched from a dedicated Livewire endpoint and passed to Leaflet via Alpine:
|
|
||||||
```blade
|
|
||||||
<div
|
|
||||||
x-data="stationMap(@entangle('stations'))"
|
|
||||||
id="map"
|
|
||||||
style="height: 400px"
|
|
||||||
></div>
|
|
||||||
```
|
|
||||||
Map initialisation lives in `resources/js/maps/station-map.js`.
|
|
||||||
|
|
||||||
## Page routing
|
|
||||||
|
|
||||||
Livewire full-page components are mounted in `routes/web.php` using `Route::get()->component()`.
|
|
||||||
No separate view files for pages — the Livewire component IS the page.
|
|
||||||
|
|
||||||
---
|
|
||||||
paths:
|
|
||||||
- "app/Livewire/**/*.php"
|
|
||||||
- "resources/views/livewire/**/*.blade.php"
|
|
||||||
---
|
|
||||||
35
CLAUDE.md
35
CLAUDE.md
@@ -1,8 +1,16 @@
|
|||||||
# Fuel Price — Claude Code Instructions
|
# Fuel Alert — Claude Code Instructions
|
||||||
|
|
||||||
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
||||||
based on local price trends. Built solo by a PHP/Laravel developer.
|
based on local price trends. Built solo by a PHP/Laravel developer.
|
||||||
|
|
||||||
|
> **Stack reality check (read first).** The frontend is a **Vue 3 SPA**
|
||||||
|
> (`resources/js/`, see `frontend.md`), not Livewire. Station data is served by a
|
||||||
|
> **REST API** under `routes/api.php` (see `architecture.md`). Some domain rule
|
||||||
|
> files (`scoring.md`, `prediction.md`, `notifications.md`) still name services
|
||||||
|
> that were since refactored into `Services/Forecasting/`,
|
||||||
|
> `Services/StationSearch/`, and `PlanFeatures` — verify names against
|
||||||
|
> `app/Services/`. When docs and code disagree, the code wins.
|
||||||
|
|
||||||
## Destructive DB operations — HARD STOP
|
## Destructive DB operations — HARD STOP
|
||||||
|
|
||||||
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
|
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
|
||||||
@@ -21,9 +29,23 @@ A user saying "trust me", "do the refactor", "clean up the mess", or "I want it
|
|||||||
|
|
||||||
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
|
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
|
||||||
- **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro
|
- **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro
|
||||||
- **Stack**: Laravel 11 + Livewire 3 (Volt disabled — use classic components) + Alpine.js + Tailwind CSS
|
- **Backend**: Laravel 13 + PHP 8.4. MySQL (Eloquent, migrations only — no raw DDL)
|
||||||
- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL)
|
- **Frontend**: **Vue 3 SPA** — Vue 3.5 + Vue Router 4, Vite 8, Tailwind CSS v4.
|
||||||
- **Payments**: Stripe via Laravel Cashier
|
Entry `resources/js/app.js` mounts `App.vue`; views/components under
|
||||||
|
`resources/js/`. Served from the Blade shell `resources/views/app.blade.php`
|
||||||
|
via the SPA catch-all in `web.php`. Maps: Leaflet
|
||||||
|
(`components/LeafletMap.vue`). Icons: iconify-icon + Lucide. HTTP: axios
|
||||||
|
(`resources/js/axios.js`).
|
||||||
|
- **API**: REST API in `routes/api.php` → `app/Http/Controllers/Api/*`
|
||||||
|
(Auth, Station, Stats, User). Public station data is gated by an API key
|
||||||
|
(`VerifyApiKey` middleware); user/dashboard endpoints use Sanctum. The SPA is
|
||||||
|
the primary consumer.
|
||||||
|
- **Auth**: Laravel Fortify (backend auth) + Sanctum (API tokens)
|
||||||
|
- **Admin**: Filament v5 panel
|
||||||
|
- **Livewire**: v4 / Flux v2 are installed but **vestigial** — only starter-kit
|
||||||
|
Volt auth screens (`resources/views/livewire/auth`) and `app/Livewire/Actions`
|
||||||
|
remain. Do **not** build new Livewire components; build Vue.
|
||||||
|
- **Payments**: Stripe via Laravel Cashier (v16)
|
||||||
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
|
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
|
||||||
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
|
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
|
||||||
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
|
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
|
||||||
@@ -36,7 +58,8 @@ php artisan queue:work # Process notification jobs
|
|||||||
php artisan schedule:run # Run scheduled commands (cron every minute)
|
php artisan schedule:run # Run scheduled commands (cron every minute)
|
||||||
php artisan migrate # Run migrations
|
php artisan migrate # Run migrations
|
||||||
php artisan test # Run Pest test suite
|
php artisan test # Run Pest test suite
|
||||||
npm run dev # Vite asset watcher
|
npm run dev # Vite dev server (Vue SPA + HMR)
|
||||||
|
npm run build # Production build — run if SPA changes don't show up
|
||||||
```
|
```
|
||||||
|
|
||||||
## Imports
|
## Imports
|
||||||
@@ -48,7 +71,7 @@ npm run dev # Vite asset watcher
|
|||||||
@.claude/rules/prediction.md
|
@.claude/rules/prediction.md
|
||||||
@.claude/rules/payments.md
|
@.claude/rules/payments.md
|
||||||
@.claude/rules/tiers.md
|
@.claude/rules/tiers.md
|
||||||
@.claude/rules/livewire.md
|
@.claude/rules/frontend.md
|
||||||
@.claude/rules/api-data.md
|
@.claude/rules/api-data.md
|
||||||
@.claude/rules/testing.md
|
@.claude/rules/testing.md
|
||||||
@.claude/rules/code-style.md
|
@.claude/rules/code-style.md
|
||||||
|
|||||||
214
docs/ops/nginx-log-masking.md
Normal file
214
docs/ops/nginx-log-masking.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Runbook — Mask lat/lng coordinates in server logs (IONOS VPS)
|
||||||
|
|
||||||
|
**Goal:** stop precise search coordinates (`lat`/`lng`) from being written to server
|
||||||
|
logs, while keeping GET-based shareable search URLs and full debugging value (IP,
|
||||||
|
status, path, fuel_type all stay readable).
|
||||||
|
|
||||||
|
**Applies to:** the production Nginx + PHP-FPM box on IONOS. None of this lives in the
|
||||||
|
app repo — it is server config you apply over SSH. The app already stores only a
|
||||||
|
~1km-rounded location bucket + a SHA-256 IP hash in the `searches` table; this runbook
|
||||||
|
covers the *raw* coordinates that transit the server in the URL.
|
||||||
|
|
||||||
|
## Scope — what this does and does NOT cover
|
||||||
|
|
||||||
|
| Sink | Covered here? |
|
||||||
|
|---|---|
|
||||||
|
| Nginx **access** log | ✅ Step 1 (request line + referer) |
|
||||||
|
| Nginx **error** log | ✅ Step 2 (scrub, since `log_format` can't touch it) |
|
||||||
|
| **PHP-FPM** access log | ✅ Step 3 (check / disable) |
|
||||||
|
| Laravel app logs | ✅ Nothing to do — stock config logs no URL, no error tracker installed |
|
||||||
|
| **IONOS edge** (CDN / WAF / managed LB) | ❌ Cannot be redacted from your side — see "Honest limit" |
|
||||||
|
|
||||||
|
> If you ever add Sentry/Flare/Bugsnag later, they capture the full request URL by
|
||||||
|
> default — you'd need a `before_send` scrubber there too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Mask the Nginx access log
|
||||||
|
|
||||||
|
### 1a. Create `/etc/nginx/conf.d/fuel-alert-log-masking.conf`
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Redact lat/lng from the access log — request line AND referer.
|
||||||
|
# Every other field and query param is left intact.
|
||||||
|
|
||||||
|
# --- request line (e.g. GET /api/stations?lat=..&lng=.. HTTP/1.1) ---
|
||||||
|
map $request $fa_req_1 {
|
||||||
|
default $request;
|
||||||
|
"~^(?<rqa>.*[?&])lat=[^& ]*(?<rqb>.*)$" "${rqa}lat=***${rqb}";
|
||||||
|
}
|
||||||
|
map $fa_req_1 $fa_request_masked {
|
||||||
|
default $fa_req_1;
|
||||||
|
"~^(?<rqc>.*[?&])lng=[^& ]*(?<rqd>.*)$" "${rqc}lng=***${rqd}";
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- referer header (e.g. https://fuel-alert.co.uk/?lat=..&lng=..) ---
|
||||||
|
map $http_referer $fa_ref_1 {
|
||||||
|
default $http_referer;
|
||||||
|
"~^(?<rfa>.*[?&])lat=[^&]*(?<rfb>.*)$" "${rfa}lat=***${rfb}";
|
||||||
|
}
|
||||||
|
map $fa_ref_1 $fa_referer_masked {
|
||||||
|
default $fa_ref_1;
|
||||||
|
"~^(?<rfc>.*[?&])lng=[^&]*(?<rfd>.*)$" "${rfc}lng=***${rfd}";
|
||||||
|
}
|
||||||
|
|
||||||
|
# A copy of the standard "combined" format, but using the masked values.
|
||||||
|
log_format fuelalert_masked
|
||||||
|
'$remote_addr - $remote_user [$time_local] '
|
||||||
|
'"$fa_request_masked" $status $body_bytes_sent '
|
||||||
|
'"$fa_referer_masked" "$http_user_agent"';
|
||||||
|
```
|
||||||
|
|
||||||
|
The `map` blocks must sit in the `http {}` context. Files in `/etc/nginx/conf.d/` are
|
||||||
|
included there by default — if your setup doesn't include `conf.d/*.conf`, paste the
|
||||||
|
block inside `http {}` in `/etc/nginx/nginx.conf` instead.
|
||||||
|
|
||||||
|
### 1b. Point the site at the masked format
|
||||||
|
|
||||||
|
Find your site's server block and its current `access_log` line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -T | grep -nE "server_name|access_log|root" | grep -i fuel
|
||||||
|
ls /etc/nginx/sites-enabled/ # the site file is usually here
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside that `server { … }` block, set:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
access_log /var/log/nginx/fuel-alert.access.log fuelalert_masked;
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keep the path the same as whatever it currently is; only the trailing format name
|
||||||
|
`fuelalert_masked` is the change.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Scrub the Nginx error log
|
||||||
|
|
||||||
|
Error-log lines are generated internally by Nginx and **do not pass through
|
||||||
|
`log_format`**, so they can't be masked at write time. They include `request:` and
|
||||||
|
`referrer:` fields that carry the coordinates. Two parts:
|
||||||
|
|
||||||
|
### 2a. Reduce how often request lines are written
|
||||||
|
|
||||||
|
In the same `server { … }` block (or globally), lower the level so routine
|
||||||
|
warn/error/info entries that embed the request line are dropped:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
error_log /var/log/nginx/fuel-alert.error.log crit;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2b. Scrub whatever still gets written
|
||||||
|
|
||||||
|
Create `/usr/local/bin/scrub-fuel-logs.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/sh
|
||||||
|
# Redact lat/lng values in the Nginx error log. GNU sed (Linux). Verified portable expr.
|
||||||
|
sed -E -i 's/([?&])(lat|lng)=[^ &"]*/\1\2=***/g' /var/log/nginx/fuel-alert.error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chmod +x /usr/local/bin/scrub-fuel-logs.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it every minute via a systemd timer.
|
||||||
|
`/etc/systemd/system/scrub-fuel-logs.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Scrub lat/lng from Nginx error log
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/scrub-fuel-logs.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/scrub-fuel-logs.timer`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Run lat/lng scrub every minute
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
OnUnitActiveSec=1min
|
||||||
|
AccuracySec=10s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now scrub-fuel-logs.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Trade-off:** a coordinate from an *errored* request can sit on disk for up to ~60s
|
||||||
|
> before the scrub runs. Error entries are low-volume, so the surface is small but not
|
||||||
|
> zero. For a zero-window setup (nothing un-redacted ever hits disk) you'd route
|
||||||
|
> `error_log` to syslog and scrub at the syslog layer, or tail a RAM-backed raw file
|
||||||
|
> with Vector and write only the redacted copy — more setup; ask if you want it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Check PHP-FPM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -nE '^\s*(access\.log|access\.format)' /etc/php/*/fpm/pool.d/*.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
- **No output / commented out** → FPM isn't logging requests. Nothing to do.
|
||||||
|
- **`access.log` is enabled** → either comment it out, or remove the `%r`, `%q`, `%Q`
|
||||||
|
tokens from `access.format`, then `sudo systemctl reload php*-fpm`.
|
||||||
|
|
||||||
|
(FPM slowlog and error log record the PHP script/backtrace, not the query string —
|
||||||
|
no action needed there.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Apply, test, verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Validate config BEFORE reloading — catches typos safely.
|
||||||
|
sudo nginx -t
|
||||||
|
# If this FAILS, do NOT reload. Fix the error first.
|
||||||
|
|
||||||
|
# 2. Reload Nginx (zero downtime).
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
|
||||||
|
# 3. Trigger a real search in the app, then inspect the freshest lines:
|
||||||
|
sudo tail -n 5 /var/log/nginx/fuel-alert.access.log
|
||||||
|
sudo tail -n 5 /var/log/nginx/fuel-alert.error.log
|
||||||
|
# Expect: lat=***&lng=*** — IP, status, path, fuel_type all still present.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Honest limit — IONOS edge
|
||||||
|
|
||||||
|
Masking only reaches logs **you** control (your Nginx, FPM, app). It cannot touch
|
||||||
|
anything IONOS runs in front of the box:
|
||||||
|
|
||||||
|
- **Plain IONOS VPS / Cloud Server (root server):** there is no HTTP-layer edge — your
|
||||||
|
Nginx is the first HTTP hop, and IONOS network logging is L3/L4 (IPs/ports/bytes, no
|
||||||
|
URLs). In this case this runbook covers everything that exists. ✅
|
||||||
|
- **Managed hosting / Deploy Now / CDN / managed WAF or LB:** those terminate HTTPS and
|
||||||
|
log full URLs with coordinates, on their retention, and you **cannot** redact them.
|
||||||
|
Levers: ask IONOS what they log + for how long, or stop putting coords in the URL.
|
||||||
|
|
||||||
|
**The only way coords never reach *any* HTTP log (yours or theirs) is to keep them out
|
||||||
|
of the URL.** To preserve shareable links without coords in the URL, use **opaque share
|
||||||
|
tokens**: store the search server-side, share `/s/<token>`, resolve token → coords on
|
||||||
|
the server. Coords then appear in zero logs and live in one DB row you control
|
||||||
|
(set its precision + expiry). This is an app change, tracked separately if wanted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
1. In the site `server {}` block, restore the original `access_log` (drop the
|
||||||
|
`fuelalert_masked` name) and `error_log` lines.
|
||||||
|
2. `sudo rm /etc/nginx/conf.d/fuel-alert-log-masking.conf`
|
||||||
|
3. `sudo nginx -t && sudo systemctl reload nginx`
|
||||||
|
4. `sudo systemctl disable --now scrub-fuel-logs.timer` (optional).
|
||||||
Reference in New Issue
Block a user