=== External Image Importer - Import Remote Images to the Media Library ===
Contributors: nerdcow
Tags: external images, import images, remote images, media library, migration
Requires at least: 6.0
Tested up to: 7.0
Requires PHP: 7.4
Stable tag: 1.0.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Automatically import external and remote images into your Media Library on save and rewrite the URLs to local copies. Built SSRF-safe.

== Description ==

**External Image Importer** automatically uploads externally-hosted images to your Media Library when you paste external content into the WordPress editor, then rewrites every URL to point at your own local copy. It is built **SSRF-safe**: every remote fetch is validated and hardened before the file is downloaded.

Remove the hassle of importing external images by hand when you:

* migrate to WordPress from another CMS;
* work across multiple environments, such as development, staging and production;
* paste external content drafts written elsewhere.

= The dev-to-live problem this solves =

When you copy or migrate WordPress content between environments - for example from a **dev or staging** site to your **live** site - images embedded in that content stay *hotlinked* to the source domain. The block HTML (or pasted classic-editor markup) keeps pointing at `https://dev.example.com/...`, so your production pages silently load images from staging. If the source environment is private, password-protected or taken down after launch, those images break.

This plugin fixes that automatically: it finds the external image URLs, pulls the files into your own Media Library, and updates the content so everything is served from your own domain.

= Key features =

* **Safe imports.** Every download is verified to be a genuine, decodable image of the expected type before any URL is rewritten. If a file fails that check - for example the "image" was really a geo-blocked placeholder page or an error document - it is discarded and your original reference is left unchanged, so a broken file is never put in place of a working URL.
* **Self-hosted images.** Serving images from your own domain means search engines index them on your domain and your pages no longer depend on a third party staying online.
* **Full-size originals.** It imports the full-size original and lets WordPress regenerate every registered size locally, then points the markup at the matching local size so the layout is unchanged.
* **Duplicate-aware.** Images that already exist in your Media Library (by source URL or exact filename) are reused instead of downloaded again.
* **Zero configuration.** It works on activation; there is nothing to set up.

= Security =

Server-side remote fetching is handled with defence in depth:

* **SSRF guard on every fetch.** Before any download, the target URL is validated. Requests to private, reserved, loopback, link-local, ULA, CGNAT and IPv6 transition (6to4 / NAT64) ranges are blocked - including the cloud metadata endpoint `169.254.169.254`. *All* resolved A/AAAA records must be public.
* **IP pinning and redirect re-validation.** The validated public IP is *pinned* for the actual download (cURL transport), and redirects are followed manually with the SSRF guard re-run on every hop. A public, validator-passing URL therefore cannot be DNS-rebound or 30x-redirected to an internal address after the check.
* **Scheme, port and credential locking.** Only `http` and `https`, only ports `80` and `443`, and URLs containing embedded credentials (`user:pass@`) are refused.
* **Real content-type verification.** After download the *actual* file type is checked, not just the extension. Anything that is not a genuine raster image is deleted and rejected.
* **SVG and ICO are skipped by default.** SVG can carry script and ICO is a common smuggling vector, so both are excluded.
* **Download caps.** A per-request timeout and a maximum file size are enforced (and filterable) so a hostile or runaway source cannot tie up or fill your server.

= How it works =

Paste your content as you normally would, then Publish or Save. When you do, the plugin scans the content for externally-hosted images, securely downloads each one into your Media Library, and rewrites the markup to your local copy at the same size it referenced, so your layout is unchanged.

* **Automatically on save.** A single canonical save hook covers *both* the Block (Gutenberg) editor and the Classic editor, so newly pasted external images are imported the moment you save.
* **It skips duplicates.** Before downloading, it checks whether the image already exists in your Media Library - by the exact remote source URL it previously imported, and by exact filename against your whole Media Library (including images you uploaded normally). If a match exists it is reused instead of downloaded again.
* **It imports the full-size original.** Even when your content references a scaled image (for example `photo-1024x670.webp`), the plugin fetches the full original and lets WordPress regenerate every registered size locally, then points the markup at the matching local size.
* **WP-CLI for retroactive fixes.** Run `wp auei localise` to sweep content that was saved *before* the plugin was active, with `--dry-run`, `--post_type`, `--post_id` and `--limit` options.

= What it scans =

* `src`, `data-src` and `data-lazy-src` attributes
* `srcset` (every candidate, with width and density descriptors)
* Gutenberg block-comment JSON (`url` / `src` / `href`)
* CSS `background-image: url(...)` in inline styles

= Compatibility =

* **Block and Classic editors.** A single save hook (`wp_insert_post_data`) covers both.
* **Single sites and multisite.** On multisite, each site's external images are imported into that site's own Media Library.
* **Alt text and captions are preserved.** The plugin only rewrites image URLs and remaps block attachment IDs; your alt text, captions and other attributes are left untouched.
* **Supported formats:** JPEG, PNG, GIF, WebP, AVIF and BMP. SVG and ICO are excluded by default for security.
* **Actively maintained** and tested against the current WordPress release.

= Privacy and external connections =

This plugin **does not** phone home and **does not** send your data to any third party. There are no analytics, no tracking and no external API calls. The *only* outbound requests it makes are to the image URLs that already appear in *your own* content, purely to download those images into your Media Library. Nothing about your site, content or visitors is transmitted anywhere else.

== Installation ==

1. Upload the `nerdcow-external-image-importer` folder to `wp-content/plugins/`, or install the plugin through the **Plugins > Add New** screen.
2. Activate the plugin through the **Plugins** screen in WordPress.
3. That is it - no configuration is required. External images are imported automatically the next time you save a post or page.
4. (Optional) To fix content saved before activation, run the WP-CLI command: `wp auei localise --dry-run` to preview, then `wp auei localise` to apply.

== Frequently Asked Questions ==

= Will it ever replace a working image with a broken one? =

No - this is a core safety behaviour. Every download is verified to be a genuine, decodable image of the expected MIME type before any URL is rewritten. If the downloaded file is not a real image (for example a geo-blocked placeholder page or an error document), it is discarded and your original external URL is left exactly as it was. A broken local copy is never put in place of a URL that was working.

= Does it work with the Block editor and the Classic editor? =

Yes. A single save hook (`wp_insert_post_data`) covers both the Block (Gutenberg) editor - which saves via the REST API - and the Classic editor.

= Does it work on multisite? =

Yes. It runs per site, so each site's external images are imported into that site's own Media Library.

= What happens to alt text and captions? =

They are preserved. The plugin only rewrites image URLs and remaps the attachment ID stored in Gutenberg blocks; it does not touch your alt text, captions or any other attributes.

= Which image formats are supported? =

JPEG, PNG, GIF, WebP, AVIF and BMP. SVG and ICO are excluded by default for security (SVG can embed script, and ICO is a common smuggling vector). If you fully trust your sources you can extend the allowed set via the `auei_allowed_extensions` filter, but doing so is at your own risk.

= Does it send my data anywhere? =

No. The plugin uses no external services and contains no tracking or analytics. The *only* outbound HTTP requests it makes are to the image URLs that already exist in your own content, purely to download those images into your local Media Library.

= Which image size does it import, and what happens to the block in the editor? =

It imports the **full-size original**. WordPress content usually references a *scaled* image (for example `photo-1024x670.webp`); the plugin strips that `-WxH` suffix, fetches the full original, and lets WordPress regenerate every registered size locally - so you have the thumbnail, medium, large and full versions, just like a normal upload. The image in your content is then rewritten to the local copy at the **same size it referenced**, so the layout is unchanged. It also remaps the attachment ID stored in the block (`"id":N` / `wp-image-N`) to the new local attachment, so the editor's image controls keep working and no stale or colliding source ID is left behind. Both behaviours can be turned off with the `auei_import_full_size` and `auei_remap_ids` filters.

= Will it re-download images it has already imported? =

No. Before downloading, the plugin checks whether the image already exists in your Media Library and reuses it:

1. by the exact remote source URL it previously imported (stored in `_auei_source_url` post meta); and
2. by exact filename against your *whole* Media Library, including images you uploaded normally, not just ones this plugin imported.

Point 2 is the important one for the dev-to-live workflow: if the destination already has the images (for example the page was partly built on live before), they are reused instead of re-uploaded as duplicates. Matching is on the exact stored-file basename, and only when a single unambiguous match exists, so an unrelated image is never picked. Turn filename reuse off with the `auei_reuse_by_filename` filter if you prefer always-fresh imports.

= How do I keep images from a trusted CDN or partner as external? =

Add the domain to the whitelist with the `auei_whitelisted_domains` filter. Whitelisted domains are never imported and their URLs are left untouched.

= Is it safe to run on a server behind a private network? =

Yes - that is a core design goal. The built-in SSRF guard blocks requests to private, reserved, loopback, link-local, ULA and CGNAT addresses, plus the cloud metadata endpoint, and requires every resolved IP of a host to be public before any fetch happens.

== Filters and Hooks ==

All filters use the `auei_` prefix.

* `auei_enabled` (bool, default `true`) - master on/off switch for the whole plugin.
* `auei_whitelisted_domains` (string[], default empty) - domains whose images are never imported (left as external), for example a trusted CDN.
* `auei_import_full_size` (bool, default `true`) - import the full-size original (strip a `-WxH` scaled suffix) so the destination regenerates every size. Set to false to import only the exact file referenced.
* `auei_remap_ids` (bool, default `true`) - rewrite the source-site attachment ID in Gutenberg blocks (`"id":N` / `wp-image-N`) to the new local attachment ID.
* `auei_allowed_extensions` (string[], default `jpg, jpeg, png, gif, webp, avif, bmp`) - image extensions eligible for import. `svg` and `ico` are excluded by default.
* `auei_allowed_schemes` (string[], default `http, https`) - URL schemes permitted for fetching.
* `auei_allowed_ports` (int[], default `80, 443`) - explicit ports permitted on a target URL.
* `auei_blocked_ip_ranges` (string[], default empty) - extra CIDR ranges to block, in addition to the built-in private/reserved set.
* `auei_max_file_size` (int, default `8388608`, i.e. 8 MB) - maximum download size in bytes; larger files are rejected.
* `auei_download_timeout` (int, default `20`) - download timeout in seconds.
* `auei_max_redirects` (int, default `3`) - maximum HTTP redirects to follow; every hop is re-validated by the SSRF guard.
* `auei_reuse_by_filename` (bool, default `true`) - reuse an existing Media Library image with the same filename (any attachment, not just ones this plugin imported) instead of downloading a duplicate. Matched on the exact stored-file basename.
* `auei_allow_url` (true or WP_Error, default validator result) - final override on a per-URL basis; return `true` to force-allow or a `WP_Error` to force-block.
* `auei_force_logging` (bool, default `false`) - force debug logging even when `WP_DEBUG` is off.

== Changelog ==

= 1.0.1 =
* Reuse existing Media Library images by exact filename - including images uploaded normally, not just ones this plugin imported - so copying a page whose images already exist on the destination no longer creates duplicate uploads. Matched on the exact stored-file basename, single unambiguous match only.

= 1.0.0 =
* Initial release.
* Automatic import of externally-hosted images on post save (Block and Classic editors).
* Imports the full-size original (regenerating every thumbnail and size locally) even when the content references a scaled "-WxH" image, then rewrites the markup to the matching local size so the layout is unchanged.
* Remaps the source-site attachment ID carried in Gutenberg image, cover and gallery blocks (`"id":N` / `wp-image-N`) to the new local attachment ID, so blocks never point at a stale or colliding ID on the destination.
* SSRF-safe URL validation: blocks private, reserved, loopback, link-local, ULA, CGNAT, IPv6 6to4/NAT64 and cloud-metadata addresses; requires all resolved IPs to be public; pins the validated IP for the fetch and re-validates every redirect hop; locks scheme, port and credentials.
* Real content-type verification after download; SVG and ICO excluded by default.
* Download timeout and maximum file-size caps (filterable).
* Attachment reuse by `_auei_source_url` meta and (optionally) by previously-imported filename to avoid re-downloading.
* URL rewriting across `src`, `data-src`, `data-lazy-src`, `srcset`, Gutenberg block JSON and CSS `background-image`.
* `wp auei localise` WP-CLI command with `--dry-run`, `--post_type`, `--post_id` and `--limit` options for retroactive fixes.
* Comprehensive `auei_*` filter set for whitelisting, allowed types, schemes and ports, caps and overrides.

== Upgrade Notice ==

= 1.0.1 =
Skips re-uploading images that already exist in your Media Library (matched by exact filename), avoiding duplicates when migrating content.

= 1.0.0 =
Initial release of External Image Importer.
