Skip to content

Commit 8f0c78d

Browse files
authored
Merge pull request #5130 from rtfd/humitos/allow-custom-404-pages
Allow custom 404.html on projects
2 parents 05b7c3f + a8f548a commit 8f0c78d

File tree

6 files changed

+154
-4
lines changed

6 files changed

+154
-4
lines changed

docs/conf.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@
8181
# Activate autosectionlabel plugin
8282
autosectionlabel_prefix_document = True
8383

84+
# sphinx-notfound-page
85+
# https://github.com/rtfd/sphinx-notfound-page
86+
notfound_context = {
87+
'body': '''
88+
<h1>Page not found</h1>
89+
90+
<p>Sorry, we couldn't find that page.</p>
91+
92+
<p>Try using the search box or go to the homepage.</p>
93+
''',
94+
}
95+
8496

8597
def setup(app):
8698
app.add_stylesheet('css/sphinx_prompt_css.css')

readthedocs/core/views/__init__.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,23 @@
1010
from __future__ import division
1111
import os
1212
import logging
13+
from urllib.parse import urlparse
1314

1415
from django.conf import settings
1516
from django.http import HttpResponseRedirect, Http404, JsonResponse
1617
from django.shortcuts import render, get_object_or_404, redirect
1718
from django.views.generic import TemplateView
1819

20+
1921
from readthedocs.builds.models import Version
22+
from readthedocs.core.resolver import resolve_path
23+
from readthedocs.core.symlink import PrivateSymlink, PublicSymlink
2024
from readthedocs.core.utils import broadcast
25+
from readthedocs.core.views.serve import _serve_file
26+
from readthedocs.projects.constants import PRIVATE
2127
from readthedocs.projects.models import Project, ImportedFile
2228
from readthedocs.projects.tasks import remove_dirs
23-
from readthedocs.redirects.utils import get_redirect_response
29+
from readthedocs.redirects.utils import get_redirect_response, project_and_path_from_request, language_and_version_from_path
2430

2531
log = logging.getLogger(__name__)
2632

@@ -115,6 +121,7 @@ def server_error_404(request, exception=None, template_name='404.html'): # pyli
115121
"""
116122
response = get_redirect_response(request, full_path=request.get_full_path())
117123

124+
# Return a redirect response if there is one
118125
if response:
119126
if response.url == request.build_absolute_uri():
120127
# check that we do have a response and avoid infinite redirect
@@ -124,6 +131,88 @@ def server_error_404(request, exception=None, template_name='404.html'): # pyli
124131
)
125132
else:
126133
return response
134+
135+
# Try to serve custom 404 pages if it's a subdomain/cname
136+
if getattr(request, 'subdomain', False) or getattr(request, 'cname', False):
137+
return server_error_404_subdomain(request, template_name)
138+
139+
# Return the default 404 page generated by Read the Docs
140+
r = render(request, template_name)
141+
r.status_code = 404
142+
return r
143+
144+
145+
def server_error_404_subdomain(request, template_name='404.html'):
146+
"""
147+
Handler for 404 pages on subdomains.
148+
149+
Check if the project associated has a custom ``404.html`` and serve this
150+
page. First search for a 404 page in the current version, then continues
151+
with the default version and finally, if none of them are found, the Read
152+
the Docs default page (Maze Found) is rendered by Django and served.
153+
"""
154+
155+
def resolve_404_path(project, version_slug=None, language=None):
156+
"""
157+
Helper to resolve the path of ``404.html`` for project.
158+
159+
The resolution is based on ``project`` object, version slug and
160+
language.
161+
162+
:returns: tuple containing the (basepath, filename)
163+
:rtype: tuple
164+
"""
165+
filename = resolve_path(
166+
project,
167+
version_slug=version_slug,
168+
language=language,
169+
filename='404.html',
170+
subdomain=True, # subdomain will make it a "full" path without a URL prefix
171+
)
172+
173+
# This breaks path joining, by ignoring the root when given an "absolute" path
174+
if filename[0] == '/':
175+
filename = filename[1:]
176+
177+
version = None
178+
if version_slug:
179+
version = project.versions.get(slug=version_slug)
180+
181+
private = any([
182+
version and version.privacy_level == PRIVATE,
183+
not version and project.privacy_level == PRIVATE,
184+
])
185+
if private:
186+
symlink = PrivateSymlink(project)
187+
else:
188+
symlink = PublicSymlink(project)
189+
basepath = symlink.project_root
190+
fullpath = os.path.join(basepath, filename)
191+
return (basepath, filename, fullpath)
192+
193+
project, full_path = project_and_path_from_request(request, request.get_full_path())
194+
195+
language = None
196+
version_slug = None
197+
schema, netloc, path, params, query, fragments = urlparse(full_path)
198+
if not project.single_version:
199+
language, version_slug, path = language_and_version_from_path(path)
200+
201+
# Firstly, attempt to serve the 404 of the current version (version_slug)
202+
# Secondly, try to serve the 404 page for the default version (project.get_default_version())
203+
for slug in (version_slug, project.get_default_version()):
204+
basepath, filename, fullpath = resolve_404_path(project, slug, language)
205+
if os.path.exists(fullpath):
206+
log.debug(
207+
'serving 404.html page current version: [project: %s] [version: %s]',
208+
project.slug,
209+
slug,
210+
)
211+
r = _serve_file(request, filename, basepath)
212+
r.status_code = 404
213+
return r
214+
215+
# Finally, return the default 404 page generated by Read the Docs
127216
r = render(request, template_name)
128217
r.status_code = 404
129218
return r

readthedocs/core/views/serve.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,23 @@ def _serve_401(request, project):
139139

140140

141141
def _serve_file(request, filename, basepath):
142+
"""
143+
Serve media file via Django or NGINX based on ``PYTHON_MEDIA``.
144+
145+
When using ``PYTHON_MEDIA=True`` (or when ``DEBUG=True``) the file is served
146+
by ``django.views.static.serve`` function.
147+
148+
On the other hand, when ``PYTHON_MEDIA=False`` the file is served by using
149+
``X-Accel-Redirect`` header for NGINX to take care of it and serve the file.
150+
151+
:param request: Django HTTP request
152+
:param filename: path to the filename to be served relative to ``basepath``
153+
:param basepath: base path to prepend to the filename
154+
155+
:returns: Django HTTP response object
156+
157+
:raises: ``Http404`` on ``UnicodeEncodeError``
158+
"""
142159
# Serve the file from the proper location
143160
if settings.DEBUG or getattr(settings, 'PYTHON_MEDIA', False):
144161
# Serve from Python

readthedocs/doc_builder/python_environments.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ def install_core_requirements(self):
443443
cmd.extend(requirements)
444444
self.build_env.run(
445445
*cmd,
446-
cwd=self.checkout_path # noqa - no comma here in py27 :/
446+
cwd=self.checkout_path,
447447
)
448448

449449
pip_cmd = [

readthedocs/rtd_tests/tests/test_doc_serving.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
import django_dynamic_fixture as fixture
44
import mock
5+
import os
56
from django.conf import settings
67
from django.contrib.auth.models import User
78
from django.http import Http404
8-
from django.test import TestCase
9+
from django.test import TestCase, RequestFactory
910
from django.test.utils import override_settings
1011
from django.urls import reverse
1112
from mock import mock_open, patch
1213

14+
from readthedocs.core.middleware import SubdomainMiddleware
15+
from readthedocs.core.views import server_error_404_subdomain
1316
from readthedocs.core.views.serve import _serve_symlink_docs
1417
from readthedocs.projects import constants
1518
from readthedocs.projects.models import Project
@@ -169,3 +172,31 @@ def test_custom_robots_txt(self, os_mock, open_mock):
169172
)
170173
self.assertEqual(response.status_code, 200)
171174
self.assertEqual(response.content, b'My own robots.txt')
175+
176+
@override_settings(
177+
PYTHON_MEDIA=False,
178+
USE_SUBDOMAIN=True,
179+
PUBLIC_DOMAIN='readthedocs.io',
180+
ROOT_URLCONF=settings.SUBDOMAIN_URLCONF,
181+
)
182+
@patch('readthedocs.core.views.serve.os')
183+
@patch('readthedocs.core.views.os')
184+
def test_custom_404_page(self, os_view_mock, os_serve_mock):
185+
os_view_mock.path.exists.return_value = True
186+
187+
os_serve_mock.path.join.side_effect = os.path.join
188+
os_serve_mock.path.exists.return_value = True
189+
190+
self.public.versions.update(active=True, built=True)
191+
192+
factory = RequestFactory()
193+
request = factory.get(
194+
'/en/latest/notfoundpage.html',
195+
HTTP_HOST='public.readthedocs.io',
196+
)
197+
198+
middleware = SubdomainMiddleware()
199+
middleware.process_request(request)
200+
response = server_error_404_subdomain(request)
201+
self.assertEqual(response.status_code, 404)
202+
self.assertTrue(response['X-Accel-Redirect'].endswith('/public/en/latest/404.html'))

requirements/local-docs-build.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
-r pip.txt
22

3-
# Base packages
3+
# Base packages
44
docutils==0.14
55
Sphinx==1.8.3
66
sphinx_rtd_theme==0.4.2
@@ -17,5 +17,6 @@ Markdown==3.0.1
1717
# Docs
1818
sphinxcontrib-httpdomain==1.7.0
1919
sphinx-prompt==1.0.0
20+
sphinx-notfound-page==0.1
2021
commonmark==0.8.1
2122
recommonmark==0.5.0

0 commit comments

Comments
 (0)