diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index e72cc04ff34..420fb0f7853 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -27,7 +27,7 @@ from ietf.doc.models import ConsensusDocEvent from ietf.ietfauth.utils import can_request_rfc_publication as utils_can_request_rfc_publication from ietf.utils import log -from ietf.doc.utils import prettify_std_name +from ietf.doc.utils import external_canonical_url, prettify_std_name from ietf.utils.html import clean_html from ietf.utils.text import wordwrap, fill, wrap_text_if_unwrapped, linkify from ietf.utils.validators import validate_url @@ -140,6 +140,19 @@ def rfceditor_info_url(rfcnum : str): """Link to the RFC editor info page for an RFC""" return urljoin(settings.RFC_EDITOR_INFO_BASE_URL, f'rfc{rfcnum}/') +@register.simple_tag(takes_context=True) +def canonical_url(context, doc): + """Absolute URL to declare canonical for doc on the current page + + Never returns None - an empty or "None" href would be a canonical pointing at a + URL that does not exist. + """ + if not context.get("snapshot"): + external = external_canonical_url(doc) + if external: + return external + return urljoin(settings.IDTRACKER_BASE_URL, context["request"].path) + def doc_name(name): """Check whether a given document exists, and return its canonical name""" diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 6f15003f92f..0b4818ab788 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -47,11 +47,13 @@ BallotDocEventFactory, DocumentAuthorFactory, NewRevisionDocEventFactory, StatusChangeFactory, DocExtResourceFactory, - RgDraftFactory, BcpFactory, RfcAuthorFactory) + RgDraftFactory, BcpFactory, StdFactory, + FyiFactory, RfcAuthorFactory) from ietf.doc.forms import NotifyForm from ietf.doc.fields import SearchableDocumentsField from ietf.doc.utils import ( create_ballot_if_not_open, + external_canonical_url, investigate_fragment, uppercase_std_abbreviated_name, DraftAliasGenerator, @@ -2345,6 +2347,98 @@ def test_template_tags(self): failures, tests = doctest.testmod(ietf_filters) self.assertEqual(failures, 0) +@override_settings(RFC_EDITOR_INFO_BASE_URL="https://www.rfc-editor.example.org/info/") +class CanonicalUrlTests(TestCase): + """Tests of the rel=canonical link declared by document pages""" + + def canonical_href(self, r): + """Extract the canonical href from a response, asserting that it is usable + + An empty href resolves to the current URL and an href of "None" resolves to a + URL that does not exist - neither is visible when eyeballing a rendered page. + """ + self.assertEqual(r.status_code, 200) + links = PyQuery(r.content)("link[rel='canonical']") + self.assertEqual(len(links), 1) + href = links.attr("href") + self.assertNotIn(href, ["", "None", None]) + return href + + def test_external_canonical_url(self): + for doc in [WgRfcFactory(), BcpFactory(), StdFactory(), FyiFactory()]: + self.assertEqual( + external_canonical_url(doc), + f"https://www.rfc-editor.example.org/info/{doc.name}/", + f"{doc.type_id} belongs to the RFC Editor", + ) + for doc in [WgDraftFactory(), CharterFactory(), StatusChangeFactory()]: + self.assertIsNone(external_canonical_url(doc), f"{doc.type_id} is ours") + + def test_rfc_pages_canonicalize_to_rfc_editor(self): + rfc = WgRfcFactory() + rfc.save_with_history([DocEventFactory(doc=rfc)]) + (Path(settings.RFC_PATH) / rfc.get_base_name()).touch() + expected = f"https://www.rfc-editor.example.org/info/{rfc.name}/" + + for viewname in [ + "ietf.doc.views_doc.document_main", + "ietf.doc.views_doc.document_html", + ]: + url = urlreverse(viewname, kwargs=dict(name=rfc.name)) + r = self.client.get(url) + self.assertEqual(self.canonical_href(r), expected, f"{url} canonical") + + def test_draft_pages_canonicalize_to_datatracker(self): + draft = WgDraftFactory() + # an active draft's file is in both of these - see Document.get_file_path() + for dir in [settings.INTERNET_DRAFT_PATH, settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR]: + (Path(dir) / draft.get_base_name()).touch() + + for viewname in [ + "ietf.doc.views_doc.document_main", + "ietf.doc.views_doc.document_html", + ]: + url = urlreverse(viewname, kwargs=dict(name=draft.name)) + r = self.client.get(url) + self.assertEqual( + self.canonical_href(r), + f"{settings.IDTRACKER_BASE_URL}{url}", + f"{url} canonical", + ) + + def test_subseries_pages_canonicalize_to_rfc_editor(self): + for doc in [BcpFactory(), StdFactory(), FyiFactory()]: + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=doc.name) + ) + r = self.client.get(url) + self.assertEqual( + self.canonical_href(r), + f"https://www.rfc-editor.example.org/info/{doc.name}/", + f"{url} canonical", + ) + + +class SubseriesHtmlRedirectTests(TestCase): + """Tests of the /doc/html/ redirects for the bcp/std/fyi subseries + + These patterns interpolate RFC_EDITOR_INFO_BASE_URL when the URLconf is imported, + so override_settings cannot reach them - build the expectation from the setting. + """ + + def test_subseries_html_redirects_to_rfc_editor(self): + for name in ["bcp1", "std2", "fyi3"]: + for suffix in ["", "/", ".txt", ".html"]: + url = f"/doc/html/{name}{suffix}" + r = self.client.get(url) + self.assertEqual(r.status_code, 302, url) + self.assertEqual( + r["Location"], + f"{settings.RFC_EDITOR_INFO_BASE_URL}{name}/", + url, + ) + + class ReferencesTest(TestCase): def test_references(self): diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 0c13503b787..a73c340aac8 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -73,8 +73,9 @@ url(r'^stats/person/(?P[0-9]+)/drafts/data/?$', views_stats.chart_data_person_drafts), # This block should really all be at the idealized docs.ietf.org service - url(r'^html/(?Pbcp[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s", permanent=False)), - url(r'^html/(?Pstd[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s", permanent=False)), + url(r'^html/(?Pbcp[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)), + url(r'^html/(?Pstd[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)), + url(r'^html/(?Pfyi[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)), url(r'^html/%(name)s(?:-(?P[0-9]{2}(-[0-9]{2})?))?(\.txt|\.html)?/?$' % settings.URL_REGEXPS, views_doc.document_html), url(r'^id/%(name)s(?:-%(rev)s)?(?:\.(?P(txt|html|xml)))?/?$' % settings.URL_REGEXPS, views_doc.document_raw_id), diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 5f8f587c59f..c371ba6f2e4 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -15,6 +15,7 @@ from hashlib import sha384 from pathlib import Path from typing import Iterator, Optional, Union, Iterable +from urllib.parse import urljoin from zoneinfo import ZoneInfo from django.conf import settings @@ -807,6 +808,18 @@ def prettify_std_name(n, spacing=" "): else: return n +def external_canonical_url(doc): + """Authoritative external URL for doc, or None if the datatracker is authoritative + + The authoritative home of an RFC, and of a bcp/std/fyi subseries document, is the + RFC Editor's info page, so we point search engines there rather than at our own + rendering of the same thing. Documents of other types are ours. + """ + if doc.type_id in ["rfc", "bcp", "std", "fyi"]: + # trailing slash matches the form the RFC Editor serves + return urljoin(settings.RFC_EDITOR_INFO_BASE_URL, f"{doc.name}/") + return None + def default_consensus(doc): # if someone edits the consensus return that, otherwise # ietf stream => true and irtf stream => false diff --git a/ietf/templates/doc/canonical_link.html b/ietf/templates/doc/canonical_link.html new file mode 100644 index 00000000000..8732aa5ee48 --- /dev/null +++ b/ietf/templates/doc/canonical_link.html @@ -0,0 +1,3 @@ +{# Copyright The IETF Trust 2026, All Rights Reserved #} +{% load ietf_filters %} + diff --git a/ietf/templates/doc/document_subseries.html b/ietf/templates/doc/document_subseries.html index f0273c9093d..8d233ed1fb0 100644 --- a/ietf/templates/doc/document_subseries.html +++ b/ietf/templates/doc/document_subseries.html @@ -4,6 +4,9 @@ {% load static %} {% load ietf_filters %} {% block title %}{{ doc.name|prettystdname }}{% endblock %} +{% block pagehead %} + {% include "doc/canonical_link.html" %} +{% endblock %} {% block content %} {% origin %} {{ top|safe }} diff --git a/ietf/templates/doc/opengraph.html b/ietf/templates/doc/opengraph.html index 1c8c5abe912..ef80fcbcee9 100644 --- a/ietf/templates/doc/opengraph.html +++ b/ietf/templates/doc/opengraph.html @@ -5,7 +5,7 @@ {% origin %} - +{% include "doc/canonical_link.html" %}