Skip to content

Prescription File Access

From version 1.6.0, SpecCart no longer serves prescription files directly from wp-content/uploads/speccart-prescriptions/. All file reads route through an authenticated REST endpoint:

GET /wp-json/speccart/v1/prescriptions/{attachment_id}/file

The endpoint returns the file bytes only when the caller is authorised; otherwise it returns 403 Forbidden.

There are three ways to get a 200 response from the endpoint.

CallerHow access is granted
StaffLogged-in user with the WooCommerce edit_shop_orders capability. No token is required. Used by the admin order meta box and the prescription management screen.
Customer (same session)A short-lived HMAC-signed token, issued by the server for one specific attachment with a 1-hour TTL. Used by the customer’s own wizard after a Smart Upload.
Secure-upload visitorA magic-link token (the one they used to open the secure-upload page), validated against the order that owns the attachment.

Every other request returns 403.

On activation and on every admin hit, SpecCart writes two files into the uploads directory:

  • .htaccess — denies direct HTTP access on Apache (both 2.4 Require all denied and legacy Deny from all syntax).
  • index.html — an empty placeholder so directory indexing never reveals filenames.

If either file is deleted by a server migration or backup restore, the plugin re-creates it the next time an admin page is loaded.

nginx hosts: manual location block required

Section titled “nginx hosts: manual location block required”

nginx ignores .htaccess. If your host runs nginx (either standalone or fronting Apache), the .htaccess file is written but not read. You need a matching location block in your nginx server config:

location ^~ /wp-content/uploads/speccart-prescriptions/ {
return 403;
}

Place this rule before any generic /wp-content/uploads/ rules so it matches first. The authenticated REST endpoint (/wp-json/speccart/v1/prescriptions/{id}/file) continues to work because it’s served by PHP, not the static handler.

From any machine (logged out of wp-admin):

Terminal window
# Should return 403 on a protected host.
curl -I https://your-site.example.com/wp-content/uploads/speccart-prescriptions/2026/04/any-file.jpg
# Should also return 403 when no token is supplied.
curl -I https://your-site.example.com/wp-json/speccart/v1/prescriptions/123/file

If you get 200 OK from either request, protection is not yet active for that path — check that .htaccess exists on Apache, or that your nginx location block is in place.

Where you used to have…Replace with…
wp_get_attachment_url($id) for a prescription attachment\SpecCart\Helpers\PrescriptionFileUrl::for_attachment($id, 'staff') in admin-only contexts
A hardcoded URL in an email templateThe customer-context helper: \SpecCart\Helpers\PrescriptionFileUrl::for_attachment($id, 'customer', $user_id)
A URL in a secure-upload flow\SpecCart\Helpers\PrescriptionFileUrl::for_attachment($id, 'secure-upload', null, $magic_link_token)

Historical reminder emails sent before 1.6.0 will contain raw wp-content URLs that now return 403. Resend those emails with the new URLs if needed.

Deactivating the plugin or reverting to 1.5.0 leaves the .htaccess and index.html files in place. They’re harmless under older versions because nothing requests them. If you need to fully revert protection (not recommended), delete them manually from the uploads directory.