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
1 change: 0 additions & 1 deletion makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,6 @@
'django.contrib.messages.context_processors.messages',
'website.context_processors.recent_news',
'website.context_processors.admin_version_info',
'website.context_processors.site_scheme',
],
},
},
Expand Down
14 changes: 0 additions & 14 deletions website/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

from .models import News
from django.conf import settings
from website.utils.metadata import site_scheme as metadata_site_scheme

def recent_news(request):
""" context processors returning recent news """
Expand All @@ -18,19 +17,6 @@ def recent_news(request):

return { 'recent_news': news_items, }

def site_scheme(request):
"""
Expose the canonical URL scheme (``http`` / ``https``) to every template, so
templates can build absolute URLs as
``{{ site_scheme }}://{{ request.get_host }}{{ path }}`` that aren't ``http://``
behind UW CSE's TLS-terminating proxy (issue #1236).

Delegates to :func:`website.utils.metadata.site_scheme` (the single source of
truth, keyed on ``DJANGO_ENV``) so the scheme used by view-built JSON-LD URLs
and template-built canonical/OG URLs can't drift.
"""
return {'site_scheme': metadata_site_scheme(request)}

def admin_version_info(request):
"""
Make version and debug info available to all templates.
Expand Down
13 changes: 7 additions & 6 deletions website/templates/website/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@
by <meta name="description">, og:description, and twitter:description (no
duplication).

Absolute URLs use {{ site_scheme }} (https on the servers, http in local dev)
so they don't advertise http:// behind the TLS proxy — see #1236.
Absolute URLs use request.scheme (https on the servers, http in local dev).
It's correct behind the TLS proxy because SECURE_PROXY_SSL_HEADER trusts the
proxy's X-Forwarded-Proto header — see #1329 (which replaced the #1236 fix).

Detail templates customize via:
{% block social_image %} — og:image + twitter:image (default: lab logo)
Expand All @@ -81,19 +82,19 @@
{% with meta_title=page_meta.title|default:"Makeability Lab" og_type=page_meta.og_type|default:"website" canonical_path=page_meta.canonical_path|default:request.path %}
{% firstof page_meta.description "The Makeability Lab is an advanced research lab in Human-AI directed by Prof. Jon E. Froehlich at UW's Allen School of Computer Science." as meta_description %}
<meta name="description" content="{{ meta_description }}">
<link rel="canonical" href="{{ site_scheme }}://{{ request.get_host }}{{ canonical_path }}">
<link rel="canonical" href="{{ request.scheme }}://{{ request.get_host }}{{ canonical_path }}">

<!-- Open Graph (Facebook, LinkedIn, Slack, Bluesky, …) -->
<meta property="og:site_name" content="Makeability Lab">
<meta property="og:locale" content="en_US">
<meta property="og:title" content="{{ meta_title }}">
<meta property="og:type" content="{{ og_type }}">
<meta property="og:description" content="{{ meta_description }}">
<meta property="og:url" content="{{ site_scheme }}://{{ request.get_host }}{{ canonical_path }}">
<meta property="og:url" content="{{ request.scheme }}://{{ request.get_host }}{{ canonical_path }}">
{% block social_image %}
<meta property="og:image" content="{{ site_scheme }}://{{ request.get_host }}{% static 'website/img/logos/makelab_logo_v3_white_with_colors_and_text_og_image_ratio_1200w.png' %}">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'website/img/logos/makelab_logo_v3_white_with_colors_and_text_og_image_ratio_1200w.png' %}">
<meta property="og:image:alt" content="Makeability Lab logo">
<meta name="twitter:image" content="{{ site_scheme }}://{{ request.get_host }}{% static 'website/img/logos/makelab_logo_v3_white_with_colors_and_text_og_image_ratio_1200w.png' %}">
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'website/img/logos/makelab_logo_v3_white_with_colors_and_text_og_image_ratio_1200w.png' %}">
{% endblock social_image %}
{% block meta_extra %}{% endblock %}

Expand Down
4 changes: 2 additions & 2 deletions website/templates/website/member.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@
{% block social_image %}
{% thumbnail person.image '1200x630' box=person.cropping crop=True upscale=True as og_img %}
{% if og_img %}
<meta property="og:image" content="{{ site_scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ person.get_full_name }}">
<meta name="twitter:image" content="{{ site_scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}{{ og_img.url }}">
{% else %}
{{ block.super }}
{% endif %}
Expand Down
8 changes: 4 additions & 4 deletions website/templates/website/news_item.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@
{% block social_image %}
{% if news_item.image %}{% thumbnail news_item.image '1200x630' box=news_item.cropping crop=True upscale=True as og_img %}{% endif %}
{% if og_img %}
<meta property="og:image" content="{{ site_scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ news_item.title }}">
<meta name="twitter:image" content="{{ site_scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}{{ og_img.url }}">
{% else %}
<meta property="og:image" content="{{ site_scheme }}://{{ request.get_host }}{% static news_item.default_news_image_filename %}">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static news_item.default_news_image_filename %}">
<meta property="og:image:alt" content="{{ news_item.title }}">
<meta name="twitter:image" content="{{ site_scheme }}://{{ request.get_host }}{% static news_item.default_news_image_filename %}">
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}{% static news_item.default_news_image_filename %}">
{% endif %}
{% endblock social_image %}

Expand Down
4 changes: 2 additions & 2 deletions website/templates/website/project.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@
{% block social_image %}
{% thumbnail project.gallery_image 1200x630 box=project.cropping crop=True upscale=True as og_img %}
{% if og_img %}
<meta property="og:image" content="{{ site_scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ project.name }}">
<meta name="twitter:image" content="{{ site_scheme }}://{{ request.get_host }}{{ og_img.url }}">
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}{{ og_img.url }}">
{% else %}
{{ block.super }}
{% endif %}
Expand Down
68 changes: 41 additions & 27 deletions website/tests/test_page_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
it) is caught. They pin three things:

* canonical + Open Graph + Twitter Card tags are present on every page type;
* absolute URLs use ``https`` behind the proxy (the #1236 fix), driven by the
``site_scheme`` context processor — verified by toggling ``DEBUG``;
* absolute URLs follow ``request.scheme`` — https behind the proxy because
``SECURE_PROXY_SSL_HEADER`` trusts the proxy's ``X-Forwarded-Proto`` header
(#1329, which replaced the #1236 ``site_scheme`` workaround);
* detail pages emit distinct, per-page titles/descriptions/types rather than
the single generic site description.

See website/templates/website/base.html, website/context_processors.py, and
website/utils/metadata.py.
See website/templates/website/base.html and website/utils/metadata.py.
"""

import json
Expand Down Expand Up @@ -46,15 +46,17 @@ def _position(person, title=None):
)


# On the servers (DJANGO_ENV TEST/PROD) site_scheme pins https — assert against
# that (the test client's host is "testserver", auto-added to ALLOWED_HOSTS).
# NB: DJANGO_ENV, not DEBUG — the test server runs DEBUG=True behind the proxy
# (see PageMetadataSchemeTests for the regression that motivated this).
@override_settings(DJANGO_ENV='PROD')
# Absolute URLs follow request.scheme. We issue secure (https) requests to mirror
# the deployed servers, where SECURE_PROXY_SSL_HEADER makes request.scheme https
# behind the TLS proxy (see PageMetadataSchemeTests for the header-driven path).
# The test client's host is "testserver", auto-added to ALLOWED_HOSTS.
class PageMetadataHttpsTests(DatabaseTestCase):
# Requests are issued with secure=True so request.scheme == "https", mirroring
# the deployed servers where SECURE_PROXY_SSL_HEADER makes the scheme https
# behind the TLS proxy.

def test_home_has_core_metadata(self):
resp = self.client.get(reverse("website:index"))
resp = self.client.get(reverse("website:index"), secure=True)
self.assertEqual(resp.status_code, 200)
# Canonical present and https.
self.assertContains(resp, '<link rel="canonical" href="https://testserver/">')
Expand All @@ -67,7 +69,7 @@ def test_home_has_core_metadata(self):

def test_no_http_scheme_in_social_urls(self):
"""#1236: og:url / og:image / canonical must never advertise http://."""
resp = self.client.get(reverse("website:index"))
resp = self.client.get(reverse("website:index"), secure=True)
self.assertNotContains(resp, 'property="og:url" content="http://')
self.assertNotContains(resp, 'property="og:image" content="http://')
self.assertNotContains(resp, 'rel="canonical" href="http://')
Expand All @@ -78,7 +80,7 @@ def test_project_detail_metadata(self):
start_date=date(2020, 1, 1),
summary="SoundWatch is a smartwatch system for sound awareness.",
)
resp = self.client.get(reverse("website:project", args=[project.short_name]))
resp = self.client.get(reverse("website:project", args=[project.short_name]), secure=True)
self.assertEqual(resp.status_code, 200)
# Canonical matches the sitemap's reverse()-built URL exactly.
canonical = "https://testserver" + reverse("website:project", args=[project.short_name])
Expand All @@ -94,7 +96,8 @@ def test_member_detail_metadata(self):
bio="Ada researches accessible computing.")
_position(person)
resp = self.client.get(
reverse("website:member_by_name", kwargs={"member_name": person.url_name})
reverse("website:member_by_name", kwargs={"member_name": person.url_name}),
secure=True,
)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, '<meta property="og:type" content="profile">')
Expand All @@ -113,7 +116,8 @@ def test_news_detail_metadata(self):
content="The Makeability Lab won a best paper award at CHI.",
)
resp = self.client.get(
reverse("website:news_item_by_id", kwargs={"id": item.id})
reverse("website:news_item_by_id", kwargs={"id": item.id}),
secure=True,
)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, '<meta property="og:type" content="article">')
Expand Down Expand Up @@ -245,25 +249,35 @@ def test_project_without_summary_or_about_uses_trimmed_default(self):


class PageMetadataSchemeTests(DatabaseTestCase):
"""site_scheme keys off DJANGO_ENV, not DEBUG. The test server runs DEBUG=True
behind the same TLS proxy as prod, so a DEBUG-based check would emit http://
there (regression for the #1236 follow-up fix)."""

@override_settings(DJANGO_ENV='TEST', DEBUG=True)
def test_test_server_uses_https_even_with_debug_true(self):
"""The crux: DJANGO_ENV=TEST + DEBUG=True must still emit https."""
resp = self.client.get(reverse("website:index"))
"""Absolute URLs follow ``request.scheme``. Behind UW CSE's TLS-terminating
proxy that is https because ``SECURE_PROXY_SSL_HEADER`` trusts the proxy's
``X-Forwarded-Proto`` header (#1329, which replaced the DJANGO_ENV-keyed
``site_scheme`` workaround from #1236)."""

@override_settings(SECURE_PROXY_SSL_HEADER=("HTTP_X_FORWARDED_PROTO", "https"))
def test_forwarded_proto_header_drives_https(self):
"""The deployed path: an http request carrying X-Forwarded-Proto: https
is treated as secure, so absolute URLs emit https — exactly what Apache
forwards to the container (#1329). This is the crux that the old
DEBUG-based check got wrong on the test server (#1236)."""
# Send a clean Host header too (as Apache does), so get_host() doesn't
# append the test client's :80 port to the now-https URL.
resp = self.client.get(
reverse("website:index"),
HTTP_X_FORWARDED_PROTO="https",
HTTP_HOST="testserver",
)
self.assertContains(resp, '<link rel="canonical" href="https://testserver/">')
self.assertContains(resp, '<meta property="og:url" content="https://testserver/">')

@override_settings(DJANGO_ENV='PROD', DEBUG=False)
def test_prod_uses_https(self):
resp = self.client.get(reverse("website:index"))
def test_secure_request_uses_https(self):
"""A direct TLS request (request.scheme == https) emits https URLs."""
resp = self.client.get(reverse("website:index"), secure=True)
self.assertContains(resp, '<link rel="canonical" href="https://testserver/">')
self.assertContains(resp, '<meta property="og:url" content="https://testserver/">')

@override_settings(DJANGO_ENV='DEBUG', DEBUG=True)
def test_local_dev_uses_request_scheme(self):
"""Local dev (DJANGO_ENV=DEBUG/unset over http) follows request.scheme."""
"""Local dev over http (no proxy header) follows request.scheme — http."""
resp = self.client.get(reverse("website:index"))
self.assertContains(resp, '<link rel="canonical" href="http://testserver/">')
self.assertContains(resp, '<meta property="og:url" content="http://testserver/">')
28 changes: 7 additions & 21 deletions website/utils/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

import json

from django.conf import settings
from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from django.utils.text import Truncator
Expand Down Expand Up @@ -43,30 +42,17 @@ def meta_description(html, max_chars=META_DESCRIPTION_MAX_CHARS):
return Truncator(text).chars(max_chars)


def site_scheme(request):
"""
The canonical scheme for absolute URLs (https on the test/prod servers,
``request.scheme`` in local dev). Single source of truth — the ``site_scheme``
context processor delegates here, so view-built JSON-LD URLs and template-built
canonical/OG URLs always agree.

We key off ``DJANGO_ENV``, NOT ``DEBUG``: the **test** server runs with
``DEBUG=True`` (config-test.ini) yet sits behind the same TLS-terminating Apache
proxy as prod, so a DEBUG-based check emits ``http://`` on test — the exact #1236
bug. ``DJANGO_ENV`` is ``'TEST'``/``'PROD'`` on the servers (set by
rebuildanddeploy.sh) and ``'DEBUG'``/unset locally, which is the signal we want.
def absolute_url(request, path):
"""Build an absolute, scheme-correct URL from a root-relative path.

NOTE: in-repo workaround. #1329 (IT enabling SECURE_PROXY_SSL_HEADER) would make
request.scheme correct everywhere and let this fall back to it.
Uses ``request.scheme``, which is correct in every environment now that
``SECURE_PROXY_SSL_HEADER`` trusts the proxy's ``X-Forwarded-Proto`` header
behind UW CSE's TLS-terminating Apache (#1329) — https on test/prod, http in
local dev. This replaced the old ``site_scheme`` DJANGO_ENV workaround (#1236).
"""
return 'https' if settings.DJANGO_ENV in ('PROD', 'TEST') else request.scheme


def absolute_url(request, path):
"""Build an absolute, scheme-correct URL from a root-relative path."""
if not path:
return None
return f"{site_scheme(request)}://{request.get_host()}{path}"
return f"{request.scheme}://{request.get_host()}{path}"


def render_jsonld(data):
Expand Down
Loading