Skip to content

Commit a5e455a

Browse files
authored
QR code handling (boostorg#1896)
1 parent e9f93be commit a5e455a

File tree

3 files changed

+118
-0
lines changed

3 files changed

+118
-0
lines changed

config/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
UserGuideTemplateView,
3434
BoostDevelopmentView,
3535
ModernizedDocsView,
36+
QRCodeView,
3637
)
3738
from libraries.api import LibrarySearchView
3839
from libraries.views import (
@@ -116,6 +117,14 @@
116117
path("feed/news.rss", RSSNewsFeed(), name="news_feed_rss"),
117118
path("feed/news.atom", AtomNewsFeed(), name="news_feed_atom"),
118119
path("LICENSE_1_0.txt", BSLView, name="license"),
120+
path(
121+
"qrc/<str:campaign_identifier>/", QRCodeView.as_view(), name="qr_code_root"
122+
), # just in case
123+
path(
124+
"qrc/<str:campaign_identifier>/<path:main_path>",
125+
QRCodeView.as_view(),
126+
name="qr_code",
127+
),
119128
path(
120129
"accounts/social/signup/",
121130
CustomSocialSignupViewView.as_view(),

core/tests/test_views.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from unittest.mock import patch
23

34
import pytest
@@ -268,3 +269,53 @@ def test_docs_libs_gateway_200_html_transformed(rf, tp, mock_get_file_data):
268269
def test_calendar(rf, tp):
269270
response = tp.get("calendar")
270271
tp.response_200(response)
272+
273+
274+
def test_qrc_redirect_and_plausible_payload(tp):
275+
"""XFF present; querystring preserved; payload/headers correct."""
276+
with patch("core.views.requests.post", return_value=None) as post_mock:
277+
url = "/qrc/pv-01/library/latest/beast/?x=1&y=2"
278+
res = tp.get(url)
279+
280+
tp.response_302(res)
281+
assert res["Location"] == "/library/latest/beast/?x=1&y=2"
282+
283+
# Plausible call
284+
(endpoint,), kwargs = post_mock.call_args
285+
assert endpoint == "https://plausible.io/api/event"
286+
287+
# View uses request.path, so no querystring in payload URL
288+
assert kwargs["json"] == {
289+
"name": "pageview",
290+
"domain": "qrc.boost.org",
291+
"url": "http://testserver/qrc/pv-01/library/latest/beast/",
292+
"referrer": "", # matches view behavior with no forwarded referer
293+
}
294+
295+
headers = kwargs["headers"]
296+
assert headers["Content-Type"] == "application/json"
297+
assert kwargs["timeout"] == 2.0
298+
299+
300+
def test_qrc_falls_back_to_remote_addr_when_no_xff(tp):
301+
"""No XFF provided -> uses REMOTE_ADDR (127.0.0.1 in Django test client)."""
302+
with patch("core.views.requests.post", return_value=None) as post_mock:
303+
res = tp.get("/qrc/camp/library/latest/algorithm/")
304+
305+
tp.response_302(res)
306+
assert res["Location"] == "/library/latest/algorithm/"
307+
308+
(_, kwargs) = post_mock.call_args
309+
headers = kwargs["headers"]
310+
assert headers["X-Forwarded-For"] == "127.0.0.1" # Django test client default
311+
312+
313+
def test_qrc_logs_plausible_error_but_still_redirects(tp, caplog):
314+
"""Plausible post raises -> error logged; redirect not interrupted."""
315+
with patch("core.views.requests.post", side_effect=RuntimeError("boom")):
316+
with caplog.at_level(logging.ERROR, logger="core.views"):
317+
res = tp.get("/qrc/c1/library/", HTTP_USER_AGENT="ua")
318+
319+
tp.response_302(res)
320+
assert res["Location"] == "/library/"
321+
assert any("Plausible event post failed" in r.message for r in caplog.records)

core/views.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import re
33
from urllib.parse import urljoin
44

5+
import requests
56
import structlog
67
from bs4 import BeautifulSoup
78
from dateutil.parser import parse
@@ -13,11 +14,14 @@
1314
HttpResponse,
1415
HttpResponseNotFound,
1516
HttpResponseRedirect,
17+
HttpRequest,
1618
)
1719
from django.shortcuts import redirect
1820
from django.template.loader import render_to_string
1921
from django.urls import reverse
22+
from django.utils.decorators import method_decorator
2023
from django.views import View
24+
from django.views.decorators.cache import never_cache
2125
from django.views.generic import TemplateView
2226
from requests.compat import chardet
2327

@@ -898,3 +902,57 @@ def get(self, request, requested_version):
898902
if requested_version == "release":
899903
new_path = "/libraries/"
900904
return HttpResponseRedirect(new_path)
905+
906+
907+
@method_decorator(never_cache, name="dispatch")
908+
class QRCodeView(View):
909+
"""Handles QR code urls, sending them to Plausible, then redirecting to the desired url.
910+
911+
QR code urls are formatted /qrc/<campaign_identifier>/desired/path/to/content/, and will
912+
result in a redirect to /desired/path/to/content/.
913+
914+
E.g. https://www.boost.org/qrc/pv-01/library/latest/beast/ will send this full url to Plausible,
915+
then redirect to https://www.boost.org/library/latest/beast/
916+
"""
917+
918+
def get(self, request: HttpRequest, campaign_identifier: str, main_path: str = ""):
919+
absolute_url = request.build_absolute_uri(request.path)
920+
referrer = request.META.get("HTTP_REFERER", "")
921+
user_agent = request.META.get("HTTP_USER_AGENT", "")
922+
923+
plausible_payload = {
924+
"name": "pageview",
925+
"domain": "qrc.boost.org",
926+
"url": absolute_url,
927+
"referrer": referrer,
928+
}
929+
930+
headers = {"Content-Type": "application/json", "User-Agent": user_agent}
931+
932+
client_ip = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip()
933+
client_ip = client_ip or request.META.get("REMOTE_ADDR")
934+
935+
if client_ip:
936+
headers["X-Forwarded-For"] = client_ip
937+
938+
try:
939+
requests.post(
940+
"https://plausible.io/api/event",
941+
json=plausible_payload,
942+
headers=headers,
943+
timeout=2.0,
944+
)
945+
except Exception as e:
946+
# Don’t interrupt the redirect - just log it
947+
logger.error(f"Plausible event post failed: {e}")
948+
949+
# Now that we've sent the request url to plausible, we can redirect to the main_path
950+
# Preserve the original querystring, if any.
951+
# Example: /qrc/3/library/latest/algorithm/?x=1 -> /library/latest/algorithm/?x=1
952+
# `main_path` is everything after qrc/<campaign>/ thanks to <path:main_path>.
953+
redirect_path = "/" + main_path if main_path else "/"
954+
qs = request.META.get("QUERY_STRING")
955+
if qs:
956+
redirect_path = f"{redirect_path}?{qs}"
957+
958+
return HttpResponseRedirect(redirect_path)

0 commit comments

Comments
 (0)