Digital Sovereignty for everyone

Architectural Notes

Signed download links, not WordPress nonces

Every vCard download link this plugin generates (LH_Vcard_plugin::return_signed_vcard_download_url()) is a self-contained, expiring token: {expiry_timestamp}.{hmac_hash}, where the hash covers the sorted user ID list and the expiry, keyed on wp_salt('auth'). It is deliberately not a wp_create_nonce() nonce โ€“ a WordPress nonce’s validity is tied to whichever user is currently logged in at both generation and verification time. That works fine for a link a person generates and clicks themselves in the same browser session, but breaks for a link generated by one identity (e.g. an MCP ability running as its own service account) and opened by a different person in a different, unrelated session.

The token depends only on (user_ids, expiry) plus the server-side secret โ€“ identical validity for any requester, for as long as it hasn’t expired. This mirrors lh-save-down‘s generate_export_token()/verify_export_token() pattern for post exports exactly, scoped to a set of users instead of a single post.

REST route, not admin-ajax

GET /wp-json/lh-vcard/v1/download verifies the token and streams the vCard directly โ€“ no REST response envelope, the same way lh-save-down‘s feed callbacks stream PDFs/epubs directly. permission_callback is deliberately __return_true: the route is reachable without being logged in at all, because the token itself is the authorization. Any capability check belongs upstream, on whoever is allowed to generate a link (an admin screen’s own current_user_can() check, or an MCP ability’s permission_callback) โ€“ not on whoever later opens it.

is_user_member_of_blog() is still checked at redemption time, but as a data-validity check (only ever export vCards for genuine members of the current site), not an attempt to authorize the requester.

2026: retired the admin-ajax path entirely

Versions up to 1.01 served vCards via admin-ajax.php?action=lh_vcard-do_vcard, gated by is_user_logged_in() + a capability check (a nonce was generated and attached to the URL but never actually verified server-side โ€“ it provided no real security). 1.02 added the signed REST route as a second, narrower mechanism just for one MCP ability’s use. 1.03 switched every remaining caller over (user row actions, BuddyPress profile/group links, group reports, the dashboard widget, bbPress reply links) and removed the admin-ajax handler, its hook registration, and the now-dead helper methods (return_vcard_link(), maybe_return_object(), handle_singular_user_id(), handle_multiple_user_id()) entirely. The plugin now has exactly one vCard download mechanism.

Residual risk, flagged deliberately: at removal time there was no tool available to search across every other plugin on the network for a direct call to one of the removed methods. This codebase has a documented precedent of exactly that kind of assumption failing before โ€“ lh-profile-page‘s 2.03 phone-field fatal happened because a method assumed to be internal-only turned out to have an external caller. If something on the site starts building a ...admin-ajax.php?action=lh_vcard-do_vcard... URL directly and it silently stops working, that removal is the cause.

TTL: 15 minutes โ†’ 24 hours

The signed link’s expiry (lh_vcard_signed_link_ttl filter) started at 15 minutes when this was a narrow addition serving only a single, freshly-generated MCP-ability link. Once every UI surface switched over in 1.03 โ€“ including the dashboard widget and group reports, which can sit open or unvisited for hours before a link is actually clicked โ€“ the default was raised to DAY_IN_SECONDS, matching lh-save-down‘s own default for its (structurally identical) signed export links.

2026: vCard generation moved to sabre/vobject

The vCard text itself was originally built by includes/wp-gvc-cf-vcard.class.php, a bundled library with no author, license, or version header โ€“ not something that could be tracked via Composer, since it wasn’t a real published package. 1.04 replaced it with sabre/vobject (Packagist, actively maintained), pulled in via a plugin-level composer.json. Output is still forced to vCard 3.0 (not sabre/vobject’s own 4.0 default) for compatibility with older phone contact-import apps.

Two real bugs in the old library were fixed as a byproduct of the switch, not by hand-patching the old code: its ADR line had a stray extra field (8 components instead of RFC 6350’s 7), and it never line-folded long PHOTO data at the RFC-required 75-character continuation width. Verified directly against a live-generated vCard post-migration.

The old library’s photo/URL-fetching helpers (file_get_contents(), PHP’s own parse_url()) were also replaced with wp_remote_get() and wp_parse_url() respectively โ€“ the WordPress-native equivalents, fixing two more plugin-check flags along the way. includes/wp-gvc-cf-vcard.class.php itself was deleted once nothing referenced it any longer.

The data contract feeding vCard generation โ€“ the $add array shape from add_to_vcard_data() and the lh_vcard_add_data filter other plugins (e.g. lh-profile-page) hook into โ€“ is unchanged; only the internal rendering engine moved.

2026: split from a god class into one class per concern

Before 1.06, LH_Vcard_plugin owned every concern directly โ€“ signed tokens, REST handling, vCard generation, the recently-updated feature, and every third-party UI integration โ€“ roughly 1,000 lines in one class. 1.06 split it into:

  • LH_Vcard_Signed_Link โ€“ token generation/verification/TTL, URL builder
  • LH_Vcard_Builder โ€“ vCard content assembly and sabre/vobject serialisation
  • LH_Vcard_Rest_Controller โ€“ the download REST route
  • LH_Vcard_Recently_Updated โ€“ tracking, cached query, dashboard widget, shortcode, activation backfill
  • LH_Vcard_Integrations โ€“ the six unrelated third-party UI touchpoints (user row actions, BuddyPress member/group links, LH Group Reports, LH Check Ins, bbPress reply links)

LH_Vcard_plugin itself shrank to a thin bootstrap: identity methods, hook wiring, activation/deactivation, cron setup. Matches the precedent already established in lh-profile-page, which underwent the same one-class-per-concern split (see that plugin’s field class docblocks, most concretely its 2.03 phone-field fatal, which traced directly back to an overloaded class doing too much).

LH_Vcard_plugin::return_signed_vcard_download_url() was kept in place as a one-line proxy to LH_Vcard_Signed_Link‘s method of the same name, rather than moved outright โ€“ lh-profile-page‘s search-users MCP ability calls that exact class/method directly, a confirmed, current, load-bearing external caller, not a hypothetical one.

Verified behaviourally identical: a vCard generated post-refactor was byte-for-byte identical to one generated immediately before it (same FN/N/EMAIL/TEL/URL/PHOTO, including the full base64 photo data), aside from the UID field, which sabre/vobject regenerates randomly on every call regardless of refactor.

If you like this plugin please consider making a donation to further development

Install LHero.org

lhero.org

Open it straight from your home screen, full screen and quicker to load.

  1. Tap the Share button . If you cannot see it, tap โ€ขโ€ขโ€ข first.
  2. Scroll down and tap Add to Home Screen.
  3. Tap Add.
  1. Tap the browser menu โ‹ฎ.
  2. Tap Install app or Add to Home screen.
  1. In the menu bar, choose File, then Add to Dock.

Use your browser's menu to install this site or add it to your home screen.