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}/fileThe endpoint returns the file bytes only when the caller is authorised; otherwise it returns 403 Forbidden.
Access model
Section titled “Access model”There are three ways to get a 200 response from the endpoint.
| Caller | How access is granted |
|---|---|
| Staff | Logged-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 visitor | A 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.
Directory protection
Section titled “Directory protection”On activation and on every admin hit, SpecCart writes two files into the uploads directory:
.htaccess— denies direct HTTP access on Apache (both 2.4Require all deniedand legacyDeny from allsyntax).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.
Verifying protection
Section titled “Verifying protection”From any machine (logged out of wp-admin):
# 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/fileIf 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.
Migrating existing integrations
Section titled “Migrating existing integrations”| 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 template | The 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.
Rolling back
Section titled “Rolling back”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.