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.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/codebyshoaib/phoenix-vehicle-condition/archive/refs/heads/master.zipA 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.
Engineering notes
- The owner board doesn't render through the theme, on purpose. Measured on a real phone, the site's header put 660 of 844 px of chrome above the first booking, and the page builder's
h1/button/arules overrode the plugin's styles — repainting the primary action as a theme button with an underlined link inside it. Specificity wars against a page builder are unwinnable and unstable across its updates, so the board emits its own minimal document (5.8 KB instead of ~300 KB) withapple-mobile-web-app-capableso the home-screen bookmark opens without browser chrome. The customer page stays in-theme, where branding earns its keep. - Two kinds of timestamp, and mixing them is a real bug. Pickup/return come from a
datetime-localfield and are wall-clock — there is no timezone in2026-07-23 08:00to convert from. The photo locks are written bycurrent_time('mysql')and genuinely are site time. Formatting the first like the second showed an 8 am pickup as 4:00 am and pushed an evening pickup onto the next day, where it vanished off the board.pn_vc_fmt_local()andpn_vc_fmt()exist separately for this reason. 'post_status' => 'any'is a trap. It expands to statuses registeredexclude_from_search => false. Booking plugins routinely register their own statuses, so'any'can return zero rows with no error at all. Enumerateget_post_stati()minustrash/auto-draftinstead.- Never gate behaviour on
has_shortcode( $post->post_content )alone. A page built in a page builder keeps the shortcode in postmeta whiledo_shortcodestill runs — so noindex, cache headers and the entire JS payload silently don't happen, and the page looks perfect. Match the request path too. - Classification rides in with the INSERT.
_pn_vc_phase/_pn_vc_slotare passed viameta_inputso they land in the same query as the attachment. Written afterwards, a resize that dies mid-request (a 48 MP HEIC against ImageMagick limits) leaves an attachment invisible to every query, never replaced, never deleted, and publicly readable. - The QR is generated in the browser, never fetched. The encoded URL contains a booking secret; handing it to a QR-image API hands over the booking.
qrcode-generator(MIT) is vendored for this reason. - Bump the asset version when an asset changes. Confirmed the hard way: with the file correct on disk and the URL unchanged, a CDN happily served the old stylesheet.
- Watch the PHP function-hoisting trap. A top-level
if ( function_exists('…') ) return;guard is always true — PHP hoists top-level declarations at compile time — so the file returns before registering a single hook. Functions exist; nothing is wired; nothing errors. The self-test asserts the registrations for exactly this reason.
Tests
php tests/selftest.php # → OK (110 assertions)
No framework, no fixtures, no WordPress: it stubs the WP functions the plugin touches and asserts the things that fail silently — hook registration, the phase machine and its lock, the day-board roles, the timezone split above, secret normalisation, the short-code scope, and photo keying. Excluded on purpose: the AJAX handlers and rendering, which need a real request.
Part of a four-plugin system
Each plugin owns one job and one set of meta keys, so a bug in a reporting screen can never take down the booking flow.
| Plugin | Job | |
|---|---|---|
| 1 | Custom booking fields (site-specific, not published) | Injects and saves renter fields on the booking form → _phoenix_* |
| 2 | phoenix-esign-bridge | Booking → pre-filled agreement → signed PDF, and records which document belongs to which booking |
| 3 | phoenix-vehicle-condition (this repo) | Pickup + return condition photos at handover → _pn_vc_* |
| 4 | phoenix-customer-records | Read-only: groups every booking into customers and shows all of the above in one place |
flowchart LR
F["1 · booking fields<br/><code>_phoenix_*</code>"] --> E["2 · e-sign bridge<br/><code>_pn_esign_doc_id</code>"]
E --> V["3 · condition photos<br/><code>_pn_vc_*</code> + attachments"]
F --> R["4 · customer records<br/>reads only"]
E --> R
V --> R
This plugin is self-contained: it reads booking meta written by 1, and 4 reads its keys — but it calls no other plugin's functions, so deactivating any of them degrades a display rather than breaking a capture.
Author
Shoaib Ud Din — Full-stack engineer LinkedIn · GitHub
License
GPL-2.0-or-later — consistent with the WordPress plugin ecosystem.
assets/qrcode.min.js is qrcode-generator v2.0.4 by Kazuhiko Arase, MIT licensed, vendored unmodified.