=== Medifence – Access Control for Media Uploads ===
Contributors: redwoodcity
Tags: access control, media, uploads, protection, membership
Requires at least: 6.0
Tested up to: 7.0
Requires PHP: 7.4
Stable tag: 1.23.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Blocks direct access to uploaded media files for visitors who are not logged in. File URLs stay unchanged.

== Description ==

By default, every file inside the WordPress uploads directory can be opened by anyone who knows its URL. For membership sites this means that protected content pages are useless if the images inside them can still be opened directly.

Medifence puts a login check in front of every uploads request — without changing a single URL and without moving any files.

= How it works =

On activation, the plugin writes a small rule block into the `.htaccess` file inside your uploads directory (using WordPress's own marker mechanism, so rules from other plugins are preserved). Requests for existing files are rewritten to WordPress itself, which bootstraps normally; the plugin then verifies the visitor is logged in and streams the file. No WordPress core file is ever loaded directly. Deactivating or uninstalling the plugin removes the rules, and your files are served normally again.

= Features =

* File URLs stay exactly the same — nothing to update in posts or the database
* Physical files stay where they are — no migration needed
* Built-in live verification: the settings screen requests a test file without a session cookie and shows the actual HTTP status anonymous visitors receive
* A warning appears on all admin screens if another plugin overwrites the rules
* Optionally require a specific capability in addition to being logged in
* Choose the denied status code (403, 404, or 401) — 404 hides that the file exists
* Or send visitors who open a protected file to the login screen, and back to the file afterwards
* Or to a page of your own — a membership or contact page, for people who have no account yet
* Optionally serve a placeholder image instead of denied images
* Exclude specific file extensions (useful when your theme loads CSS or fonts from uploads)
* Exclude individual folders, or flip it around and protect only the folders you list — picked from the folders you actually have
* Or let each file follow the post it is attached to: published posts stay public, private ones require a login
* Make a single file an exception to all of that — always public, or always protected — from the media library
* Share a single protected file with someone who has no account, using an expiring signed link — and revoke any of them individually
* Limit a share link to a number of downloads, note down who it was issued to, and see how often it has been used
* Pause the protection for a few minutes while diagnosing a problem — it always resumes by itself, and the rules stay in place
* A daily check restores the rules automatically if another plugin removes or overwrites them
* Optionally emails you when the protection stops working — once, when the verdict changes
* Reports the protection status in Site Health, and offers WP-CLI commands for deployments
* Inspect any single file to see whether it is reachable, and which setting decided it
* Preview a settings change before saving it: see exactly which files would stop being protected
* Carry a configuration from staging to production as a JSON file, reviewed before it is applied
* See which administrator changed the protection, when, and from what to what
* Audit the whole library in one click and get the list of files your settings leave public
* Optionally count what was blocked, per file — without storing IP addresses or user agents — and download the list as CSV
* See at a glance which files are protected, from a column in the media library
* Range request support for video and audio seeking, ETag-based browser caching for logged-in users
* Cleans up completely on uninstall

= Requirements =

* Apache or LiteSpeed with mod_rewrite enabled
* A writable `.htaccess` file in the uploads directory

This plugin does not work on nginx, because nginx does not read `.htaccess` files. The built-in live test will tell you immediately whether your server is compatible.

= For developers =

The final access decision can be filtered:

`
add_filter( 'medifence_allow_access', function ( $allowed, $file_path ) {
    // Example: always allow files inside uploads/public/.
    if ( false !== strpos( $file_path, '/uploads/public/' ) ) {
        return true;
    }
    return $allowed;
}, 10, 2 );
`

== Installation ==

1. Upload the `medifence` folder to `/wp-content/plugins/`, or install it from the Plugins screen.
2. Activate the plugin. The `.htaccess` rules are written automatically.
3. Go to **Media → Access Control** and confirm that the live test reports "Anonymous access is blocked".
4. Optionally, open a media file URL in a private browsing window to verify it yourself.

== Frequently Asked Questions ==

= Do my file URLs change? =

No. Existing URLs in posts, themes, and the database keep working exactly as before. Logged-in visitors see no difference at all.

= How do I know the protection is actually working? =

The settings screen runs a live test: it creates a temporary file, requests it over HTTP without any session cookie, deletes it, and reports the status code. If your server configuration prevents the rules from working, you will see it there — not months later.

= Does it work on nginx? =

No. The plugin relies on `.htaccess` rewrite rules, which nginx does not support. You would need an equivalent rule in your nginx configuration, which is outside what a plugin can safely manage.

= What happens when I deactivate the plugin? =

The rules are removed from the `.htaccess` file and all files are served normally again. Uninstalling additionally deletes the plugin's settings.

= Another plugin overwrote the .htaccess file. What now? =

Medifence detects this and shows a warning on your admin screens. One click on "Regenerate .htaccess rules" restores them. A daily scheduled check also rewrites the rules on its own, so the protection comes back even if nobody logs in; you can turn that off in the settings.

= Will I hear about it if the protection breaks while nobody is logged in? =

Switch on the email notification in the settings. The daily check then sends one message when it finds that anonymous visitors can reach your files, and one more when the protection works again. It is sent only when the verdict changes, so a problem that persists does not fill your inbox, and a live test that could not reach the site at all is never reported as a failure. Use "Send a test email" first to confirm that this site can send mail at all.

= Does this modify files outside the plugin directory? =

Yes, one file: the `.htaccess` inside your uploads directory. The plugin uses WordPress's `insert_with_markers()` function, which only touches its own marked block and preserves everything else. The block is removed on deactivation and uninstall.

= Does the plugin contact any external service? =

No. The live test sends one HTTP request to your own site. Nothing is sent anywhere else.

= Does the plugin store personal data? =

Nothing about your visitors. The optional access log records the file path, what happened to it, and when — no IP addresses, no user agents, and no user names. It is off unless you switch it on, keeps at most 200 files for 30 days, and can be cleared with one click.

The settings history is the one place a person is named, and only an administrator of your own site: when the settings that decide access change, it records which user account made the change. That is the point of it — a protection that was widened should be traceable. It keeps the last 50 changes and can be cleared with one click.

= How do I find out which files my visitors are actually blocked from? =

Switch on the access log in the settings, then look at the Access log tab. It counts per file what Medifence did: blocked the request, served the placeholder, sent the visitor to the login screen, or accepted a share link. A file that is blocked over and over is usually one your theme or another plugin needs for everyone — exclude its extension or its folder.

The list can be downloaded as CSV from the same tab — one row per file, with one column per event — or produced on the command line with `wp medifence log --format=csv`.

= Does this slow down my site? =

Protected files are served through PHP, which adds overhead compared to direct file delivery. To keep this small, the plugin sends ETag headers so browsers of logged-in users cache files locally, and repeat requests are answered with an empty 304 response. Files with excluded extensions bypass PHP entirely.

= A file is still reachable right after I enabled the protection. Why? =

Files that were served before the protection was active — or while their folder or extension was excluded — may still sit in a server-side or CDN cache, and that copy is served without ever reaching WordPress. Medifence sends `Cache-Control: private` on everything it delivers itself, so protected files are never stored in a shared cache; the stale copy disappears once the cache entry expires, or immediately if you purge the cache. Verify with a fresh query string (for example `?x=1`), which bypasses most caches.

= Can I keep the images in published posts public, but protect the ones in members-only posts? =

Yes. Choose the "Follow the post each file is attached to" scope. A file attached to a published post is served to everyone, exactly as before the plugin was active; a file attached to a private, draft, or password-protected post requires a login. Files that belong to no post remain protected, and generated image sizes follow their original — a thumbnail is never more public than the full image.

= Someone clicks a protected PDF and just gets an error. Can I show the login screen instead? =

Yes. Set "When access is denied" to "Send visitors to the login screen, then back to the file". After logging in they land on the file they originally asked for. The redirect applies only when the browser navigates to the file directly — an `<img>` or a video embedded in a page still receives the status code, so your pages never end up showing a login form where an image should be.

= A page is broken and I suspect the plugin. Do I have to deactivate it to check? =

No. Use "Pause the protection" on the Protection status tab: every file is served as if the plugin were inactive, but the `.htaccess` rules stay in place and the protection resumes automatically after the time you chose — 5 minutes up to 1 hour, so it cannot stay off by accident. While the pause lasts, responses carry an `X-Medifence: paused` header, a warning shows on every admin screen, and the email notification does not report the pause as a failure. Also available as `wp medifence pause` and `wp medifence resume`.

= Can I set this up on staging and copy the configuration to the live site? =

Yes. "Download the settings" on the settings tab writes a JSON file, and "Read a settings file" on the other site reads it back. Only the settings travel — share links, the signing key, the access log, and per-file exceptions belong to one site and are never exported.

An imported file is not applied on the spot. It fills in the form as an unsaved change and reports what it would do to *that* library, so a scope that made sense on staging cannot quietly expose files on the live site. Press save once the effect looks right. On the command line, `wp medifence export --file=medifence.json` and `wp medifence import medifence.json` do the same, and `--dry-run` reports the effect without saving.

= I want to change the protection scope, but I am afraid of exposing something. =

Use "Preview the effect" next to the save button. It applies the values currently in the form to your media library **without saving them**, and reports which files would change side — the ones that would stop being protected first, since that is the direction that matters. Your edits stay in the form, so you can then save them or discard them. Nothing is written until you press save.

= The login screen is the wrong answer for people who have no account yet. =

Set "When access is denied" to "Send visitors to a page of your own" and pick a published page — a membership page, a contact form, whatever explains how to get access. The file they tried to open is added to the URL as `mf_from`, so the page can name it ("You tried to open <em>report.pdf</em>").

The same rule as the login redirect applies: it only happens when someone opens the file in their browser. An image or a video embedded in a page still receives the status code, so your pages never show a membership page where a picture should be. If the chosen page is later unpublished or trashed, denied visitors quietly fall back to the status code rather than being sent into a 404.

= A file is public when I expected it to be protected. How do I find out why? =

Use the file inspector on the Protection status tab, or the Audit tab to see all of them at once — it applies your settings to the whole library and lists every file a logged-out visitor can open, with the reason for each.

For a single file: Paste the file's URL (or enter its attachment ID or path) and it reports whether a logged-out visitor can reach it, naming the setting that decided — an excluded extension, an excluded path, a scope that does not cover it, or the status of the post it is attached to. The same check is available from the command line as `wp medifence inspect <file>`.

= One single file has to be public (or protected), but the rest of the folder should stay as it is. =

Select the file in the media library and use the bulk action "Medifence: always public" or "Medifence: always protected"; the same choice is on the file's own edit screen. That one file then ignores the protection scope, and generated image sizes follow their original. Every exception is listed on the Audit tab and can be reset there.

One limit is worth knowing: "always protected" only works for files that reach Medifence at all. If the file has an excluded extension, or sits in an excluded folder, your web server delivers it directly and no plugin is asked. The file inspector says so when it happens.

= Can I let someone without an account see one file? =

Yes. On the settings screen, or from the "Share link" action in the media library, create a share link for that file and choose how long it stays valid. The link is the file's normal URL plus a signature, so it works for that one file only and stops working when it expires.

You can also limit a link to a number of downloads — one, for a file that should be fetched exactly once — and add a note recording who it was issued to. Revalidating a cached file and seeking inside a video are not counted, so a one-download link is not spent halfway through a video.

Every active link is listed on the settings screen with its expiry, how often it was downloaded, and when it was last used; each one can be revoked on its own. "Revoke all share links" additionally replaces the signing key, invalidating everything issued so far at once.

== Screenshots ==

1. Protection status with live verification
2. Settings for the protection scope, denied behaviour, and maintenance
3. Share links with individual revocation
4. The generated .htaccess rules

== Changelog ==

= 1.23.0 =
* The settings that decide access now keep a change history: what moved, from what to what, when, and which administrator did it.
* Covers every route into the settings — the form, an imported file, and WP-CLI — because the record is taken where the settings are written.
* Settings that move no file, such as the log switch or the notification address, are left out so the list stays readable.
* The last 50 changes are kept and can be cleared at any time. Also available as `wp medifence history`, with `--clear`.

= 1.22.0 =
* Denied visitors can now be sent to a page of your own instead of the login screen — a membership or contact page, which reads better to someone who has no account yet.
* The file they asked for is passed to that page as `mf_from`, so the page can name what was behind the door.
* If the chosen page is unpublished or trashed later, denied visitors fall back to the status code instead of being sent into a 404.
* The access log counts this separately from the login redirect.

= 1.21.0 =
* The settings can now be downloaded as a JSON file and read back in on another site, so a configuration worked out on staging does not have to be repeated by hand.
* Only the settings are exported. Share links, the signing key, the access log, and per-file exceptions belong to one site and are never included.
* An imported file is treated as an unsaved change and its effect on the receiving library is reported first, so a scope from another site cannot quietly expose files.
* Every imported value goes through the same sanitizer as the settings form, so a hand-edited file cannot introduce a setting the screen would reject.
* Also available as `wp medifence export` and `wp medifence import <file>`, with `--dry-run`.

= 1.20.0 =
* A settings change can now be previewed before it is saved: "Preview the effect" applies the values in the form to your library without storing them, and lists the files that would change side.
* Files that would stop being protected are reported first — until now the only way to notice was to save, audit, and find out afterwards.
* The edits stay in the form after a preview, so they can be saved or discarded once the effect is clear.

= 1.19.1 =
* The Help tab now covers the access log and its CSV download, which had no entry there at all.
* The developer reference lists the `paused` value of the X-Medifence header and the `pause` and `resume` commands, both introduced in 1.18.0.
* Documentation only. Nothing changes in how files are protected.

= 1.19.0 =
* The access log can now be downloaded as CSV from the Access log tab — one row per file, with one column per event and machine-readable UTC times.
* Handy for a spreadsheet, or for keeping a record before clearing the log. Until now the CSV was only available on the command line.

= 1.18.0 =
* The protection can now be paused for 5, 15, or 60 minutes: every file is served as if the plugin were inactive, and the protection always resumes by itself.
* Deactivating the plugin used to be the only way to rule it out while diagnosing a broken page; a pause does the same without touching the rules — and cannot be forgotten.
* While paused, a warning shows on every admin screen and in Site Health, served files carry an `X-Medifence: paused` header, and the email notification does not report the pause as a failure.
* Also available as `wp medifence pause --minutes=15` and `wp medifence resume`.

= 1.17.0 =
* The protection scope can now be filled in by picking an existing folder from the uploads directory, instead of typing the path.
* Configured paths that do not exist are pointed out — a typo used to look exactly like a working setting.
* The capability field suggests the common ones and reports which roles actually have the capability you entered, or warns when no role has it.

= 1.16.0 =
* The daily check can now email you when the protection stops working, and again when it works again.
* The message is sent only when the verdict changes, so a site that stays broken does not send one every day.
* A live test that could not reach your site at all is never reported as a failure.
* The address can be set separately from the site administrator's, and a test email can be sent from the settings screen.

= 1.15.0 =
* A single file can now be an exception to the protection scope: always public, or always protected.
* Set it for several files at once with the new bulk actions in the media library, or on a file's own edit screen.
* Generated image sizes follow their original, and the exceptions are listed on the Audit tab where each can be reset.
* The file inspector reports when "always protected" cannot take effect because an excluded extension or folder keeps the file away from Medifence.
* Also available as `wp medifence override <id> --set=public|protected|default`.

= 1.14.0 =
* Added an optional access log: it counts, per file, what Medifence did — blocked a request, served the placeholder, sent someone to the login screen, or accepted a share link.
* Off by default. Nothing identifying is stored: no IP addresses, no user agents, no user names — only the file, what happened, and when.
* The 200 most recently seen files are kept, for 30 days, and the log can be cleared at any time.
* Also available as `wp medifence log`, with `--clear`.

= 1.13.0 =
* Added an Audit tab: one click applies the current settings to the whole media library and lists every file a logged-out visitor can open, with the reason for each.
* The result also counts the files that are protected and the attachments whose file is missing on disk.
* Also available as `wp medifence audit`, which covers the entire library and can write the list to CSV.

= 1.12.0 =
* A share link can now be limited to a number of downloads. Once it has been used that often it stops working, even if it has not expired yet.
* Each link can carry a short note, so it is clear later who it was issued to.
* The list of active links shows how often each one was downloaded and when it was last used.
* Reloading a cached file and seeking inside a video are not counted as downloads.
* `wp medifence share` accepts `--uses` and `--label`.

= 1.11.0 =
* Files are now picked from the media library instead of typing an attachment ID by hand — both for the placeholder image and when creating a share link.
* The chosen file's name is shown next to the field, so it is clear what a stored ID refers to.

= 1.10.0 =
* Added an Access column to the media library, showing whether each file is protected or public. Hovering a badge explains which setting decided it.

= 1.9.0 =
* Added a file inspector: paste a URL, an attachment ID, or a path, and see whether a logged-out visitor can reach that file — and which setting decided it.
* The verdict names the exact rule responsible (the excluded extension, the excluded path, the parent post, and so on), so a misconfigured scope is easy to spot.
* Also available as `wp medifence inspect <file>`.

= 1.8.0 =
* Reorganised the settings screen into tabs — Protection status, Settings, Share links, Rules, and a new Help tab — so each part is easier to find.
* Added a status bar that states at a glance whether the protection is active.
* Introduced a stylesheet matching the look of our other plugins. It is loaded only on this screen.

= 1.7.0 =
* The settings screen now lists every active share link, with the file it points at and when it expires.
* Any single link can be revoked without affecting the others; expired entries drop off the list on their own.
* **Links issued before this version stop working.** A link is now valid only while its record exists, which is what makes individual revocation possible.

= 1.6.0 =
* Denied requests can now send the visitor to the login screen and back to the file afterwards, instead of returning a status code.
* The redirect only happens for top-level navigation, so images and videos embedded in a page are never replaced by a login page.

= 1.5.0 =
* Added a third protection scope: each file follows the post it is attached to. Files on published posts are served to everyone, files on private, draft, or password-protected posts require a login, and files attached to no post stay protected.
* Generated image sizes follow their original attachment, so a thumbnail is never more public than the full image.

= 1.4.0 =
* Moved the settings screen from Settings to **Media → Access Control**, next to the library it protects. The screen itself is unchanged; bookmarks pointing at the old location need updating.

= 1.3.1 =
* Added a Japanese translation covering the whole admin interface, the Site Health report, and the plugin description.

= 1.3.0 =
* Added a daily scheduled check that rewrites the rules when they are missing or outdated (can be turned off).
* Added a Site Health test reporting whether uploaded media files are actually protected.
* Added WP-CLI commands: `wp medifence status`, `rewrite`, `remove`, `verify`, and `share`.

= 1.2.0 =
* Added expiring share links: grant access to one protected file without an account, for 1 hour up to 30 days.
* A share link is the file's normal URL plus a signature, so it cannot be reused for any other file or kept alive past its expiry.
* Added a "Share link" action to the media library rows, and a "Revoke all share links" button that replaces the signing key.

= 1.1.0 =
* Added a protection scope setting: protect everything except the folders you exclude, or protect only the folders you list.
* Excluded folders are served directly by the web server, without loading PHP.
* The same scope is now also evaluated in PHP, so behaviour stays consistent when the .htaccess rules are outdated.

= 1.0.0 =
* Initial release.

== Upgrade Notice ==

= 1.23.0 =
Adds a history of who changed the protection settings. Recording starts from this version.

= 1.22.0 =
Adds the option to send denied visitors to a page of your own. The default behaviour is unchanged.

= 1.21.0 =
Adds settings export and import as JSON, reviewed before it is applied. Existing settings are unaffected.

= 1.20.0 =
Adds a preview that shows which files a settings change would expose, before you save it. Existing settings are unaffected.

= 1.19.1 =
Fills in the Help tab for the access log, the CSV download, and the pause commands. Documentation only.

= 1.19.0 =
Adds a CSV download for the access log. No change to how files are protected.

= 1.18.0 =
Adds a temporary pause for the protection that always ends by itself. Nothing changes until you use it.

= 1.17.0 =
Adds a folder picker and validation to the scope settings. No change to how files are protected.

= 1.16.0 =
Adds an optional email notification when the protection state changes. It is off until you switch it on.

= 1.15.0 =
Adds per-file exceptions (always public / always protected). Existing settings are unaffected.

= 1.14.0 =
Adds an optional access log counting what was blocked, per file. It is off until you switch it on.

= 1.13.0 =
Adds an Audit tab listing every file your settings leave public. No functional changes to the protection itself.

= 1.12.0 =
Adds download limits and notes for share links. Existing links keep working unchanged.

= 1.11.0 =
Replaces the attachment ID fields with a media library picker.

= 1.10.0 =
Adds an Access column to the media library so you can see what is protected at a glance.

= 1.9.0 =
Adds a file inspector that explains why any given file is protected or public.

= 1.8.0 =
Reorganises the settings screen into tabs and adds a Help tab. No functional changes.

= 1.7.0 =
Adds a list of active share links with individual revocation. Share links issued before this version stop working; re-issue any you still need.

= 1.6.0 =
Adds an option to send visitors to the login screen when they open a protected file. The default behaviour is unchanged.

= 1.5.0 =
Adds a protection scope that follows each file's parent post. Existing settings are unaffected.

= 1.4.0 =
The settings screen moved from Settings to Media → Access Control.

= 1.3.1 =
Adds a Japanese translation. No functional changes.

= 1.3.0 =
Adds automatic repair of the rules, a Site Health test, and WP-CLI commands.

= 1.2.0 =
Adds expiring share links for granting access to individual files without an account.

= 1.1.0 =
Adds folder-level control over what is protected. Existing settings keep working unchanged.

= 1.0.0 =
Initial release.
