Phoenix Nest Vehicle Condition Photos
WordPress plugin: pickup & return vehicle condition photos captured on the customer's own phone via QR without app, no login. Phase-locked before/after evidence filed against the MotoPress booking. Live in production.
by Shoaib Ud Din · github.com/codebyshoaib/phoenix-vehicle-condition · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/codebyshoaib/phoenix-vehicle-condition/archive/refs/heads/master.zipReadme
Phoenix Nest — Vehicle Condition Photos
A single-file WordPress plugin that captures pickup and return condition photos of a rental vehicle on the customer's own phone — no app, no login, no account — files them against the MotoPress booking, and locks each set after submit so the before/after pair is usable as dispute evidence.
The owner opens a bookmarked board on their phone, taps a booking, and shows a QR code. The customer scans it, takes six photos, and hits submit. The set locks and the link dies.
Live in production: phoenixnest.properties — Phoenix Nest Properties LLC (Destin, FL vehicle rentals).
Why this exists
Condition photos are a physical event, not form data. The booking happens online days ahead and the agreement is signed online right after — at neither moment is the customer standing next to the car. So the photos cannot ride:
- the booking form — the vehicle isn't there yet;
- the e-signature document — signer-input fields do text, not file uploads.
They need their own capture surface, opened twice per rental. And the business value is evidence: a photo set that can be quietly replaced later is worth nothing, so the lock is part of the feature, not a hardening extra.
The hard constraint is that the person holding the camera is a customer you cannot ask to install anything or create an account. That single requirement drives the whole design.
Architecture
sequenceDiagram
actor O as Owner (phone)
participant B as Owner board /pn-today/
actor C as Customer (phone)
participant P as Capture page /vehicle-photos/
participant WP as WordPress
Note over O,B: bookmarked once with ?k=SECRET<br/>then a derived HttpOnly cookie
O->>B: open board for a chosen day
B-->>O: cards — coming back · going out · still out
alt customer is standing here
O->>B: tap Show QR
Note over B: QR drawn in-browser onto a canvas<br/>never sent to a QR-image API
else customer has already gone
O->>B: tap Send pickup or return photo link
Note over B: phase is DERIVED, so there is no choice to offer
B-->>O: green or red toast, dismissed with the X
end
C->>P: scan or tap → /vehicle-photos/?t=TOKEN (128-bit)
Note over P: phase DERIVED from state —<br/>no pickup → pickup, pickup → return, both → read-only
loop each of 6 slots
C->>WP: one photo per AJAX request
WP-->>C: thumbnail + progress
end
C->>WP: Submit
Note over WP: re-check completeness server-side →<br/>write the phase lock → ROTATE the token
WP-->>C: locked — this link is now dead
The four surfaces
| Surface | What it is |
|---|---|
Owner board — /pn-today/, [pn_vc_today] |
Day-by-day work board with date navigation and customer search. Also sends the capture link to a customer who isn't present (v1.2.2). Renders as its own document, bypassing the site theme entirely (see engineering notes). |
Capture page — /vehicle-photos/, [pn_vc_capture] |
Six labelled slots, capture="environment", one AJAX upload per photo. Renders inside the theme, where site branding reassures the customer. |
| Booking metabox | Per-phase status, thumbnails, and an admin-only Reopen action. |
pn_vc_nudge endpoint |
Not a screen of its own. The board's send button posts here, and from v1.2.3 so does the Customers screen in Phoenix Nest Customer Records, which sends a back URL and gets returned to it (validated same-host). The endpoint is the contract between the two plugins — neither calls the other's functions, and the phase is always re-derived here from the locks, never taken from the form. |
| Upload notice (v1.3.0) | Not a screen either. When a phase locks, the owner is emailed at the site's admin_email (filter: pn_vc_notify_email) with which booking, who filed it, and a link that opens that one booking on the board from a phone with no login. Closes the loop the nudge opened: a customer who uploads at 11pm no longer waits for the owner to think of looking. |
The upload notice carries the owner key, and that is the trade. The board renders nothing without the cookie or
k, so a keyless link would open a blank page on any device that had never been set up — worse than not sending it. So the message is a credential: it grants the whole board (every customer's name and dates, live capture tokens and codes, the send buttons). It goes to the site's own admin address and nowhere else, the link is last in the body with nothing after it (a half-linkified credential is its own failure), and Settings → Vehicle Photos → regenerate revokes every copy at once. The booking is pinned withq=#<id>rather than the customer's email —pn_vc_matches_search()already matches the id, a search replaces the day window so it resolves whatever the dates are, and no customer address ends up in an inbox or a server log. The#israwurlencode()d becauseadd_query_arg()does not encode: a raw one truncates the URL into a fragment and the board opens on today. | Settings | The owner link, and a regenerate button that also invalidates every phone's cookie. |
Data model
WP attachments hung off the booking post. No custom tables.
| Where | Key | Value |
|---|---|---|
| attachment | post_parent |
booking ID |
| attachment meta | _pn_vc_phase · _pn_vc_slot |
pickup|return · front|rear|left|right|interior|odometer |
| attachment meta | _pn_vc_captured_at · _captured_ip · _captured_by |
provenance; captured_by is derived from the request, never from a field the phone sends |
| booking meta | _pn_vc_token |
128-bit hex, rotated on every phase submit |
| booking meta | _pn_vc_code |
6-digit scan fallback, re-rolled with the token |
| booking meta | _pn_vc_nudged_pickup_at · _pn_vc_nudged_return_at |
when the capture link was last sent for that phase. Drives the board's "link sent" line |
| booking meta | _pn_vc_nudged_pickup_r · _pn_vc_nudged_return_r |
the outcome: sent (a real transport took it), nosmtp, failed, or noemail. Only sent shows green — wp_mail() returning true over PHP mail() means nothing was delivered. Stored rather than passed in the URL, so the toast cannot be made to claim a delivery that never happened. Cleared for both phases by Reopen, whose token rotation voids either link |
A mail plugin that replaces
wp_mail()is trusted, and finding that out cost a red lie (v1.2.4).pn_vc_mail_transport()judged delivery from$GLOBALS['phpmailer']->Mailer, treatingwp_mail(), rebuilds core's body, calls$phpmailer->isMail()(pinningMailertosent.Mailercannot separate the two; who ownswp_mail()can, so that check now runs first (pn_vc_wp_mail_is_core_file(), viaReflectionFunction) and an unknown path still counts as core, keeping the stricter reading. A red lie is not the safe direction — it costs the owner exactly what a false green costs him. | booking meta |_pn_vc_pickup_done_at·_pn_vc_return_done_at| the phase locks — phase is derived from these, never passed in a URL |
Security model
| Concern | Decision |
|---|---|
| Token guessing | bin2hex(random_bytes(16)) — 128 bits, so no rate limiting needed |
| Customer photographs the QR off the owner's screen | The token rotates on submit, so a captured link is dead immediately. Residual risk (a set filed early by whoever holds the link) is covered by the admin Reopen action rather than pretended away |
| A sent link outlives the handover (v1.2.2) | Rotation-on-submit still ends a link the customer used. What changed is the link the customer didn't use: it has no expiry, so a nudge that is never acted on stays valid, and the token now lives in a chat thread rather than 30 seconds on a screen. Accepted deliberately — the phase lock is the gate, and the token resolves nothing but that one booking's capture form |
The nudge endpoint is registered nopriv |
Its only authorisation is the owner cookie/key, so it is POST-only (enforced, not assumed — admin-post.php dispatches on any method and a SameSite=Lax cookie is sent on a top-level cross-site GET), nonce-bound per booking, and post-type checked before it writes. Both owner pages send frame-ancestors 'self' + X-Frame-Options: SAMEORIGIN, which blocks the cross-origin frame a clickjack needs while leaving the page editable in the site's own builder |
| The result toast could be made to lie | Everything it states — that a nudge happened, which phase, and whether the mailer took it — is read from stored meta. The redirect carries only the booking id. A SameSite=Lax cookie rides a top-level cross-site GET, so any fact taken from the URL is a fact an attacker picks: the panel would have told the owner he had emailed a renter he never emailed, and he would stop chasing them |
| The 6-digit fallback code is brute-forceable | It only resolves a booking inside the handover window (going out today/tomorrow, or still out), it re-rolls on submit, and a per-IP counter adds a speed bump. The window is the real guard |
| Owner secret leaking to the customer standing next to them | The board takes ?k= once and then rides a wp_hash()-derived HttpOnly cookie, so the master key never appears in the capture URL, its DOM, or a screenshot. Both pages send Referrer-Policy: no-referrer |
| Phase submitted twice | Completeness and the lock are both re-checked server-side inside the submit handler |
| Uploaded files are public-by-URL | True of wp-content/uploads/ in any WordPress install. Filenames are therefore randomised (pn-vc-<booking>-<phase>-<slot>-<64 random bits>.jpg) so a URL cannot be guessed from a phone's IMG_0042.jpg naming. A protected-directory + nonce-checked-endpoint upgrade path is documented for when that isn't enough |
Installation
# From wp-content/plugins/
git clone https://github.com/codebyshoaib/phoenix-vehicle-condition.git
Or upload the ZIP via Plugins → Add New → Upload Plugin, then Activate.
Requirements: WordPress 5.7+, PHP 7.4+, MotoPress Booking Calendar (or any booking CPT — see pn_vc_booking_post_types()).
Setup
- Create a page at
/vehicle-photos/containing[pn_vc_capture]. - Create a page at
/pn-today/containing[pn_vc_today]. - Settings → Vehicle Photos → open the owner link on the phone once and add it to the home screen.
- Exclude both paths from any page cache. A cached page ships a stale security nonce, and every upload then fails with a misleading "check your signal".
The plugin fails loud with an admin notice if either page is missing.