Skip to content

feat: multi-city GTFS uploads, Google auth, changelog & announcement banner - #19

Open
faizm10 wants to merge 9 commits into
mainfrom
feature/multi-city-gtfs
Open

feat: multi-city GTFS uploads, Google auth, changelog & announcement banner#19
faizm10 wants to merge 9 commits into
mainfrom
feature/multi-city-gtfs

Conversation

@faizm10

@faizm10 faizm10 commented Jun 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • Multi-city GTFS pipeline — Upload any city's GTFS ZIP; TypeScript processor (fflate + custom CSV parser) derives 8 data files (routes, variants, stops, schedules, departures, simulation trips) and streams them to Vercel Blob with background processing via after() so the upload API returns in ~5s regardless of feed size
  • Auth-gated feeds — Google/GitHub sign-in required to upload; feeds scoped to session.user.id with ownership check on delete; gtfs_feeds table added to Neon DB
  • Feed mode switcher — "City Feeds" button in the top nav bar (GO / City only / Both); city routes rendered in a separate Mapbox GL layer using GTFS route_color; FeedSelector dropdown with clear upload CTA
  • Announcement banner — Dismissable banner on home page (localStorage) linking to /changelog
  • Changelog page/changelog with full release history styled to match landing page; nav link added to LandingHeader

New files

File Purpose
lib/gtfsProcessor.ts TypeScript GTFS processor (~600 lines)
lib/feedLoader.ts Abstraction over local (GO) vs Blob (city) data sources
lib/feedColors.ts Route color resolution from GTFS route_color field
app/api/gtfs/upload Upload endpoint (auth-gated, returns immediately)
app/api/gtfs/process Internal background processing endpoint
app/api/gtfs/feeds List user's feeds / delete with ownership check
app/api/gtfs/feeds/[id]/geojson-url Redirect to Blob GeoJSON for Mapbox
app/gtfs/page.tsx Upload UI with polling
components/overlays/FeedSelector.tsx Nav-integrated feed/mode switcher
hooks/useFeed.ts Feed state + localStorage persistence
app/changelog/page.tsx Changelog page
components/marketing/AnnouncementBanner.tsx Dismissable banner

API changes

All existing API routes (/api/routes, /api/stops, /api/departures, /api/simulation, /api/variant-stops) now accept an optional ?feed=<uuid> param. Omitting it defaults to "gotransit" — no breaking change.

Test plan

  • GO Transit map loads unchanged (no ?feed= param)
  • Sign in with Google → upload a city GTFS ZIP → feed transitions uploading → processing → ready
  • City Feeds button in nav opens dropdown; GO / City / Both mode switches work
  • City routes render on map with correct feed colors
  • Delete feed removes Blob files + DB row; other users cannot delete your feed
  • Announcement banner shows on home page, dismisses and stays dismissed on reload
  • /changelog page renders correctly

🤖 Generated with Claude Code

Greptile Summary

This PR adds multi-city GTFS feed uploads (client-side Blob upload → /api/gtfs/registerafter()-triggered processing), auth-gated feeds scoped to session.user.id, a feed mode switcher on the map, a changelog page, and a dismissable announcement banner. The existing GO Transit API routes have been refactored to accept an optional ?feed=<uuid> param, backed by a new FeedCache LRU/TTL class and a feedLoader abstraction.

  • GTFS pipeline: GtfsClient uploads directly to Vercel Blob, then POST /api/gtfs/register inserts a DB row and uses after() to asynchronously call /api/gtfs/process; the upload-token and register routes have complementary auth handling.
  • Feed-scoped API routes: all five existing data endpoints (/routes, /stops, /departures, /simulation, /variant-stops) now accept ?feed= and route through resolveFeedSourceFeedCache.
  • UI additions: FeedSelector nav dropdown, useFeed hook with localStorage persistence, announcement banner, and /changelog page.

Confidence Score: 4/5

Safe to merge after fixing the unvalidated callbackUrl redirect in the sign-in page.

The GTFS pipeline, feed-scoped API routes, caching layer, and auth-gating are all well-structured. The one concrete defect is the sign-in page redirecting already-authenticated users to whatever URL is in the query string — a crafted link can silently forward a signed-in visitor to an external domain.

client/app/auth/signin/page.tsx — the unvalidated callbackUrl redirect needs a same-origin guard before this merges.

Security Review

  • Open redirect (client/app/auth/signin/page.tsx lines 36–40): callbackUrl from the query string is used verbatim in redirect(callback) for already-authenticated users, enabling an attacker to redirect signed-in visitors to an external domain via a crafted link.

Important Files Changed

Filename Overview
client/app/auth/signin/page.tsx Open redirect: redirect(callback) uses unvalidated callbackUrl from searchParams for already-authenticated users.
client/app/api/gtfs/register/route.ts Correctly uses after() for async processing; fails loudly if GTFS_PROCESS_SECRET is unconfigured; marks feed failed on trigger error.
client/app/api/gtfs/process/route.ts Processes GTFS feeds with proper secret auth; returns 503 when secret is unconfigured in production; cleans up raw ZIP on success and failure.
client/app/api/gtfs/feeds/[id]/route.ts Ownership check correctly gates deletion on userId match; Blob prefix cleanup covers derived files.
client/lib/feedCache.ts Correct LRU/TTL implementation; gotransit is pinned, city feeds are evicted after 10 min or when the LRU cap (6) is exceeded.
client/lib/feedLoader.ts Clean abstraction over local and Blob feed sources; errors clearly when feed is missing/not-ready.
client/app/gtfs/GtfsClient.tsx Upload flow handles file validation, progress, polling, and stale-processing detection; state machine is correct.
client/lib/gtfsProcessSecret.ts Returns null in production when env var is absent, preventing the dev-secret fallback from leaking into prod.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant UploadToken as /api/gtfs/upload-token
    participant BlobCDN as Vercel Blob
    participant Register as /api/gtfs/register
    participant DB as Neon DB
    participant Process as /api/gtfs/process

    Browser->>UploadToken: POST (get upload token)
    UploadToken-->>Browser: signed token
    Browser->>BlobCDN: PUT feeds/raw/timestamp-file.zip
    BlobCDN-->>Browser: blobUrl

    Browser->>Register: "POST { blobUrl, cityName }"
    Register->>DB: "INSERT gtfs_feeds (status=processing)"
    Register-->>Browser: "{ feedId, status: processing }"

    Note over Register: after() runs post-response
    Register->>Process: "POST x-gtfs-secret + { feedId, blobUrl }"
    Process->>BlobCDN: fetch raw ZIP
    BlobCDN-->>Process: ZIP bytes
    Process->>Process: processGtfsFeed() — parse GTFS, derive 8 JSON files
    Process->>BlobCDN: "PUT feeds/feedId/*.json + geojson"
    Process->>DB: "UPDATE status=ready, blobBaseUrl, routeCount, stopCount"
    Process->>BlobCDN: DELETE raw ZIP
    Process-->>Register: 200 OK

    Browser->>DB: "poll /api/gtfs/feeds?ids=..."
    DB-->>Browser: "{ feeds: [{ status: ready }] }"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant UploadToken as /api/gtfs/upload-token
    participant BlobCDN as Vercel Blob
    participant Register as /api/gtfs/register
    participant DB as Neon DB
    participant Process as /api/gtfs/process

    Browser->>UploadToken: POST (get upload token)
    UploadToken-->>Browser: signed token
    Browser->>BlobCDN: PUT feeds/raw/timestamp-file.zip
    BlobCDN-->>Browser: blobUrl

    Browser->>Register: "POST { blobUrl, cityName }"
    Register->>DB: "INSERT gtfs_feeds (status=processing)"
    Register-->>Browser: "{ feedId, status: processing }"

    Note over Register: after() runs post-response
    Register->>Process: "POST x-gtfs-secret + { feedId, blobUrl }"
    Process->>BlobCDN: fetch raw ZIP
    BlobCDN-->>Process: ZIP bytes
    Process->>Process: processGtfsFeed() — parse GTFS, derive 8 JSON files
    Process->>BlobCDN: "PUT feeds/feedId/*.json + geojson"
    Process->>DB: "UPDATE status=ready, blobBaseUrl, routeCount, stopCount"
    Process->>BlobCDN: DELETE raw ZIP
    Process-->>Register: 200 OK

    Browser->>DB: "poll /api/gtfs/feeds?ids=..."
    DB-->>Browser: "{ feeds: [{ status: ready }] }"
Loading

Fix All in Cursor Fix All in Codex Fix All in Claude Code

Reviews (2): Last reviewed commit: "fix: unstick uploads hanging at 99% and ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

…anner

- Add GTFS upload pipeline: TypeScript processor (fflate unzip + CSV parse)
  derives 8 data files and uploads to Vercel Blob; background processing via
  next/server after() so upload returns immediately
- New API routes: /api/gtfs/upload, /api/gtfs/process, /api/gtfs/feeds,
  /api/gtfs/feeds/[id], /api/gtfs/feeds/[id]/geojson-url
- Gate uploads behind Google/GitHub auth (NextAuth v5); feeds scoped to
  signed-in user with ownership check on delete
- Add gtfs_feeds table to Neon DB schema (Drizzle ORM)
- feedLoader abstraction: resolves GO Transit (local) vs city (Blob) data
  sources; all API routes generalised with ?feed= param and per-feed caches
- City route layer in Mapbox GL using GTFS route_color; FeedSelector moved
  into top nav bar with GO / City / Both mode switcher
- /gtfs upload page with 3-state UI (idle → processing poll → feed list)
- Dismissable announcement banner on home page linking to /changelog
- /changelog page with full release history styled to match landing page
- Add Changelog nav item to LandingHeader

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
transit-flow Ready Ready Preview, Comment Jul 2, 2026 7:27pm

- Replace buffered multipart upload (hit Next.js body limit) with client-side
  direct upload via @vercel/blob/client upload()
- Add /api/gtfs/upload-token for auth-gated Blob token generation
- Add /api/gtfs/register to create DB row + trigger processing after upload
- Update /api/gtfs/process to accept blobUrl directly instead of listing blobs
- Fix auth redirect loop: /gtfs no longer redirects server-side; shows inline
  sign-in gate when unauthenticated (passes authenticated prop to GtfsClient)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Pass user from server session to GtfsClient; show avatar + name in header
- Sign-in page now redirects to callbackUrl (not '/') when already authenticated,
  so signing in from /gtfs brings the user back to /gtfs after OAuth

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…emory fixes

- Map page now applies ?feed= and ?feedMode= params from /gtfs links (previously ignored)
- Selecting a city feed auto-switches to city view; map zooms to the city's extent
- Upload shows a real progress bar; instant client-side validation with friendly errors
- Processing failures surface the stored error message; polling times out instead of
  spinning forever; stale processing feeds are flagged as stuck
- Stale/deleted active feeds fall back to GO Transit with a toast
- GTFS processor streams stop_times/shapes line-by-line and frees zip entries and
  decoded CSVs to cut peak memory on large city feeds

Co-Authored-By: Claude Fable 5 <[email protected]>
Comment thread client/app/api/gtfs/feeds/[id]/route.ts
Comment thread client/app/api/gtfs/process/route.ts
Comment thread client/app/api/routes/route.ts
Comment thread client/app/api/gtfs/upload/route.ts Outdated
…wnership

- Remove auth requirement from upload-token and register; anonymous feeds
  store a null userId (column was already nullable — no migration)
- New lib/localFeeds.ts tracks uploaded feed IDs in localStorage; /gtfs and
  the map's City Feeds dropdown query /api/gtfs/feeds?ids=<local ids>
- Feeds API validates IDs as UUIDs (max 50) and still merges in
  account-owned feeds when a session exists
- Delete allows anonymous feeds via their unguessable UUID; account-owned
  feeds still require the owning session
- Drop the sign-in gate on /gtfs; note that feeds are remembered per browser

Co-Authored-By: Claude Fable 5 <[email protected]>
…ead route

- Delete the raw ZIP from Blob after processing (success or failure); it
  lives at feeds/raw/* which the feed-delete handler never covered
- Replace the "dev-secret" fallback: /api/gtfs/process now refuses to run
  unconfigured in production (503) and register fails loudly before
  accepting an upload if GTFS_PROCESS_SECRET is missing
- New FeedCache (TTL + LRU, GO Transit pinned) replaces unbounded
  module-level Maps in routes/simulation/stops/variant-stops/gtfsRawIndex
- Remove unused server-side /api/gtfs/upload route; process endpoint now
  requires blobUrl instead of falling back to a blob listing

Co-Authored-By: Claude Fable 5 <[email protected]>
- Drop onUploadCompleted from the upload-token handler: it makes Vercel
  Blob call back into the deployment before the browser upload resolves,
  which hangs at ~99% on localhost and protected preview deployments
- Enable multipart uploads (parallel parts + per-part retries) for large ZIPs
- Trigger /api/gtfs/process via the deployment's own VERCEL_URL so previews
  don't call another environment; send the protection-bypass header when
  configured; mark the feed failed if the trigger errors instead of
  leaving it spinning
- Raise register maxDuration to 300s to cover the awaited processing call

Co-Authored-By: Claude Fable 5 <[email protected]>
Comment on lines 36 to 37
const { callbackUrl } = await searchParams;
const callback = callbackUrl ?? "/";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Open redirect for already-authenticated users

redirect(callback) uses callbackUrl directly from the URL query string without any origin check. A crafted link like /auth/signin?callbackUrl=https://evil.com silently forwards an already-signed-in visitor to the attacker's domain, which is a classic phishing vector. (NextAuth's own signIn() with redirectTo does validate the origin, but this direct redirect() call does not.)

Suggested change
const { callbackUrl } = await searchParams;
const callback = callbackUrl ?? "/";
const { callbackUrl } = await searchParams;
// Only allow same-origin redirects — reject absolute URLs and protocol-relative paths.
const callback =
callbackUrl && callbackUrl.startsWith("/") && !callbackUrl.startsWith("//")
? callbackUrl
: "/";

Fix in Cursor Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant