Skip to content

MAIN - #17280

Open
niteeshkanna-sh wants to merge 111 commits into
react:mainfrom
niteeshkanna-sh:main
Open

MAIN#17280
niteeshkanna-sh wants to merge 111 commits into
react:mainfrom
niteeshkanna-sh:main

Conversation

@niteeshkanna-sh

Copy link
Copy Markdown

No description provided.

This workflow sets up a CI pipeline for Node.js projects, including installation, caching, building, and testing across multiple Node.js versions.
@meta-cla

meta-cla Bot commented Aug 27, 2026

Copy link
Copy Markdown

Hi @niteeshkanna-sh!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

niteeshkanna-sh and others added 27 commits September 1, 2026 16:46
Scaffold a new standalone single-page app alongside the existing project:

- Vite 8 with @vitejs/plugin-react, TypeScript, React 19
- Tailwind CSS v4 wired through @tailwindcss/vite
- Replace the Vite demo page with a minimal starter component
- Pin an inline (empty) PostCSS config so Vite does not walk up and load
  the parent directory's postcss.config.js, which broke the build
- README covering setup, scripts, and layout

Verified: npm run lint, npm run build, and npm run dev all pass, and the
dev server renders the styled page with working component state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
`npm ci` failed at the install step of every CI job:

    npm ERR! `npm ci` can only install packages when your package.json
    npm ERR! and package-lock.json are in sync.
    npm ERR! Missing: yaml@2.9.1 from lock file

The lockfile predates the current dependency tree: tailwindcss 3.4.19
pulls postcss-load-config, which needs yaml@^2, but the lockfile only
carried the top-level yaml@1.10.2.

Regenerated with `npm install --package-lock-only`. The change adds the
missing nested yaml@2.9.1 and drops some stale `peer` metadata flags; no
dependency version or integrity hash changes.

Verified `npm ci` validates under both npm 10 and npm 8.19.4, the version
the lint workflow pins via Node 16.

This unblocks the install step only. The workflows have further problems
that predate this branch, noted on the pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Adds a workflow that builds my-app and publishes it to GitHub Pages, plus
the CNAME for the custom domain.

The workflow installs with `npm ci`, lints, builds, and uploads my-app/dist
as the Pages artifact. It triggers on pushes to main that touch my-app/, and
on manual dispatch. Because it is scoped to main, merging is what deploys;
nothing publishes from a feature branch.

`public/CNAME` is copied verbatim into dist/ by Vite, landing at the site
root where Pages looks for it.

Vite's default base of '/' is correct here: a custom domain serves from the
root, and the built asset paths are root-relative to match.

Verified by running the workflow's exact steps locally -- npm ci, npm run
lint, npm run build -- and confirming dist/ contains CNAME, index.html with
root-relative asset URLs, and the hashed JS/CSS bundles.

Deploying still needs two manual steps that cannot be done from here:
enabling Pages with the GitHub Actions source, and pointing DNS at GitHub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Scaffolds my-app/ as a standalone Vite + React + TypeScript + Tailwind SPA,
syncs the root package-lock.json with package.json so npm ci works again, and
adds a GitHub Pages deploy workflow with a CNAME for niteshacars.in.

Merged at the repo owner's request. CI is red from pre-existing failures that
reproduce on main and are unrelated to these changes; see the pull request
description for the four causes.
Every workflow run in this repo's history has failed, on main as well as on
every branch. The cause is commit dc079b1, which replaced the monorepo's root
package.json with a create-react-app app's. That dropped the `workspaces` field
and the alex, eslint, prettier and test:integration scripts the inherited CI
depends on, leaving four distinct failures:

    lint              npm run alex        -> Missing script: "alex"
    Build / build     react-scripts build -> no public/index.html
    Integration Tests npm run test:integration -> Missing script
    E2E               tasks/publish.sh    -> No workspaces found

Remove build-and-test.yml, e2e-base.yml and lint.yml. These are upstream
create-react-app CI: they exercise packages/ through lerna, verdaccio and
puppeteer on Node 16. Restoring the old root package.json would not fix them
either -- it runs `prettier --list-different` and `eslint .` across the whole
repo, now including the uploaded public_html/ tree and my-app/, so it trades
four failures for a larger set.

Retarget node.js.yml at my-app, the app this repo actively maintains, running
install, lint and build. Drop the 18.x matrix leg: Vite 8 requires
^20.19.0 || >=22.12.0, so that leg could never pass.

This also closes a real gap. No workflow covered my-app on pull requests --
deploy-my-app.yml only runs on main -- so its code was never built or linted
before reaching the default branch.

packages/, docusaurus/, tasks/ and test/ are left untouched; only CI changes.

Verified by running the workflow's exact steps -- npm ci, npm run lint,
npm run build -- from my-app on Node 22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Removes the inherited upstream create-react-app CI (build-and-test.yml,
e2e-base.yml, lint.yml), which exercised packages/ through lerna, verdaccio and
puppeteer on Node 16 and could not pass after commit dc079b1 replaced the
monorepo root package.json with a create-react-app app's.

Retargets node.js.yml at my-app, running install, lint and build on Node 20.x
and 22.x. The 18.x leg is dropped because Vite 8 requires ^20.19.0 || >=22.12.0.

This also gives my-app its first pull request coverage; deploy-my-app.yml only
runs on main, so its code was previously never built or linted before reaching
the default branch.
This repository forked create-react-app and carried its entire monorepo, none
of which was ever developed here. Commit dc079b1 replaced the root package.json
with an app's, dropping the workspaces field, so the monorepo had already
stopped being buildable; PR #4 then removed the CI that exercised it. What
remained was 116k lines of upstream source that nothing builds, tests, or ships.

Removed:

  packages/       334 files, 7.7M  upstream CRA packages
  docusaurus/      59 files, 508K  upstream documentation site
  test/            78 files, 456K  upstream integration tests
  tasks/           15 files,  88K  upstream release and e2e scripts
  CHANGELOG*.md     6 files        upstream release history
  CONTRIBUTING.md                  upstream contributor guide
  SECURITY.md                      Create React App disclosure policy
  screencast*.svg                  upstream README assets

Also removed, because each only configured the directories above and now
resolves to nothing:

  lerna.json                       monorepo config, no packages left
  netlify.toml                     builds docusaurus/website
  .eslintrc.json, .eslintignore    scoped to packages/, test/, docusaurus/
  .prettierrc, .prettierignore     scoped to packages/, test/
  .alexrc, .alexignore             alex prose linter, upstream prose only
  .github/CODEOWNERS               assigns packages/ and docusaurus/ to
                                   upstream maintainers who do not work here
  .github/FUNDING.yml              upstream funding links
  .github/ISSUE_TEMPLATE/          templates for reporting CRA bugs
  .github/lock.yml, stale.yml      upstream issue-bot config

LICENSE is kept: the fork lineage is real and removing a license is not a
cleanup decision. CODE_OF_CONDUCT.md and the pull request template are generic
and still apply.

README.md described a create-react-app app at the repo root via `npm start`.
No such app exists -- the root has no src/ or public/ -- so it was boilerplate
for something that could not run. Rewritten to describe what is actually here:
my-app and public_html, how each is built and deployed, and this history.

Nothing in my-app/ or public_html/ referenced any removed path. Verified with
the CI steps -- npm ci, npm run lint, npm run build -- from my-app, and dist/
still contains CNAME, index.html and the hashed bundles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Removes ~116,000 lines of upstream create-react-app source that nothing in this
repository builds, tests, or ships: packages/, docusaurus/, test/, tasks/, the
changelogs, CONTRIBUTING.md, SECURITY.md and the screencast assets, along with
the configs that only scoped to those directories -- lerna.json, netlify.toml,
the eslint/prettier/alex configs, and the upstream .github meta files.

LICENSE is kept; the fork lineage is real. CODE_OF_CONDUCT.md and the pull
request template are generic and still apply.

README.md described a create-react-app app at the repo root that does not
exist, since the root has no src/ or public/. Rewritten to describe my-app and
public_html, how each is built and deployed, and this history.

Verified with the CI steps from my-app -- npm ci, npm run lint, npm run build --
and by grepping the tree for references to every removed path.
Replaces the Vite starter with an actual site for the business: hero, filterable
fleet, how-it-works, and an enquiry form that posts to the live admin API.

The old public site's theme is not in this repository. asset-manifest.json
references /static/css/main.0700b630.css and /static/js/main.b3748527.js, and
neither file was ever committed. What did survive is the brand token block in
public_html/admin.niteshacars.in/admin/admin.css, whose comments name the
colours outright -- navy #0A0E20, gold #F5A500, Poppins, 14px radius, warm
#FAF8F4 ground. Those are carried into @theme in index.css, so the public site
and the admin panel now share one palette.

The fleet in src/data/cars.ts is placeholder data shaped to the real vehicles
and vehicle_rates tables: body type, fuel, transmission, seats, model year,
daily/weekly/monthly rates, KM limit, extra-KM rate and deposit. Editing that
file is all it takes to put the real cars up.

The enquiry form is wired to the real endpoint rather than mocked.
api/enquiry-submit.php needs no sign-in and config.sample.php already
allowlists https://niteshacars.in for CORS, so submissions land in the admin
panel. It sends the honeypot field the endpoint expects, surfaces per-field
errors from the API, and shows the returned enquiry number on success.

vehicle_id is deliberately not sent. The endpoint validates it against the
vehicles table and the placeholder ids do not exist there, so sending one would
be rejected; the chosen car goes in `requirements` as free text instead. Once
cars.ts carries real ids the vehicle can be linked properly.

Live inventory is not fetched because api/vehicles.php calls
api_guard('vehicle.view'), which a public page cannot satisfy.

Images reuse the two web-sized files already in public_html: background
car.webp (8.4K) and brezza1.avif (27K). The two PNGs there are 876K and 3.5M,
too heavy to ship without processing.

Verified with npm ci, npm run lint and npm run build, then driven in a real
browser: six cards render, the SUV filter narrows to two, and clicking a card's
enquire button preselects that car in the form. The only console error is the
Google Fonts request, which this sandbox's proxy blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the Vite starter with a site for the business: hero, filterable fleet,
how-it-works, and an enquiry form posting to the live admin API.

The theme is rebuilt from the brand tokens in the admin panel's admin.css --
navy #0A0E20, gold #F5A500, Poppins, 14px radius -- because the old public
site's compiled CSS and JS were never committed to this repository.

The fleet in src/data/cars.ts is placeholder data shaped to the real vehicles
and vehicle_rates tables; editing that file puts the real cars up. Live
inventory cannot be fetched because api/vehicles.php requires authentication.

Verified with lint and build on Node 20 and 22, and driven in a browser.
public_html/admin.niteshacars.in/admin/config.php was committed with real
values: a database name, user and password that are in use. This repository is
a fork of facebook/create-react-app and is public, so those credentials are
readable by anyone who finds it.

Untracks the file and adds it to .gitignore. config.sample.php is the template
to copy on a new server, and its values were checked against the live ones --
they differ, so the sample leaks nothing.

The working copy is left in place; `git rm --cached` only stops tracking. The
copy on the server is untouched, and the panel keeps running.

This does NOT remove the file from git history. Every commit that carried it
still does, so the credentials remain readable to anyone who clones. Rotating
the database password is what actually closes this; that has to happen in the
hosting panel.

Scanned the rest of public_html for other committed secrets. Only two other
files matched a credential-shaped pattern, and neither is one:
config.sample.php holds example values, and tools/test-auth.php uses a fixed
throwaway password for a test user it creates and deletes in the same run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
config.php was committed with a real database name, user and password. This
repository is public, so those values were readable by anyone who found it.

Untracks the file and adds it to .gitignore. The working copy and the server's
copy are both untouched, so the admin panel keeps running.

This does not remove the file from git history; rotating the database password
in the hosting panel is what actually closes the exposure.
Replaces the placeholders the site shipped with -- +91 90000 00000 and
hello@niteshacars.in -- which were visible to customers on the live domain.

Four places: the header's Call us button, and the footer's phone and email,
each as both the visible text and the tel:/mailto: href.

The tel: href carries no spaces (+916374942976) so phone dialers parse it,
while the visible text keeps the readable +91 63749 42976 grouping.

Verified in a browser: all four resolve to the new values, and a grep over
my-app confirms no placeholder survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the placeholders the site shipped with -- +91 90000 00000 and
hello@niteshacars.in -- which were visible to customers on the live domain.

Four places: the header's Call us button, and the footer's phone and email,
each as both the visible text and the tel:/mailto: href.

The tel: href carries no spaces so phone dialers parse it, while the visible
text keeps the readable +91 63749 42976 grouping.

Verified in a browser, and by grepping my-app for surviving placeholders.
The site shipped with six invented vehicles -- a Brezza, Swift, City, Innova,
Baleno and Nexon EV -- with invented rates, KM limits and deposits. On a live
domain that is worse than showing nothing: a customer could read a price for a
car that does not exist and call about it.

cars.ts is now an empty array with a commented template for one entry. Adding
the real fleet is filling it back in; nothing else has to change.

The three places that read the array each handle it being empty, so the page
stays coherent rather than rendering a blank grid:

  Fleet     shows a card inviting an enquiry or a call, and hides the
            body-type filters, which would otherwise be a row of controls
            that filter nothing
  Enquiry   hides the car picker, and relabels the free-text field to
            "What kind of car do you need?" so the visitor can say what the
            dropdown would have asked
  Header    unchanged; it never read the fleet

Verified in a browser: no placeholder name appears anywhere on the page, zero
cards and zero filters render, the empty-state card shows, the car picker is
gone, the enquiry form still works, and there are no page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the single scrolling page with six routes matching the old site's
menu: Home, About Us, Our Cars, Tariff, Blog, Contact, plus a 404.

  /          Home      hero, how it works, a closing call to action
  /about     About Us  what hiring involves; deliberately generic copy
  /cars      Our Cars  the fleet listing
  /tariff    Tariff    a rate table built from the same fleet data
  /blog      Blog      post list, currently empty
  /contact   Contact   phone and email cards, and the enquiry form
  *          404       a real page rather than a blank screen

Uses react-router-dom. The nav marks the current page with a gold dot above
it, matching the menu on the old site, and collapses to a button-toggled list
below the lg breakpoint so the six items do not crowd a phone.

Two components were written for one page and had to be rewired. Fleet took an
onEnquire callback to scroll down to the form; it now links to
/contact?car=<name>, and Enquiry reads that query parameter, so choosing a car
survives the navigation between pages. Neither takes props any more.

Tariff and Blog handle being empty the same way the fleet listing does: a card
that invites a call or an enquiry, rather than an empty table or an empty list.
About and Blog carry TODO comments -- their copy states only what the booking
flow actually does, because inventing a founding year, a fleet size or posts
would put untrue claims on a live site.

scripts/spa-fallback.mjs copies dist/index.html to dist/404.html after every
build. GitHub Pages serves static files and knows nothing about client-side
routes, so without it a direct visit to /about, or a refresh on /cars, returns
Pages' own 404 and the app never boots. Pages does serve 404.html for anything
it cannot match, so an exact copy loads the app and the router resolves the
URL. Copied rather than symlinked because the Pages artifact upload does not
follow symlinks, and written in Node so the build still works on Windows.

ScrollToTop resets scroll on navigation, since a client-side route change
otherwise keeps the previous scroll position and opens a page halfway down.
Hash links are left alone so #anchors still work.

Verified in a browser: all seven routes render the right heading, clicking a
nav item changes the URL, /contact?car= is handled with the fleet empty, the
enquiry form is present, the mobile menu opens with all seven links, and no
page errors are raised. 404.html is byte-identical to index.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
)

Two changes shipped together.

The six invented vehicles are gone. cars.ts is now an empty array with a
commented template; everything reading it handles being empty, so no page
renders a hole. Adding the real fleet is filling the array back in.

The single scrolling page becomes six routes matching the old site's menu --
Home, About Us, Our Cars, Tariff, Blog, Contact -- plus a 404 page. The nav
marks the current page with a gold dot and collapses to a toggle on phones.

Fleet and Enquiry were written for one page; a car chosen on the fleet page
now travels as /contact?car=<name> so the choice survives the navigation.

scripts/spa-fallback.mjs copies dist/index.html to dist/404.html after every
build. GitHub Pages knows nothing about client-side routes, so without it a
direct visit to /about or a refresh on /cars returns Pages' own 404 and the
app never boots.

About and Blog carry TODO comments: their copy states only what the booking
flow actually does, rather than inventing a founding year or posts.
Two pieces: a public read-only endpoint so the panel becomes the single place
the fleet is managed, and the SEO work needed for a client-rendered site on a
static host.

## The endpoint

api/public-vehicles.php returns vehicles whose status is Available, with rates
read from the dated rate card the same way the admin reads them, so a price
scheduled for next month does not leak out early. Adding a car in the panel
puts it on the site; setting one to Maintenance takes it off.

It deliberately omits columns the admin's own endpoint returns: reg_number, a
plate is not the public's business; current_km, which reveals how hard a car
has been worked; created_by and created_at, internal; colour, a swatch for the
panel's UI. A vehicle with no rate card is skipped rather than listed with a
blank price, since that would invite an enquiry nobody can answer.

CORS uses the existing public_site_origin allowlist, echoed rather than
wildcarded, matching enquiry-submit.php. GET only. No session, no writes.

The site falls back to src/data/cars.ts when the request fails -- offline, a
CORS rejection, or the endpoint not yet uploaded. That file is empty, so the
page degrades to its "ask us what's available" state instead of an error.

## SEO

The app renders in the browser, so every route was served the same index.html
carrying the home page's title. Google runs JavaScript and would eventually see
the right tags, but WhatsApp, Facebook and X do not run it at all -- they read
the HTML as served and stop. Sharing niteshacars.in/tariff previewed as the
home page.

scripts/prerender-seo.mjs now writes a directory per route with that route's
title, description, canonical and og: tags baked in. Pages serves /tariff/ from
/tariff/index.html. dist/404.html keeps the root page's tags, since it is what
Pages serves for unmatched paths.

src/data/seo.json is the single source for those strings and is also read by
src/lib/useSeo.ts, which updates the same tags on client-side navigation -- the
app never re-requests HTML after boot, so without it the title would stay on
whichever page loaded first.

Also added: sitemap.xml generated from the same file, robots.txt pointing at
it, and AutoRental structured data in index.html carrying the business name,
phone and email, which is what local search results are built from.

This replaces scripts/spa-fallback.mjs; the 404 copy now happens here.

Verified: php -l passes on the endpoint; all six routes build with their own
title, canonical and og:url; 404.html carries the root tags; the sitemap lists
every route. In a browser with the API unreachable, the listing falls back to
the empty state rather than hanging, the title changes on navigation, and no
page errors are raised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
api/public-vehicles.php returns Available vehicles with rates from the dated
rate card, so adding a car in the admin panel puts it on the site. It omits
registration numbers, current KM and internal columns, and skips vehicles with
no rate card rather than listing a blank price. CORS reuses the existing
public_site_origin allowlist. The site falls back to an empty cars.ts when the
request fails, so it degrades rather than erroring.

For SEO, every route was served the same index.html carrying the home page's
title. Google runs JavaScript, but WhatsApp, Facebook and X do not -- sharing
/tariff previewed as the home page. The build now writes a directory per route
with its own title, description, canonical and og: tags, plus sitemap.xml,
robots.txt and AutoRental structured data.

The endpoint still has to be uploaded to the server; the repo is not deployed
to Hostinger.
The metadata described the service generically, so it competed with every
self-drive rental in India and matched none of the searches that actually
convert. Someone looking for a car does not search "self-drive car hire" --
they search "car rental Nagercoil".

Titles and descriptions now name the district and its towns, kept inside the
lengths search results truncate at: every title is 42-53 characters against a
~60 limit, and every description 107-150 against ~155, so none is cut mid
sentence.

The structured data declares the service area rather than only the business:
areaServed is Kanyakumari district within Tamil Nadu, and the address carries
Nagercoil and Tamil Nadu. No street address or coordinates are invented --
those have to be real to be worth anything, and a wrong one is worse than none.

Local ranking also reads the visible page, not just the head. AreasServed
lists the twelve main towns as text on the home and contact pages, so a search
for "car rental Marthandam" lands on a page that says Marthandam. The hero now
names the district and four largest towns in its opening paragraph.

Verified in a browser: all six towns checked appear on the home page, the
title is the district one, and the JSON-LD parses with areaServed "Kanyakumari
district", locality Nagercoil, region Tamil Nadu. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Search Console shows the site at position 3.2 for its main query, with clicks
down 47% over 28 days. The indexed listing explains part of why: it advertises
"NiteSha Cars & Bikes ... premium cars, wedding rentals, and tourist vehicle
services", and the rebuilt site mentioned none of that. Replacing a page that
ranks with one covering less of what the business does is how a ranking is
lost, and the click drop may already be that.

Three services now have their own pages, because they are separate searches
and a page can only rank for what it is about. Somebody hiring a scooter is
not the person booking a wedding car.

  /bikes             two-wheelers, hourly to weekly
  /wedding-cars      decorated cars, reserved dates, vehicles for the family
  /tourist-vehicles  cars and vans with a driver, sightseeing and temple tours

The brand was wrong throughout: the site said "Nitesha Cars", the indexed
listing and the business are "NiteSha Cars & Bikes". Inconsistent naming costs
local ranking directly, so it now comes from seo.json wherever it appears.

Titles now lead with Nagercoil rather than Kanyakumari. The district name is
what people add after the town, not before it, and the competitor ranking
above us leads with Nagercoil too. All nine titles are 40-51 characters and
all nine descriptions 107-152, inside where results truncate.

The home h1 was "Take the wheel. We'll handle the rest." -- the strongest
on-page heading, carrying no keyword at all. It now reads "Self drive car &
bike rental in Nagercoil and across Kanyakumari district", with the slogan
kept as a tagline beneath it.

Nine nav items do not fit a desktop row, so the four services sit behind one
Services trigger, which keeps the six-item menu shape the old site had. Each
service keeps its own route. The mobile menu lists all nine flat.

Menus now close from the click that navigates rather than an effect watching
the path, which oxlint flagged as cascading an extra render.

Verified in a browser: all nine routes render their own h1, the brand appears
in the header, the dropdown holds four items and closes after navigating, and
the home page links to each service two or three times over -- nav, card grid
and footer. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Motion throughout: the hero staggers in, sections and cards fade up as they
are scrolled to, each route change fades the new page in, menus drop open, and
cards lift on hover.

Built on CSS keyframes and one IntersectionObserver rather than a motion
library. Framer Motion is around 34 kB gzipped; this is 0.43 kB, measured
against the previous build. That matters here specifically: the site competes
on local search, Core Web Vitals feed that, and most visitors arrive on a
mid-range phone over mobile data.

Only opacity and transform are animated. Both are composited, so no frame
triggers layout or paint -- animating height, top or margin is what makes a
site judder on the hardware most people actually have. translate3d and
scale3d keep the work on the GPU.

The reveal uses an IntersectionObserver, not a scroll listener: intersections
are reported off the main thread, where a scroll handler would run on every
frame of every scroll. Each element unobserves after firing, so content
animates once rather than re-animating whenever it passes the viewport again.

Route transitions need main to be keyed on the pathname. Without the key React
reuses the DOM node, no mount happens, and the animation never replays.

prefers-reduced-motion is handled in both directions. The whole motion block
is inside a no-preference query, and a reduce query resets [data-reveal] to
full opacity -- without that second rule the reveal would leave every section
permanently invisible for anyone who has asked their system for less motion,
which is worse than having no animation at all.

AreasServed is rewritten rather than patched: wrapping its list items left the
JSX unbalanced, and the structure reads better with the chip as a span inside
the revealed li.

Verified in a browser: a below-fold section measures opacity 0 before scroll
and 1 after, the hero is already at full opacity on load, an animationstart
event for fade-up fires on main at every route change, and under reduced
motion zero elements are left invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Kanyakumari district sends a lot of people to the Gulf, Singapore and
Malaysia, and they come back for weddings, the December holidays and family
occasions. Their problems are not a local customer's: they book months ahead
from another country, land at an airport in a different state, and usually
need a vehicle for weeks rather than days. None of that was addressed
anywhere on the site.

/nri covers the things they actually ask about -- arranging a vehicle before
flying, being met at Trivandrum, which licence works, and dropping the car
back on the way out. Trivandrum is named specifically because it is the
nearest international airport to Nagercoil and where most Gulf and Singapore
flights land, and because "car rental Trivandrum airport Nagercoil" is a
search somebody makes.

WhatsApp is the primary call to action on this page, per the owner. It costs
nothing from abroad and survives the time difference, where a phone call to
India from the Gulf does neither. The link is wa.me with the country code and
no punctuation, which is what the format requires -- a space or a leading plus
fails silently. It carries a prefilled message with blanks for arrival and
return dates, so the first message already has the information we need.

The two offers the owner chose, a long-stay discount and airport pickup, are
described without figures. No percentage, no rate, no "free". They have not
set the terms, and a number on a live page is a promise a customer can hold
them to; inventing one would commit their money. The copy says the daily rate
comes down for longer hires and to ask for the rate, which is true and still
converts. A test asserts no percentage or rupee figure appears on the page.

The licence guidance is the part most worth getting right, since being turned
away at the counter after a night flight is the failure people fear: an Indian
licence works if unexpired, a foreign one needs an International Driving
Permit alongside, passport and visa or OCI as photo ID, and send a photo ahead
so it is confirmed before travelling rather than on arrival.

The services grid now holds five cards and moves to a 3-then-5 column layout
so none is orphaned on its own row.

Verified in a browser: the page renders with its own title, the WhatsApp link
resolves to wa.me/916374942976 with the prefilled text and opens in a new tab
with noopener, Trivandrum, the IDP rule, long-stay and weddings are all
mentioned, no invented figure appears anywhere, and the home page links to it
twice. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"Monthly car rental Nagercoil" is a different search from "self drive car
rental Nagercoil", and the competitor ranking above us has a page for it while
we had nothing. A page can only rank for what it is about.

Their version is a section: a paragraph, four bullets on what affects the
rate, and a button. This goes further, because the way to outrank a thin page
is to answer what it leaves out. Six questions somebody actually has before
handing over a month of hire -- how the KM allowance works across a month
rather than a day, who services the car while they have it, what happens on a
breakdown, whether they can extend, what deposit is held, what to bring -- and
four audiences, since a family back from the Gulf and someone on a work
posting arrive with different worries.

Their page does confirm one thing: it quotes no price either, only what
affects the rate. So the no-figures approach is not a handicap against the
site currently ranking first. This page lists five factors and offers a
same-day quote.

Both spellings of the vehicle class are present. The page said "seven-seater",
but the search is typed "7 seater" -- the numeral now appears here and on the
fleet page, where somebody looking for one would land.

Monthly is linked from the NRI page's long-stay step, which is the natural
path: a visitor reading about staying for weeks is the person who wants this.

Verified in a browser: the page renders with its own h1 and title, mentions
7 seater, NRI, work postings, extra-KM, deposits and servicing, carries six
Q&A entries, and contains no invented figure. All eleven routes still render
an h1. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Monthly hire, NRI visitors and weddings were reachable only through the nav or
a small card in the services grid, where they read as four equal options. They
are not equal: a month-long hire, a family flying in for a season and a wedding
booking are each worth many times a weekend rental, and they are the bookings
worth putting in front of someone before they leave the page.

Each is now a band on navy rather than the page's sand, so it reads as an
offer block instead of more body copy. The layout follows what works on the
competitor's monthly section -- icon, heading, a gold line naming the
question in the visitor's head, a paragraph, and a panel of four checked
points ending in a call to action -- because it is a good pattern, not because
it is theirs. Ours carries three of these where they have one.

Still no figures anywhere in the band. The points say what decides the rate,
not what the rate is; a test asserts no percentage or rupee figure appears.

Verified in a browser: three blocks render, each call to action points at its
own route and navigates, the band contains no invented price, and at 390px
the blocks stack with no horizontal overflow. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The layout pattern only: two overlapping photographs with a circular badge
across them, and beside it a pill label, a heading, a paragraph and numbered
accordion rows. The content is ours. Nobody's founder story is borrowed, and
nothing is claimed about the fleet that has not been established.

The three rows say what is actually true and worth knowing before hiring:
the rate, KM allowance, extra-KM rate and deposit are all stated before the
vehicle is handed over; we cover the whole district including airport
delivery; and cars, bikes, wedding vehicles and drivers come from one place
rather than four.

The circular badge is an SVG textPath around a circle, not letters rotated
individually with transforms -- that approach drifts at different font sizes
and falls apart when the font falls back. Only the svg spins, so the arrow in
the middle stays upright, and it stops under prefers-reduced-motion.

The accordion is buttons with aria-expanded rather than details and summary,
because only one row should be open at a time and native details has no notion
of a group. Clicking an open row closes it, so the section can be collapsed
entirely.

Reuses the two web-sized images already in the project. No new assets: the
remaining photographs in public_html are 876K and 3.5M, too heavy to ship.

Verified in a browser: three rows, the first open at load, opening the second
closes the first, its panel becomes visible, clicking again closes it, the
badge links to /contact, both images render, and at 390px there is no
horizontal overflow. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
niteeshkanna-sh and others added 30 commits September 16, 2026 11:33
Move the admin panel to the repository root
The band has referenced /open-road.jpg since it was written and the file has
never existed, so it has been falling back to flat navy. public_html already
held a usable picture -- a gold car on a mountain road at sunset, which
happens to sit almost exactly on the brand's navy and gold.

Converted from a 3.5 MB PNG to a 166 KB WebP at 1920 wide. The band is
full-bleed behind a scrim, so it never resolves finer than a wide desktop
viewport and the rest of those bytes bought nothing anyone could see.

Deliberately only here. It is a stylised car in snowy mountains, not a
photograph of the fleet and not Kanyakumari, and this band is scene-setting
rather than evidence. On a service card the same picture would read as "this
is the vehicle you would get", which would be a claim rather than a mood.
The comment in the component says so, so the next person does not reuse it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Fill the open-road band with a real image
The dashboard opened at 763px of content on a 900px-tall window, and
every card was a fixed 5- or 4-column row, so 17 stat cards took 5 rows
on desktop and 17 rows on a phone.

- Panel heading and its description now sit on one line instead of
  stacked, at the top of each panel.
- Stat grids use auto-fill minmax() instead of a pinned column count, so
  a wide screen packs more cards per row and a phone shows two rather
  than one. The 900px/560px breakpoints stop fighting that by pinning
  columns of their own and just lower the minimum card width.
- Smaller cards: padding 18px -> 11px 13px, value 1.5rem -> 1.15rem,
  label 0.78rem -> 0.72rem, tighter gaps and margins.
- Value pinned to the bottom of the card (margin-top: auto) so the
  numbers line up across a row when a label wraps to two lines.

Measured on a static copy of the dashboard panel, viewport 900px tall:
763px -> 487px at 1440px wide, 763px -> 506px at 1280px wide.

Also fixes a contrast failure this touched: .stat-label used --ink-faint,
3.35:1 on white, below the 4.5:1 WCAG AA floor for text that size. Since
the label was getting smaller, it moves to --ink-dim at 7.54:1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Two things stop a car added in the panel from showing up on the site the
way the owner expects.

Cars not appearing at all
-------------------------
The site asks /admin/api/public-vehicles.php for the fleet, and when that
request fails for any reason it falls back to an empty list and shows
"Ask us what's available". Six quite different faults all end there --
the panel not published at /admin, a missing config.php, wrong database
credentials, PHP not executing, a car that is not Available, a car with
no rate card -- and the page looks identical for all of them. The site
already logs which one it hit, but it logs to the browser's developer
console, which is not somewhere the person running the business will
ever look.

public/fleet-check.html asks the same question the site asks and says in
plain English what came back and what to do about it, with the raw
response when there is one worth seeing. It is a real file in the web
root, so it is served even in the case where the whole /admin folder is
missing -- which is exactly when a diagnostic living inside /admin would
be useless. The Vehicles panel links to it.

No picture on any car
---------------------
The card markup had an image slot and nothing ever filled it: the public
endpoint returns no image column, so every card rendered the brand's
first letter in grey. Cards now resolve a photograph by filename --
car-maruti-suzuki-swift, then car-swift, then car-suv -- and fall back to
the site's own drawing rather than a letter. Dropping a file into
public/photos is all it takes; there is nothing to register. Four files
named after the body types put a real photograph on every card at once.

car-brezza.avif was already in the repository and is now in photos/,
where it will attach itself to a Brezza in the fleet.

Also fixes the build's content fetch, which still pointed at
admin.niteshacars.in months after the panel moved. That host has no
certificate, so the fetch failed on every build and shipped the defaults
-- meaning nothing typed into "Website content" in the panel has been
reaching the site either. It now uses the same origin the canonical URLs
come from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Put a picture on every car, make a missing car diagnosable, compact the dashboard
…rawn logo

The snake is drawn rather than a stock image: it sits on the navy header
at about 48px, where a photograph of a snake is a brown smudge, and the
gold is the site's own so it reads as part of the mark rather than a
sticker on top of it.

A coil is the one snake shape rotation flatters -- a straight snake
spinning would read as a stick on a spindle. The body tapers, which a
stroked arc cannot do because stroke-width is one number for the whole
path, so it is built as a filled outline: down the outer edge, round the
tail, back along the inner edge, with the head rotated onto the body's
tangent where it ends rather than set at a fixed angle.

The first version was drawn at a size nobody sees it at. At 36px the fine
body and tight coil turned into something that looked like a loading
spinner beside the logo, so the proportions are now set for the size it
actually renders: a thicker body, a more open coil, and 48px rather than
36. It also sat alone in the gap before the navigation with nothing to
belong to; it now sits next to the logo.

It turns once every 18 seconds, slow enough to read as drift rather than
as something loading, and it is hidden below 640px, where the row is
already the logo, a call button and the menu.

Continuous rotation is the kind of motion that makes some people
nauseous and it never stops on its own, so prefers-reduced-motion turns
it off. Verified both ways in a real browser: the transform advances
about 20 degrees a second normally, and the animation computes to none
with the setting on.

The logo badge now follows the same rule the car photographs do: drop a
file named logo into public/photos and it takes over the drawn NS badge
on the next build, with nothing to edit here. A file named snake does the
same for the snake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The deploy generated admin/config.php from DB_* secrets and uploaded it
beside the panel. That was right when the panel's config could only live
there. It is now a trap.

The panel reads nitesha-config/config.php from above the document root,
specifically so that a deploy cannot reach it. But config_path() checks
beside the panel first, so an uploaded copy silently wins over the real
one. A secret that had gone stale -- and the database was renamed since
these were set -- would take the admin offline with "Database
unavailable" on a deploy whose only intent was to change the website,
while the working config sat there untouched and ignored.

So the step is gone and config.php is excluded from the upload again.
The deploy now has no way to affect the panel's database settings, which
is the property the move above the document root was for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The guard that verifies the deploy target reported every curl failure as
"could not list the FTP login directory, so the target is unverified".
That reads like a network problem or a wrong path, so two runs went
looking at the wrong thing -- the actual cause both times was curl exit
67, which means the server rejected the username and password.

Now exit 67 gets its own message naming the cause and the three places
to fix it. Everything else keeps the old wording, which is right for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
install.php wrote config.php to __DIR__ . '/config.php', beside the
panel. That is inside the document root, and the document root is rebuilt
from scratch on every deploy -- so the installer's own output was erased
by the next publish and the panel came back saying it had never been set
up. That is not hypothetical: it is what happened, and it took the admin
offline.

config() already looks for nitesha-config/config.php in each directory
above the panel, which is why the working config was moved there by hand.
The installer now writes to that same place, one level above the document
root, so an install survives a deploy without anyone having to know this.

Three cases, all exercised against a real directory layout:

  nothing exists yet        -> <account>/nitesha-config/config.php
  a config already exists   -> that one is reused, wherever it is, so a
                               working install never ends up with two
                               files and config() picking a coin flip
  parent cannot be written  -> falls back beside the panel, because a
                               panel that works until the next deploy
                               beats a panel that never starts

The environment check was reporting on the wrong directory -- it tested
the panel folder while the file was going somewhere else -- so it now
checks the directory that will actually be written, and says which one
that is and whether a deploy can reach it. The success screen names the
path, and warns when it landed inside the website folder.

storage_path moves above the document root for the same reason. Nothing
reads it today, which is exactly why it would have been found the hard
way later.

The "not set up yet" response is now a page rather than one line of plain
text on a 500. It is the first thing an owner sees after a deploy wipes
the config, and "Not set up yet" reads, at that moment, like the data is
gone. It now says which file is missing, shows the two paths that were
actually searched -- computed, so they cannot drift from the code -- says
plainly that the database is untouched, and links to the installer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
install() created the Super Admin account and only then wrote config.php.
That breaks the one case this file is now most often opened for: a
database that still holds every booking and account, and a config.php
that a deploy erased.

In that state create_user() fails on the duplicate email, install()
returns early, and write_config() never runs -- so the panel is still
unconfigured, still shows "not set up yet", and the installer sends you
round the same loop with no way out. The data was never at risk, but
there was no route back in.

Now the config is written as soon as the database details are known to
work, and an account count decides what happens next: rows already there
means this is a reconnection rather than an installation, so no second
Super Admin is made and the last screen says to sign in with the existing
one. An empty database still gets the account it asked for, and if
creating it fails the message says the settings were saved and a reload
should let you in -- because by then it is true.

Verified by extracting the call order from the parsed function with
comments stripped: db_connect, migrate, write_config, the account count,
create_user, with the only early return between the write and the account
being the write failing itself. Checking it by eye first gave the wrong
answer -- the regex matched the words create_user() and write_config() in
the comment explaining the bug.

The full path could not be run here: this sandbox has no MySQL server, so
the installer's last four UI tests cannot pass and the reconnect branch
is unexercised against a live database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make the panel survive a deploy, and put a turning snake in the header
…lishes

The site has been down with a 403 and the reason was never a wipe.

Hostinger's Git deployment clones the repository and publishes the folder
named in its "Root directory" setting. That setting is public_html, this
repository has a folder called public_html, and it held five leftover
images from the old site and no index.html. Apache had nothing to serve
at the domain root and directory listings are off, so it answered 403.
The deployment reported success every time, because it had done exactly
what it was asked.

Everything that did not add up follows from this. README.md 404s because
it sits at the repository root, which is never published. The folder
looked "empty" because five images and no index page is, to anyone
looking for a website, empty. And Hostinger's deploy runs no build --
the deployment of the last merge took seven seconds -- so my-app/dist
never existed on that server and nothing was ever going to appear there.

So public_html is now the build output, committed. Carrying build output
in a repository is not something to do lightly; it is the right answer
when the host cannot build, because then the repository has to carry the
built thing or the site does not exist. `npm run publish:site` rebuilds
the folder from scratch -- from empty, so a deleted page actually leaves
the server -- and refuses to write a tree missing index.html, .htaccess
or admin/index.php, which are the three files whose absence takes the
whole site or the whole panel down.

The five old images move to assets-original/. They are the only copies,
nothing references them, and anything left in public_html would now be
served at the root of the domain and erased by the next publish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make public_html the built site, which is what Hostinger actually publishes
Hostinger publishes public_html/ verbatim and runs no build, so that
folder is the website. A change to my-app/ or admin/ that does not also
update it never reaches the server -- and nothing anywhere reports a
problem. The deploy succeeds, the site keeps serving the previous build,
and the only symptom is that the thing you changed is not there. That is
the worst shape a failure can take, and it is one forgotten command away
at all times.

So this builds on every push and pull request and fails with the list of
differing paths when the two do not match.

It is deliberately read-only. Regenerating and committing the folder
would mean a workflow that can push to main on its own, which is a far
larger permission than catching a stale folder is worth; the fix stays a
person running `npm run publish:site`.

Checked both ways against the current tree: it passes as things stand,
and appending one line to public_html/index.html makes it report
"Files my-app/dist/index.html and public_html/index.html differ".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Vehicles added in the panel already reach the site the moment they are
saved -- the browser asks for them. The words did not. "Website content"
was read once, by the build, and baked into the HTML, so an edit sat
invisible until somebody happened to rebuild and publish. With the site
now published by committing a built folder, "somebody happens to
rebuild" is not a thing that occurs on its own.

That is the same silent failure as a car that never appears on the site:
the person did what the interface offered and nothing happened, with no
error to explain it.

Baking it was right for a reason that still holds. WhatsApp, Facebook and
most link unfurlers do not run JavaScript, and a site competing on local
search wants its words in the markup rather than behind a fetch. So the
baked copy stays and remains what is served; the browser then asks the
panel for anything newer and swaps it in. Crawlers and unfurlers get the
built copy, visitors get the current one. An edit therefore shows to
people immediately and to Google at the next publish, which is the right
way round.

The merge rules move to src/content/merge.mjs and are now imported by
both the build script and the browser. They decide which of the panel's
edits are safe to apply, and two copies would eventually disagree -- a
disagreement that shows up as the site displaying something the panel
never sanctioned.

Verified against a stub panel in a real browser:

  a correctly shaped edit   HTML as served keeps the built wording, and
                            the heading becomes the panel's after load
  a malformed section       ignored, shipped copy kept, and the console
                            names the nine missing fields

The first attempt at that test invented its own field names, so the merge
rejected it and the edit correctly did not appear -- which looked exactly
like the feature being broken. The shape has to come from defaults.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Show the panel's content edits live, and catch a stale public_html
Hostinger's Git deployment clones the whole repository into the document
root. "Root directory: public_html" is the destination on the server, not
a folder inside the repository. I read it the other way and spent several
rounds putting the site into a public_html/ folder here, which the deploy
then cloned to public_html/public_html/ -- one level too deep, no
index.html at the top, and a 403 every time. Both readings produce a 403,
which is why nothing distinguished them until a listing of the document
root showed .git, my-app, package.json and a nested public_html sitting
in it.

So the built site now sits at the top level of the repository, beside the
source, and public_html/ is gone. It is not a pretty tree. It is what
this host does, and the alternative was asking someone to change a
control panel setting every time the arrangement moved.

admin/ is both the panel's source and part of the build output -- the
build copies it verbatim, and the two are byte-identical -- so it stays
one directory rather than becoming two.

Because the source now shares the web root with the site, .htaccess has
to keep it unreachable. It already denied dotfiles, package.json and the
config files; it now also denies .github (which the .git rule misses,
needing a slash straight after "git" where this has "hub"), the my-app,
scripts and assets-original trees, every .md, and LICENSE. The .git rule
stops being a precaution here: the deploy puts .git in the document root
on every run by design, and it cannot be removed without giving up
automatic deploys, so those rules are the only thing between it and the
internet. The database password in that history still needs rotating.

publish-site.mjs replaces publish-public-html.mjs. It cannot empty the
target first -- the source lives there -- so it records what it wrote in
.site-manifest.json and removes those entries on the next run, which is
what stops a deleted page living at the top level for good.

Verified by serving the repository root the way Apache will:

  /                  200, correct title
  /cars/             200
  /fleet-check.html  200
  /assets/index-*.js 200, 352540 bytes
  /admin/install.php 200
  /admin/            500, and the body is "The panel cannot find its
                     configuration" -- correct, config.php is not in the
                     repository

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Put the site at the top level, where the deploy actually lands
That message means the CSRF check failed, and the CSRF check fails when
the token stored in the session on one request is not there on the next.
Which is almost never about tokens: it is the session not surviving
between the page loading and the form being submitted. Four quite
different faults produce it and they look identical from the outside,
which is exactly the shape of problem that has cost the most time here.

session-check.php does what signing in does -- stores a token, renders it
in a form, checks it when the form comes back -- and reports what
happened alongside the settings that decide it: whether the browser
returned a cookie, whether session storage is writable, whether the
cookie is marked Secure, and whether the visitor is actually on HTTPS.

It singles out the one combination that silently eats every session: a
cookie marked Secure served over plain HTTP, which the browser accepts
and then refuses to send back. That check outranks the round-trip result,
because browsers exempt localhost from the Secure rule -- a local copy
round-trips happily on settings that lose every session on a real domain.
Without that ordering the page reported success and named a fault in the
same breath, which is worse than reporting neither.

Two real mismatches found while reading the path, both in what install.php
writes:

  https_only came from $_SERVER['HTTPS'] alone. This host terminates TLS
  upstream, so PHP is handed a plain HTTP request for a visitor who
  arrived over HTTPS -- the site's own .htaccess says exactly that about
  %{HTTPS} and redirects on X-Forwarded-Proto instead. It now reads both,
  and REQUEST_SCHEME as a third opinion. This is the value that decides
  whether the session cookie carries Secure.

  session_minutes was written; the panel reads session_idle_minutes. A
  hardcoded fallback was doing the work, so changing the value in
  config.php had no effect at all.

Neither is proven to be the cause of what is happening on the server --
that is what the new page is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say why the sign-in page reports "Session expired"
The Add Vehicle form had no way to attach a picture. Photographs only
worked by dropping a file into the repository under a filename matching
the car, which is no use to the person who actually knows which car is
which.

Pick a file, press Save, and it appears on the website's fleet card.

Where the file goes matters more than it looks. Uploads are written under
storage_path, above the document root, because this host deploys by
rebuilding the web root from the repository -- a photograph written
inside it would survive until the next push and no longer. config.php was
lost exactly that way, and a customer-facing photo vanishing during an
unrelated deploy would be the same bug in different clothes. Being
outside the web root also means Apache cannot serve it, so photo.php
reads and streams it: a PHP process per image, in exchange for nothing in
that directory ever being executed.

What decides whether an upload is an image is the file's own contents,
via getimagesize -- never its name or the type the browser claims, both
chosen by whoever is uploading. Verified over a real multipart POST:

  a real PNG                          accepted as .png
  a PHP script named car.jpg,
    declared image/jpeg               refused

The stored name is ours, never the uploader's, because a filename from a
browser can contain path separators. photo.php then only answers for names
matching the exact shape this generates, which is a better rule than
trying to enumerate the ways a path can escape a directory. Every attempt
returns 404:

  ../secret.txt, ../../nitesha-config/config.php, ..%2Fsecret.txt,
  v7-../../../etc/passwd, a well-formed name for a file that does not
  exist, and an empty name

The URL is built against the panel's root rather than used as returned.
The endpoint sends "photo.php?f=...", relative to the panel; in an <img>
that would resolve against the page instead, so the fleet page would ask
for /cars/photo.php and every photograph would 404 on some pages and work
on others. Confirmed in a browser: the card requests /admin/photo.php and
gets 200 image/png, and a vehicle with no photograph still falls back to
the drawing.

The upload is a second request after the save, because a photograph is
stored against a vehicle id and a vehicle being created has none until
the save returns one. The form hides that. Remove marks the photograph
for deletion but changes nothing until Save, so Remove then Cancel leaves
the vehicle as it was.

Not verified: anything touching the database. This sandbox has no MySQL,
so the migration, the UPDATE, and the audit entries are unexercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Add a photograph to a vehicle from the panel
The document root caches every .js and .css for a year as immutable. That
is right for the site's build output, where a changed file is a changed
filename because the name carries a content hash. It is wrong for the
panel: admin.js, admin.css, api.js, car-data.js and booking-data.js have
fixed names, so a browser that loaded the panel once would not ask for
them again until 2027 -- and every change to the panel would reach nobody
who had ever used it, with no error and nothing to notice. The vehicle
photograph upload merged minutes ago would have been the first casualty.

Two parts, because one alone does not finish the job. admin/.htaccess
overrides the header for this folder, which fixes it for browsers that
ask -- and a browser already holding the file will not ask. So the tags
now carry the file's modification time: a changed file is a different
URL, and the cached copy cannot match it.

asset() lives in its own src/assets.php. It went into http.php first,
which was wrong in a way worth recording: http.php is the JSON endpoints'
plumbing, and no page that renders HTML loads it. dashboard.php,
index.php and content.php all require csrf.php and nothing else, so
asset() was defined precisely where it was never called from and every
panel page would have died on an undefined function. Caught by loading
the pages rather than by reading them -- php -l passes on a file that
calls a function that does not exist.

Verified: asset() returns admin.css?v=<mtime> and falls back to ?v=0 for
a file that is not there; the require precedes the first call in all
three pages; index.php reaches its database call, which is as far as
anything gets here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Stop the panel's own scripts being cached for a year
Saving a vehicle with a photograph failed, and the reason was worse than
the symptom: the column the photograph feature needs had never been
created, on any installation.

migrate() ran from exactly two places -- install.php, which refuses once
an account exists, and a button on the content page that nobody has a
reason to press. So a deploy could add a migration and nothing would ever
apply it. The code shipped and the database did not, and the first sign
was a feature failing with a SQL error.

It now runs on dashboard load. migrate() already records what it has
applied and skips those, so the cost is one small SELECT per load. A
failure is reported in a banner rather than thrown: a migration that
cannot run is worth knowing about, and is not a reason to refuse to show
a panel that otherwise works.

The public fleet endpoint had the same problem from the other side, and
it mattered more. It selects a narrow list of columns by name, and one of
those was the new one -- so between a deploy and an admin next signing
in, niteshacars.in/cars would have been asking for a column that did not
exist. A change made entirely inside the panel could empty the fleet on
the live site, for visitors, with nobody signed in to notice. It now
names the column only when it is there and selects NULL otherwise, so the
site does not depend on anyone having opened the admin. Both forms of the
query were checked for validity.

The upload endpoint says what to do rather than returning a SQL error, in
the window where the column is genuinely missing.

Still unverified: the migration actually applying. There is no MySQL
here. What was checked is that the file parses to exactly the one ALTER
statement intended, and that every new function is defined by the require
chain the pages use -- the last change defined a helper in a file no page
loads, which php -l cannot catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Run pending migrations when the panel loads
The photograph upload told the user to "open the Dashboard tab once and
try again". They did, and it could not have worked: the panel switches
tabs in JavaScript without loading the page, so the migration that runs
on page load never ran. An instruction that only works if you know that
is not an instruction, it is a trap. That message was mine and it was
wrong.

So the schema is brought up to date in api_guard instead. Every endpoint
in the panel goes through it, which means a deploy that adds a column has
it created by the first action taken after that deploy, whatever the
action is and whichever page the person is on. Nothing to be told, and
nothing to remember.

migrate() is cheap but not free, and the panel makes several API calls per
page, so migrate_if_needed() decides whether to bother. What it compares
is the newest migration filename: a deploy that adds one changes that
string, so the next request applies it and every request after skips.
There is no version number anyone has to remember to bump.

A failure is returned rather than swallowed, and deliberately not
remembered -- recording it would mean the session gave up retrying for as
long as it lasted. The dashboard shows it in a banner; the upload now
says the database could not be updated and that the database user may not
be allowed to change tables, which is the actual remaining cause once the
migration has genuinely been attempted.

Exercised with migrate() stubbed, since there is still no MySQL here:

  first call                 runs once, records 002_b.sql
  two further calls          skipped, nothing re-run
  a new file appears         runs again, records 003_new.sql
  the call after that        skipped
  migrate() throws           error returned, marker left unset
  the call after that        retries, and succeeds

The first version of that test reported ran=0 for every case and looked
like the feature was dead. The harness was wrong -- __DIR__ inside the
eval'd copy pointed at the temp directory, so the glob found no
migrations at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Apply pending migrations on any panel action, not on a page load
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.

2 participants