Skip to content

fix(proxy): redirect stale content URLs to the latest ELPX extraction - #68

Merged
erseco merged 4 commits into
mainfrom
fix/2150-stale-content-url-redirects
Jul 10, 2026
Merged

fix(proxy): redirect stale content URLs to the latest ELPX extraction#68
erseco merged 4 commits into
mainfrom
fix/2150-stale-content-url-redirects

Conversation

@erseco

@erseco erseco commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Preserve previously published hash-based content URLs after an ELPX attachment is edited.
  • Resolve known obsolete extraction hashes to the attachment's current extraction.
  • Redirect to the equivalent validated file path using a temporary same-origin redirect (302 Found, Cache-Control: no-cache, must-revalidate).
  • Avoid redirect chains by resolving aliases through the current attachment state — every retired hash always points at the single latest hash.
  • Preserve existing proxy validation and security behavior: the fallback runs only after the request already failed with file_not_found.
  • Handle attachment deletion and legacy shared extraction hashes safely.

Fixes exelearning/exelearning#2150

Problem

Saving an edited ELPX generates a new extraction hash and removes the old extraction directory. URLs containing the previous hash then return file_not_found permanently — every shared or embedded link breaks on each edit.

Design

  • Alias storage: each retired hash is stored as multi-value attachment post meta (_exelearning_obsolete_hash) on the attachment that retired it, via the new ExeLearning_Content_Hash_Aliases repository. Post meta resolves the alias through the attachment identity to its single current _exelearning_extracted value (no chains possible), is deleted automatically by WordPress when the attachment is permanently deleted, matches the plugin's "everything in attachment meta" storage model, and adds zero autoloaded state.
  • Why not hash chains or retained directories: old→new hash chains need chain-walking and cycle detection and lose the attachment identity (making shared-hash ambiguity unresolvable); keeping every old extraction grows disk usage without bound. A per-hash option loses automatic deletion cleanup. Full evaluation matrix in SDD-0001 / ADR-0001.
  • Save-transaction ordering (both REST save and reprocess): validate + extract new → replace file → commit metadata → register alias and verify it persisted → only then delete the old directory. Failed saves never reach the alias step; failed alias persistence retains the old directory, so the URL keeps serving the previous content instead of dying.
  • Shared hashes (legacy content-derived extractions): a hash that is still the current hash of any attachment is never aliased, never redirected, and its directory is never deleted by a sharing attachment's save (this also fixes a latent data-loss path where a save could delete a directory another attachment still referenced). A hash aliased to more than one attachment never redirects — the proxy returns the safe 404 rather than silently picking an attachment.
  • Redirect status and cache: 302 Found with Cache-Control: no-cache, must-revalidate — the destination is mutable (the attachment can be edited again), so it must never be cached permanently.
  • Cleanup: alias meta rows die with the attachment (core wp_delete_attachment() behavior); no new cleanup surface, no options-table growth.

SDD

  • docs/architecture/sdd/SDD-0001-stale-content-url-redirects.md (Accepted)
  • docs/architecture/adr/ADR-0001-obsolete-hash-alias-storage.md (Accepted — durable storage/REST-contract decision per repository ADR policy)

Test-driven development

  1. Tests first (commit test(proxy): cover stale content URL redirects): ContentHashAliasesTest (16 tests — registration/resolution/guards/deletion/no-autoload), StaleContentRedirectTest (17 tests — redirect behavior plus negative-path guards), ReprocessorTest retirement/integration additions, RestApiTest failed-save guard.
  2. Captured expected failures before implementation:
    • ContentHashAliasesTest: Tests: 16, Errors: 16Error: Class "ExeLearning_Content_Hash_Aliases" not found.
    • StaleContentRedirectTest: all redirect tests errored with the missing class, while the negative-path guards (unknown/invalid hash, traversal, malformed alias, shared hash) already passed, pinning current behavior.
    • ReprocessorTest: 4 × Call to undefined method ExeLearning_Reprocessor::retire_extraction() plus Failed asserting that an array contains '<hash>' (reprocess registered no alias).
  3. After implementation (focused): ContentHashAliasesTest OK (16 tests, 38 assertions); StaleContentRedirectTest OK (17 tests, 87 assertions); ReprocessorTest OK (27 tests, 79 assertions); RestApiTest failed-save guard OK (1 test, 4 assertions). Full suite: OK (771 tests, 1629 assertions).

Security

  • Redirects are generated only for known obsolete hashes: format-valid, registered as an alias of exactly one existing attachment, not the current hash of any attachment.
  • Redirect destinations are generated by the plugin (ExeLearning_Content_Proxy::get_proxy_url(), the same generator as every proxy URL, including the exelearning_content_origin isolated-origin rewrite) and remain on the configured content origin.
  • The requested path is validated before redirecting: the fallback only ever runs after file_not_found (never after invalid_path/access_denied), and the destination is re-validated with the existing validate_file_path() (traversal-safe sanitization + realpath containment) against the current hash.
  • The target file must exist inside the latest extraction directory — no redirects to dead targets, which also makes chains structurally impossible.
  • Unknown hashes and malformed alias data (bad attachment IDs, non-attachment posts, malformed current hashes, self-references, ambiguous multi-owner aliases) continue to fail safely with the existing errors.
  • Shared current hashes are never reassigned to an arbitrary attachment; preserved query parameters are re-encoded with http_build_query(…, PHP_QUERY_RFC3986) so no raw user input reaches the Location header.
  • Zero added queries on successful content requests; the per-attachment save lock is unchanged and covers the new retire step.

Testing

composer phpcbf: PASS (no remaining fixable violations in the change; drive-by
                 reformatting of unrelated test files was discarded)
composer phpcs (repository ruleset, CI-equivalent):
                 ./vendor/bin/phpcs --standard=.phpcs.xml.dist --no-cache -q . → exit 0 (PASS)
                 Note: the raw `composer phpcs` script (plain --standard=WordPress) reports
                 pre-existing WordPress.Files.FileName errors on ~25 existing class files;
                 CI uses .phpcs.xml.dist (ci.yml:98), which excludes that sniff and passes.
make test:       PASS — OK (771 tests, 1629 assertions)
                 (One environment-only failure of StaticEditorInstallerTest occurs when a
                 local gitignored dist/static build is present; suite is fully green without
                 the local artifact and in CI.)
make phpmd:      PASS (exit 0, no violations)
make check-untranslated: PASS (exit 0 — the change introduces no user-facing strings;
                 regenerated .po/.mo timestamp churn was reverted)
git diff --check: PASS (clean)

Manual verification

Verified end-to-end against wp-env (pretty permalinks, wp-json URLs):

  1. Upload an ELPX attachment (fixture tests/fixtures/test-content.elpx).
  2. Record its current /wp-json/exelearning/v1/content/<hash1>/index.html URL.
  3. Edit and save the ELPX so its extraction hash changes (simulated via ExeLearning_Reprocessor::reprocess(), which shares the retire path with the REST save) → <hash2>, then again → <hash3>.
  4. curl -I <hash1 URL>?exe-teacher=1HTTP/1.1 302 Found, Location: /wp-json/exelearning/v1/content/<hash3>/index.html?exe-teacher=1, Cache-Control: no-cache, must-revalidate.
  5. curl -I <hash2 URL>302 Found<hash3> (both retired hashes redirect directly to the latest hash, no chain).
  6. curl -L <hash1 URL> → final 200 OK on the <hash3> URL.
  7. Current-hash URL serves 200 OK with the usual security headers, unchanged.
  8. An unknown 40-hex hash still returns 404.

Plain permalinks (?rest_route=…) are covered by the unit tests, which run in an environment where rest_url() produces the plain-permalink form; the redirect drops the original rest_route argument and preserves the remaining query parameters.

Changed files

  • includes/class-content-hash-aliases.php (new) — obsolete-hash alias repository: guarded registration with read-back verification, single-owner resolution, current-hash refusals.
  • includes/class-content-proxy.phpfile_not_found-only redirect fallback (maybe_redirect_stale_hash(), add_preserved_query_args()); everything else untouched.
  • includes/class-elp-reprocessor.php — new retire_extraction() (alias-then-delete, fail-open on persistence failure); reprocess() now uses it.
  • includes/class-exelearning-rest-api.php — the locked save transaction retires the old hash via the reprocessor instead of deleting it unconditionally.
  • exelearning.php — require the new class file.
  • docs/architecture/sdd/SDD-0001-stale-content-url-redirects.md, docs/architecture/adr/ADR-0001-obsolete-hash-alias-storage.md, docs/architecture/{sdd,adr}/records.md — design records.
  • tests/unit/ContentHashAliasesTest.php (new), tests/unit/StaleContentRedirectTest.php (new), tests/unit/ReprocessorTest.php, tests/unit/RestApiTest.php — TDD coverage described above.

Compatibility

  • WordPress: Requires at least: 6.1, Tested up to: 7.0 (readme.txt).
  • PHP: Requires PHP: 8.0 (readme.txt); tests run on the wp-env PHPUnit environment.
  • No migration: attachments saved before this change simply have no aliases (their already-dead URLs remain 404, as today). Rollback leaves only inert post meta.

Independent security review

An adversarial security/regression review of the full diff (open redirect, traversal-to-redirect conversion, malformed metadata, alias loops, deleted attachments, failed saves, shared-hash hijack, cache lifetime, header injection, permalink modes, isolated content origin, save-lock coverage) found no blocking findings. Non-blocking notes, all pre-existing behavior or cosmetic, were recorded and intentionally left out of scope: get_proxy_url() does not percent-encode path segments (pre-existing; not exploitable — the file must exist on disk and header() rejects CR/LF), reprocess() has no per-attachment lock (pre-existing; duplicate alias rows on one attachment are cosmetic since resolution counts distinct attachments), and Cache-Control could optionally add private.

Known limitation

When two legacy attachments genuinely shared one content-derived hash and both are later edited, requests for that shared hash cannot be attributed to either attachment without changing the public URL contract; they return the safe 404. Documented in SDD-0001 (“Shared-hash ambiguity”), together with a possible follow-up for the delete_attachment directory cleanup.

erseco added 3 commits July 10, 2026 05:29
Add SDD-0001 and ADR-0001 for redirecting obsolete extraction hashes to
the attachment's latest extraction via attachment post meta aliases.

Refs exelearning/exelearning#2150
Add failing tests for SDD-0001 ahead of the implementation:

- ContentHashAliasesTest: alias registration/resolution repository
  (fails: class not found).
- StaleContentRedirectTest: temporary redirect behavior of the content
  proxy for retired hashes (fails: class not found), plus passing
  guards pinning the current safe 404/validation behavior.
- ReprocessorTest: retirement ordering and reprocess integration
  (fails: undefined retire_extraction(); reprocess registers no alias).
- RestApiTest: failed saves must create no alias and keep the previous
  extraction (passing regression guard).

Refs exelearning/exelearning#2150
Implement SDD-0001: a request for a retired extraction hash now answers
with a temporary (302) same-origin redirect to the equivalent validated
file path under the owning attachment's current extraction, instead of a
permanent dead link.

- New ExeLearning_Content_Hash_Aliases repository persists retired
  hashes as multi-value attachment post meta (_exelearning_obsolete_hash)
  with self-alias, shared-current-hash and cross-attachment ambiguity
  guards, verified persistence, and automatic cleanup on attachment
  deletion.
- ExeLearning_Reprocessor::retire_extraction() registers the alias after
  the metadata commit and deletes the old extraction only when the alias
  is verified as stored; refused or failed registration retains the
  directory. Used by both the REST save and reprocess flows.
- ExeLearning_Content_Proxy falls back to the alias lookup only on
  file_not_found, revalidates the destination path inside the current
  extraction, preserves RFC3986-encoded query parameters (dropping
  rest_route), and sends Cache-Control: no-cache on the redirect.
- Test fixtures now populate route params via set_url_params(),
  mirroring how the REST server routes real requests.

Fixes exelearning/exelearning#2150
@github-actions

Copy link
Copy Markdown
Contributor

Test in WordPress Playground

Test the plugin with the code from this branch:

Preview in WordPress Playground

ℹ️ The eXeLearning editor is fetched from the shared release and unpacked into the plugin when the playground boots, so the first load may take a few extra seconds. ELP upload, shortcode, Gutenberg block and preview work normally.

@erseco erseco self-assigned this Jul 10, 2026
@erseco erseco added the bug Something isn't working label Jul 10, 2026
@erseco
erseco merged commit 7fad3be into main Jul 10, 2026
4 checks passed
@erseco
erseco deleted the fix/2150-stale-content-url-redirects branch July 10, 2026 06:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WordPress: redirect stale exported-content URLs to the latest ELPX version

1 participant