Skip to content

feat!: v2 overhaul, fix the module on stock installs and add stream mode - #4

Merged
SamJUK merged 6 commits into
masterfrom
feat/v2-overhaul
Sep 19, 2026
Merged

SamJUK merged 6 commits into
masterfrom
feat/v2-overhaul

Conversation

@SamJUK

@SamJUK SamJUK commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Breaking. Intended as v2.0.0.

Why

The module could not run on a stock install. RequestedMedia asked the object manager for Magento\Framework\Filesystem\DriverInterface, which Magento never binds (it passes it explicitly per type), so every media miss raised an uncaught Error. It only got past that on installs where a third party declared the preference globally, amasty/base being the common one. There, cache mode created a directory where the image should have been, on every miss including ones where the upstream was unreachable, and pub/get.php 404s on a directory forever after.

Both reproduced against the v1.0.1 tag in a clean 2.4.8 container.

Fixes

  • Concrete filesystem driver, so the module builds on a stock install.
  • Writes go to a temporary file renamed into place. No more directories, and concurrent requests cannot serve a partial image.
  • The requested path comes from the HTTP request rather than $GLOBALS['relativePath'], and is normalised and bounds checked against the media directory.
  • Config is read at store scope, so the website scope the admin form offers is no longer silently ignored.
  • Failures are logged and fall through to Magento, so a miss serves the usual placeholder rather than an error.
  • Adds the missing etc/acl.xml, the composer requires and the module sequence.

New

  • stream mode: fetches through PHP and keeps nothing. This is the answer for an upstream behind HTTP basic auth, since proxy mode redirects the browser, which has no credentials.
  • Upstream placeholder detection. A Magento upstream answers a request for media it does not hold with its own placeholder and a 200, which is indistinguishable from a real image. The module learns that signature once a day from a probe path that cannot exist, and refuses anything matching it.
  • Negative cache, five minutes, so a page of missing images does not re-ask the upstream on every render.
  • Upstream basic auth with an encrypted password, a configurable timeout, and a 32MB download cap.

Security

  • Credentials moved off a literal Authorization header onto CURLOPT_USERPWD. curl replays custom headers across a cross host redirect; it scopes CURLOPT_USERPWD to the first host.
  • Credentials are stripped from anything written to the log.
  • Loop guard: every upstream request carries X-SamJUK-Media-Proxy and is refused on the way back in, so an upstream that resolves to this installation can no longer recurse through PHP-FPM until no workers are left.

Breaking changes

ConfigInterface and RequestedMediaInterface both gained methods, and the constructors of Model\Config, Model\RequestedMedia and Plugin\Media all changed. Model\Config\Source\Mode now implements Magento\Framework\Data\OptionSourceInterface rather than extending the EAV AbstractSource, dropping an undeclared Magento_Eav dependency and losing getAllOptions().

Installs that ran 1.x cache mode have empty directories shadowing images. The changelog has the cleanup one-liner.

Verified

  • 89 unit tests, 100% line coverage (178/178). Integration suite green on 2.4.8.
  • Full matrix locally, composer + phpstan level 6 + phpcs + phpunit + di:compile green on 7.4/2.4.3, 8.1/2.4.4, 8.1/2.4.5, 8.2/2.4.6, 8.3/2.4.7, 8.3/2.4.8 and 8.4/2.4.8.
  • Live on a real 2.4.8 store against a real upstream: all three modes, placeholder detection, negative cache, unreachable and misconfigured upstreams.
  • 10 concurrent syncs of the same 2MB image produced one complete file with a matching md5 and no leftovers.
  • v1.0.1 to this upgrade rehearsed end to end, including the documented cleanup.

composer.json required nothing but PHP, so nothing guaranteed the framework and
modules the code actually uses. etc/module.xml declared no sequence.

etc/acl.xml was missing entirely, which meant the SamJUK_MediaProxy::config
resource referenced by the admin form could not be granted to a role.
The module could not run on a stock install. RequestedMedia asked the object
manager for Magento\Framework\Filesystem\DriverInterface, which Magento never
binds, so every media miss raised an uncaught Error. It only got past that on
installs where a third party happened to declare the preference globally, and
there cache mode created a directory where the image should have been, on every
miss, leaving pub/get.php to 404 on it forever after.

Media resolution now comes from the HTTP request rather than
$GLOBALS['relativePath'], is normalised and bounds checked against the media
directory, and writes go to a temporary file that is renamed into place so
concurrent requests cannot serve a partial image. Failures are logged and fall
through to Magento, so a miss serves the usual placeholder instead of an error.

Upstream responses are validated before anything is written. A Magento upstream
answers a request for media it does not hold with its own placeholder and a 200,
so the module learns that signature once a day from a probe path that cannot
exist and refuses anything matching it. Failed lookups are remembered for five
minutes, which stops a page of missing images re-asking the upstream on every
render. Downloads are capped at 32MB, because buffering more exhausts the memory
limit and that fatal cannot be turned into a graceful failure.

Adds a third mode, stream, which fetches through PHP and keeps nothing. It is
the answer for an upstream behind HTTP basic auth, since proxy mode redirects
the browser, which has no credentials. Credentials go through CURLOPT_USERPWD
rather than a literal Authorization header, which curl replays across a cross
host redirect, and are stripped from anything written to the log.

Every upstream request carries X-SamJUK-Media-Proxy and is refused on the way
back in, so an upstream that resolves to this installation can no longer recurse
through PHP-FPM until no workers are left.

Config is read at store scope, so the website scope the admin form offers is no
longer silently ignored, and gains a timeout and upstream credentials.

BREAKING CHANGE: ConfigInterface gains isStreamMode(), getTimeout(),
getUpstreamUsername() and getUpstreamPassword(). RequestedMediaInterface gains
getUpstreamRedirectUrl() and download(). The constructors of Model\Config,
Model\RequestedMedia and Plugin\Media have all changed.
Model\Config\Source\Mode now implements
Magento\Framework\Data\OptionSourceInterface instead of extending
Magento\Eav\Model\Entity\Attribute\Source\AbstractSource, dropping an undeclared
Magento_Eav dependency, and no longer has getAllOptions().
_local_test.sh could not run at all. It read a CI matrix key removed in ad13806,
built image tags as 8.3-magento2.4.8 when the publisher had moved to
php83-fpm-magento2.4.8, and the image no longer ships make. Versions can now be
overridden with VERSIONS="8.3:2.4.8" to test a single combination.

The Makefile's default RELATIVE_PROJECT_DIR was one level short for the
app/code/<vendor>/<module> layout it documents, and test-integration ran the
whole Magento suite rather than this module's.
SECURITY.md had no statement of scope. The module fetches from an admin
configured host and follows redirects, which is worth saying out loud.
@SamJUK SamJUK closed this Sep 19, 2026
@SamJUK SamJUK reopened this Sep 19, 2026
Magento 2.4.9 and Mage-OS 3.5.0 ship PHPUnit 12, which dropped support for the
@dataProvider annotation. The provider was not applied, so the test ran with no
arguments and raised ArgumentCountError, taking the suite from 89 tests to 86
with one error.

Both forms are declared because the supported range spans PHPUnit 9 through 12.
The annotation is what 9 reads, 2.4.3 to 2.4.6 pin it and it has no attribute
support. The attribute is what 12 reads, and on the PHP 7.4 that 2.4.3 runs on
it is parsed as a comment.

Verified 89 tests and exit 0 on PHPUnit 9.6, 10 and 12, and that PHP 7.4 still
parses the file.

require-dev widened to match, since the module is exercised against all four
majors rather than only the 10.5 it claimed.
@SamJUK
SamJUK merged commit 94d7aac into master Sep 19, 2026
17 checks passed
@SamJUK
SamJUK deleted the feat/v2-overhaul branch September 19, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant