WP Manifestindependent plugin directory
manifest / events / phoenix-vehicle-condition

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

0stars
0forks

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.zip

Readme

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 with q=#<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 # is rawurlencode()d because add_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, treating mail as "PHP mail(), which WP Engine discards". FluentSMTP — active on this site — declares its own wp_mail(), rebuilds core's body, calls $phpmailer->isMail() (pinning Mailer to mail) and then hands the message to its provider driver. So every send read as undelivered while FluentSMTP's own log said sent. Mailer cannot separate the two; who owns wp_mail() can, so that check now runs first (pn_vc_wp_mail_is_core_file(), via ReflectionFunction) 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

  1. Create a page at /vehicle-photos/ containing [pn_vc_capture].
  2. Create a page at /pn-today/ containing [pn_vc_today].
  3. Settings → Vehicle Photos → open the owner link on the phone once and add it to the home screen.
  4. 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.


Read the full README on GitHub →