Skip to content
Merged
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
15 changes: 14 additions & 1 deletion ietf/doc/templatetags/ietf_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand Down
96 changes: 95 additions & 1 deletion ietf/doc/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions ietf/doc/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@
url(r'^stats/person/(?P<id>[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/(?P<name>bcp[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s", permanent=False)),
url(r'^html/(?P<name>std[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s", permanent=False)),
url(r'^html/(?P<name>bcp[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)),
url(r'^html/(?P<name>std[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)),
url(r'^html/(?P<name>fyi[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)),
url(r'^html/%(name)s(?:-(?P<rev>[0-9]{2}(-[0-9]{2})?))?(\.txt|\.html)?/?$' % settings.URL_REGEXPS, views_doc.document_html),

url(r'^id/%(name)s(?:-%(rev)s)?(?:\.(?P<ext>(txt|html|xml)))?/?$' % settings.URL_REGEXPS, views_doc.document_raw_id),
Expand Down
13 changes: 13 additions & 0 deletions ietf/doc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions ietf/templates/doc/canonical_link.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{# Copyright The IETF Trust 2026, All Rights Reserved #}
{% load ietf_filters %}
<link rel="canonical" href="{% canonical_url doc %}">
3 changes: 3 additions & 0 deletions ietf/templates/doc/document_subseries.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion ietf/templates/doc/opengraph.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{% origin %}
<meta property="og:title" content="{% if doc.type_id == 'rfc' and not snapshot %}RFC {{ rfc_number }}: {% endif %}{{ doc.title }}">
<meta property="og:url" content="{{ settings.IDTRACKER_BASE_URL }}{{ request.path }}">
<link rel="canonical" href="{{ settings.IDTRACKER_BASE_URL }}{{ request.path }}">
{% include "doc/canonical_link.html" %}
<meta property="og:site_name" content="IETF Datatracker">
<meta property="og:description" content="{{ doc.abstract|clean_whitespace }}">
<meta property="og:type" content="article">
Expand Down
Loading