Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/user/user-defined-redirects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ Limitations and observations
and |com_brand| users have a number of redirects limited by their plan.
- By default, redirects only apply on pages that don't exist.
**Forced redirects** allow you to apply redirects on existing pages.
- Forced exact redirects are checked before :ref:`built-in redirects <root_url_redirect>`.
This means a forced exact redirect on ``/`` or ``/*``
will take priority over the built-in root URL redirect (``/`` → ``/en/latest/``).
This is especially useful for :ref:`migrating your documentation to another domain <migrating-to-another-domain>`.
- Redirects aren't applied on :doc:`previews of pull requests </pull-requests>`.
You should treat these domains as ephemeral and not rely on them for user-facing content.
- You can redirect to URLs outside Read the Docs,
Expand Down Expand Up @@ -320,6 +324,8 @@ Users will now be redirected:
- From ``https://docs.example.com/dev/install.html``
to ``https://docs.example.com/en/latest/install.html``.

.. _migrating-to-another-domain:

Migrating your documentation to another domain
``````````````````````````````````````````````

Expand All @@ -333,9 +339,15 @@ for example::

Users will now be redirected:

- From ``https://docs.example.com/`` (root URL)
to ``https://newdocs.example.com/``.
- From ``https://docs.example.com/en/latest/install.html``
to ``https://newdocs.example.com/en/latest/install.html``.

Since forced exact redirects are checked before :ref:`built-in redirects <root_url_redirect>`,
this single rule is enough to redirect all traffic,
including the root URL, all versions, translations, and subprojects.

Changing your Sphinx builder from ``html`` to ``dirhtml``
`````````````````````````````````````````````````````````

Expand Down
251 changes: 247 additions & 4 deletions readthedocs/proxito/tests/test_old_redirects.py
Original file line number Diff line number Diff line change
Expand Up @@ -1050,9 +1050,11 @@ def test_exact_redirect_with_wildcard(self):
"""
Test prefix redirect.

Prefix redirects don't match a version,
so they will return 404, and the redirect will
be handled there.
Forced exact redirects with wildcards are now matched
before system redirects, so they return a redirect
instead of a 404.

See: https://github.com/readthedocs/readthedocs.org/issues/10314
"""
fixture.get(
Redirect,
Expand All @@ -1065,7 +1067,11 @@ def test_exact_redirect_with_wildcard(self):
r = self.client.get(
"/woot/install.html", headers={"host": "project.dev.readthedocs.io"}
)
self.assertEqual(r.status_code, 404)
self.assertEqual(r.status_code, 302)
self.assertEqual(
r["Location"],
"http://project.dev.readthedocs.io/en/latest/install.html",
)

def test_infinite_redirect(self):
host = "project.dev.readthedocs.io"
Expand Down Expand Up @@ -1353,6 +1359,243 @@ def test_page_root_redirect_single_version(self):
self.assertEqual(r["Location"], "https://example.com/")


@override_settings(PUBLIC_DOMAIN="dev.readthedocs.io")
class UserForcedRedirectBeforeSystemRedirectTests(BaseDocServing):
"""
Test that forced redirects are checked before system redirects.

This ensures users can redirect entire projects to another domain
without the system redirect (e.g., / -> /en/latest/) taking precedence.

See: https://github.com/readthedocs/readthedocs.org/issues/10314
"""

def test_exact_forced_redirect_from_root_to_external_domain(self):
"""An exact forced redirect from / should take priority over the system redirect to /en/latest/."""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/",
to_url="https://example.com/",
force=True,
)
r = self.client.get("/", headers={"host": "project.dev.readthedocs.io"})
self.assertEqual(r.status_code, 302)
self.assertEqual(r["Location"], "https://example.com/")

def test_exact_forced_redirect_with_wildcard_from_root_to_external_domain(self):
"""An exact forced wildcard redirect from /* should match / before the system redirect."""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/*",
to_url="https://example.com/:splat",
force=True,
)
r = self.client.get("/", headers={"host": "project.dev.readthedocs.io"})
self.assertEqual(r.status_code, 302)
self.assertEqual(r["Location"], "https://example.com/")

def test_exact_forced_redirect_with_wildcard_matches_subpaths(self):
"""An exact forced wildcard redirect from /* should also match subpaths like /en/latest/."""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/*",
to_url="https://example.com/:splat",
force=True,
)
r = self.client.get(
"/en/latest/install.html",
headers={"host": "project.dev.readthedocs.io"},
)
self.assertEqual(r.status_code, 302)
self.assertEqual(
r["Location"], "https://example.com/en/latest/install.html"
)

def test_exact_forced_redirect_from_translation_path(self):
"""
An exact forced redirect from a translation path (e.g. /en/)
should take priority over the system redirect to /en/latest/.
"""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/en",
to_url="https://example.com/en/",
force=True,
)
r = self.client.get("/en/", headers={"host": "project.dev.readthedocs.io"})
self.assertEqual(r.status_code, 302)
self.assertEqual(r["Location"], "https://example.com/en/")

def test_non_forced_redirect_does_not_override_system_redirect(self):
"""A non-forced exact redirect from / should NOT take priority over the system redirect."""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/",
to_url="https://example.com/",
force=False,
)
r = self.client.get("/", headers={"host": "project.dev.readthedocs.io"})
self.assertEqual(r.status_code, 302)
self.assertEqual(
r["Location"],
"http://project.dev.readthedocs.io/en/latest/",
)

def test_exact_forced_redirect_with_query_params(self):
"""An exact forced redirect from / should preserve query parameters."""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/",
to_url="https://example.com/",
force=True,
)
r = self.client.get(
"/?foo=bar", headers={"host": "project.dev.readthedocs.io"}
)
self.assertEqual(r.status_code, 302)
self.assertEqual(r["Location"], "https://example.com/?foo=bar")

def test_exact_forced_redirect_with_301_status(self):
"""An exact forced redirect from / with 301 status should return 301."""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/",
to_url="https://example.com/",
http_status=301,
force=True,
)
r = self.client.get("/", headers={"host": "project.dev.readthedocs.io"})
self.assertEqual(r.status_code, 301)
self.assertEqual(r["Location"], "https://example.com/")

def test_exact_forced_wildcard_redirect_catches_translation_paths(self):
"""
A forced wildcard redirect on the main project should catch
translation paths like /es/latest/ before the system redirect.
"""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/*",
to_url="https://newdocs.example.com/:splat",
force=True,
)
r = self.client.get(
"/es/latest/install.html",
headers={"host": "project.dev.readthedocs.io"},
)
self.assertEqual(r.status_code, 302)
self.assertEqual(
r["Location"],
"https://newdocs.example.com/es/latest/install.html",
)

def test_exact_forced_wildcard_redirect_catches_translation_root(self):
"""
A forced wildcard redirect on the main project should catch
the translation root path /es/ (which normally system-redirects
to /es/latest/) before the system redirect.
"""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/*",
to_url="https://newdocs.example.com/:splat",
force=True,
)
r = self.client.get(
"/es/", headers={"host": "project.dev.readthedocs.io"}
)
self.assertEqual(r.status_code, 302)
self.assertEqual(
r["Location"],
"https://newdocs.example.com/es/",
)

def test_exact_forced_wildcard_redirect_catches_subproject_paths(self):
"""
A forced wildcard redirect on the main project should catch
subproject paths like /projects/subproject/en/latest/.
"""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/*",
to_url="https://newdocs.example.com/:splat",
force=True,
)
r = self.client.get(
"/projects/subproject/en/latest/install.html",
headers={"host": "project.dev.readthedocs.io"},
)
self.assertEqual(r.status_code, 302)
self.assertEqual(
r["Location"],
"https://newdocs.example.com/projects/subproject/en/latest/install.html",
)

def test_exact_forced_wildcard_redirect_catches_subproject_root(self):
"""
A forced wildcard redirect on the main project should catch
the subproject root path /projects/subproject/ (which normally
system-redirects to /projects/subproject/en/latest/).
"""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/*",
to_url="https://newdocs.example.com/:splat",
force=True,
)
r = self.client.get(
"/projects/subproject/",
headers={"host": "project.dev.readthedocs.io"},
)
self.assertEqual(r.status_code, 302)
self.assertEqual(
r["Location"],
"https://newdocs.example.com/projects/subproject/",
)

def test_subproject_redirect_not_affected_by_main_project_non_wildcard(self):
"""
A forced exact redirect on the main project for a specific path
should NOT match subproject paths.
"""
fixture.get(
Redirect,
project=self.project,
redirect_type=EXACT_REDIRECT,
from_url="/en/latest/install.html",
to_url="https://example.com/install.html",
force=True,
)
# The subproject path should not be affected by the main project's redirect.
r = self.client.get(
"/projects/subproject/en/latest/install.html",
headers={"host": "project.dev.readthedocs.io"},
)
self.assertEqual(r.status_code, 200)


@override_settings(
PYTHON_MEDIA=True,
PUBLIC_DOMAIN="dev.readthedocs.io",
Expand Down
39 changes: 39 additions & 0 deletions readthedocs/proxito/views/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,45 @@ def serve_path(self, request, path):
if unresolved_domain.is_from_external_domain:
self.version_type = EXTERNAL

# Check for forced redirects before system redirects.
# This allows users to redirect the entire project (e.g., "/" -> "https://other-domain.com/")
# without the system redirect (e.g., "/" -> "/en/latest/") taking precedence.
# At this point we don't have the resolved language/version/filename,
# so only exact redirects will be matched.
if not unresolved_domain.is_from_external_domain:
try:
redirect_response = self.get_redirect_response(
request=request,
project=unresolved_domain.project,
language=None,
version_slug=None,
filename="",
path=path,
forced_only=True,
)
if redirect_response:
# Set request attributes for the middleware to use
# (cache tags, CDN headers, etc).
# Since we haven't resolved the path yet, we try to resolve
# it now to get the version info for proper cache headers.
request.path_project_slug = unresolved_domain.project.slug
try:
unresolved = unresolver.unresolve_path(
unresolved_domain=unresolved_domain,
path=path,
append_indexhtml=False,
)
request.path_version_slug = unresolved.version.slug
self.cache_response = unresolved.version.is_public
except Exception:
# If the path can't be resolved (e.g., "/" before
# system redirect), we still return the redirect
# but let the middleware set default cache headers.
self.cache_response = True
return redirect_response
except InfiniteRedirectException:
pass

# 404 errors aren't contextualized here because all 404s use the internal nginx redirect,
# where the path will be 'unresolved' again when handling the 404 error
# See: ServeError404Base
Expand Down
Loading