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
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,63 @@ the checker is updated in one pull request:
The dpplint image does not change for this; the manifest is applied like any
other change.

## Consent banner and conversion

OwnYourData advertises the DPP Linter with Google Ads. With the visitor's
consent, the start page measures whether a visit that started with an ad leads
to a check. Without configuration the feature is off: no banner, no script, no
"Cookie settings" link and no request to Google.

| Variable | Value |
|---|---|
| `GOOGLE_ADS_ID` | Conversion ID of the Google Ads account, `AW-` followed by digits. Empty or malformed: off. |
| `GOOGLE_ADS_LABEL_LINTER_CHECK` | Conversion label of a completed check (the part of `send_to` after the slash). Without a valid label the banner still works, but nothing is measured. |

- With `GOOGLE_ADS_ID` set, the start page embeds `/consent.js`
(`app/javascript/consent.js`, adapted from the DPP Service) and shows
"Cookie settings" in the footer. Without it `/consent.js` is an empty script.
- Before the visitor decides, nothing is loaded from Google. The banner offers
"Decline" and "Accept" in the same style; the choice is kept in
`localStorage` (`oyd-consent`) for twelve months, and "Cookie settings"
opens the banner again. Declining after accepting deletes the `_gcl_` and
`_gac_` cookies on the host and its parent domains and reloads the page
without the tag.
- After "Accept", Google Consent Mode v2 is set (everything denied by default,
then `ad_storage` and `ad_user_data` granted; `ad_personalization` and
`analytics_storage` stay denied), ad personalisation signals are switched off
and `gtag.js` is loaded for the ID. The tag is not configured for the ID (no
`gtag("config")`): no page views, no Google Analytics, no remarketing, no
enhanced conversions.
- A completed check (the API answered with a result, whatever the result) sends
one conversion. A check that cannot be run sends none.
- No user data goes to Google. The conversion reports the address of the start
page with its ad click identifier (`gclid`, `gbraid`, `wbraid`) but without
the product identifier, and only the origin of the referrer
(`app/javascript/click_ids.js`); the result is not sent either. The start
page keeps the click identifier in its address when it adds the product
identifier, so that the conversion can be attributed to the ad without a
configured tag and without help from the server.
- The API documentation loads no web fonts from Google
(`app/views/rswag/ui/home/index.html.erb`, the template of rswag-ui without
them).

The privacy policy is that of the DPP Service:
<https://dpp-service.ownyourdata.eu/privacy>.

`test/integration/consent_test.rb` checks the server side.
`test/browser/consent_check.mjs` checks a running instance in a browser
(Node.js and Playwright); requests to Google are blocked, so no conversion
reaches Google:

```
node test/browser/consent_check.mjs https://dpplint.ownyourdata.eu --no-id
node test/browser/consent_check.mjs http://localhost:3000 --screenshots tmp/consent
```

The first form is for an instance without `GOOGLE_ADS_ID`. With
`--inspect-tag` the tag itself is loaded from Google (everything else stays
blocked) and the check also reads the conversion request it would send.

## Tests

```
Expand Down
23 changes: 23 additions & 0 deletions app/controllers/consent_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# GET /consent.js: the consent banner and the Google Ads conversion tag of the
# start page (README "Consent banner and conversion"). The start page embeds it
# only when GoogleAds is enabled; without a conversion ID it is an empty
# script that does nothing.
class ConsentController < ActionController::Base
SCRIPT = Rails.root.join("app/javascript/consent.js").read.freeze

# A script meant to be embedded by the start page; there is no session to
# protect.
skip_forgery_protection

def show
config = GoogleAds.client_config
body = if config
"(function (config) {\n#{SCRIPT}\n})(#{ERB::Util.json_escape(config.to_json)});\n"
else
"/* Conversion measurement is not configured on this instance. */\n"
end
response.set_header("Cache-Control", "public, max-age=300")
response.set_header("X-Content-Type-Options", "nosniff")
render plain: body, content_type: "text/javascript"
end
end
5 changes: 5 additions & 0 deletions app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ class HomeController < ActionController::Base
# Runs the check for the page; included in the page, tested on its own with
# node (test/javascript).
CHECK_CLIENT = Rails.root.join("app/javascript/check_client.js").read.freeze
# Keeps the ad click identifiers in the address of the page (click_ids.js);
# included in the page, tested on its own with node.
CLICK_IDS = Rails.root.join("app/javascript/click_ids.js").read.freeze

def show
@product_id = params[:productId].to_s
@criteria_ref = Rails.configuration.x.dpplint.criteria_ref
@check_timeout = Rails.configuration.x.dpplint.check_timeout.round
@check_client = CHECK_CLIENT
@click_ids = CLICK_IDS
@google_ads = GoogleAds.enabled?
end
end
52 changes: 52 additions & 0 deletions app/javascript/click_ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Ad click identifiers in the address of the start page. A Google Ads
// conversion is attributed to an ad only when it is sent from the page whose
// address carries the click identifier of the ad (the tag is not configured
// and keeps no identifier across pages). The start page keeps these
// identifiers in its address when it adds the product identifier after a
// check, and the conversion reports an address that has the click
// identifiers but never the product identifier. The start page includes this
// file as DppLintClickIds; consent.js uses it; test/javascript loads it with
// require().
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
else root.DppLintClickIds = api;
})(typeof self !== "undefined" ? self : this, function () {
const CLICK_IDS = ["gclid", "gbraid", "wbraid"];

// The click identifiers of a query string, in the order of CLICK_IDS.
function clickIds(search) {
const params = new URLSearchParams(search || "");
return CLICK_IDS.filter((name) => params.get(name)).map((name) => [name, params.get(name)]);
}

const encode = (pairs) => pairs.map(([name, value]) => name + "=" + encodeURIComponent(value)).join("&");

// The query for the address bar after a check is started: the product
// identifier, followed by the click identifiers the address already has.
function queryWithProductId(search, productId) {
return "?" + encode([["productId", productId], ...clickIds(search)]);
}

// The address reported with a conversion: origin and path of the page and
// its click identifiers. Nothing else of the query (the product identifier)
// and no fragment.
function conversionLocation(href) {
const url = new URL(href);
const query = encode(clickIds(url.search));
return url.origin + url.pathname + (query ? "?" + query : "");
}

// The referrer reported with a conversion: only its origin, so that an
// address with a product identifier never leaves the page.
function conversionReferrer(referrer) {
if (!referrer) return "";
try {
return new URL(referrer).origin + "/";
} catch (e) {
return "";
}
}

return { CLICK_IDS, clickIds, queryWithProductId, conversionLocation, conversionReferrer };
});
207 changes: 207 additions & 0 deletions app/javascript/consent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Consent banner and Google Ads conversion measurement of the start page
// (README "Consent banner and conversion").
//
// Adapted from app/views/consent/consent.js of OwnYourData/dpp-service-public
// at commit 4f49703 (Apache License 2.0, like this repository). Changes: one
// conversion, sent by the start page when a check is completed, instead of
// conversions for links; the conversion reports the address of the page with
// its ad click identifiers but without the product identifier (click_ids.js),
// and only the origin of the referrer; banner text for the DPP Linter; the
// privacy policy is that of the DPP Service; colours from the page.
//
// ConsentController wraps this file in a function that receives `config`
// ({ id: "AW-…", conversions: { linter_check: "AW-…/label" } }). It is served
// only when a conversion ID is configured.
//
// Google Consent Mode v2, "basic": nothing is requested from Google before
// the visitor accepts. After acceptance only the Google Ads tag is loaded. It
// is not configured for the conversion ID (no config command), so it reports
// no page views and keeps no click identifier across pages; it sends
// something only when a conversion takes place, without ad personalisation.
// No analytics, no remarketing, no user data: neither the product identifier
// nor the result of a check is passed to Google. The choice is kept in
// localStorage for at most twelve months and can be changed at any time
// through an element with the attribute data-consent-settings (the
// "Cookie settings" link in the footer).
"use strict";

var STORAGE_KEY = "oyd-consent";
var STORAGE_VERSION = 1;
var MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000;
var GOOGLE_COOKIE = /^(_gcl_|_gac_)/;
var TAG_URL = "https://www.googletagmanager.com/gtag/js?id=";
var PRIVACY_URL = "https://dpp-service.ownyourdata.eu/privacy#conversion-tracking";

var tagLoaded = false;
var banner = null;

function readChoice() {
try {
var raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
var stored = JSON.parse(raw);
if (!stored || stored.v !== STORAGE_VERSION || typeof stored.ads !== "boolean") return null;
var at = Date.parse(stored.at);
if (!(at > 0) || Date.now() - at > MAX_AGE_MS) return null;
return stored.ads;
} catch (e) {
return null;
}
}

function storeChoice(ads) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: STORAGE_VERSION, ads: ads, at: new Date().toISOString() }));
} catch (e) {
// Without storage the banner simply comes back on the next visit.
}
}

function gtag() {
window.dataLayer.push(arguments);
}

function loadTag() {
if (tagLoaded) return;
tagLoaded = true;
window.dataLayer = window.dataLayer || [];
window.gtag = gtag;
gtag("consent", "default", {
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
analytics_storage: "denied"
});
gtag("consent", "update", { ad_storage: "granted", ad_user_data: "granted" });
gtag("set", { allow_ad_personalization_signals: false });
gtag("js", new Date());
var script = document.createElement("script");
script.async = true;
script.src = TAG_URL + encodeURIComponent(config.id);
document.head.appendChild(script);
}

// Sends the named conversion, only after consent and with a configured label.
// The address reported is that of the page with its click identifiers, never
// with the product identifier; the referrer only with its origin.
function convert(name) {
var target = config.conversions[name];
if (!tagLoaded || !target) return false;
var ids = window.DppLintClickIds;
if (!ids) return false;
gtag("event", "conversion", {
send_to: target,
page_location: ids.conversionLocation(window.location.href),
page_referrer: ids.conversionReferrer(document.referrer),
transport_type: "beacon"
});
return true;
}

function deleteGoogleCookies() {
var labels = window.location.hostname.split(".");
document.cookie.split(";").forEach(function (pair) {
var name = pair.split("=")[0].trim();
if (!GOOGLE_COOKIE.test(name)) return;
document.cookie = name + "=; Max-Age=0; path=/";
for (var i = 0; i < labels.length - 1; i++) {
document.cookie = name + "=; Max-Age=0; path=/; domain=." + labels.slice(i).join(".");
}
});
}

function choose(ads) {
storeChoice(ads);
hideBanner();
if (ads) {
loadTag();
return;
}
deleteGoogleCookies();
if (tagLoaded) {
gtag("consent", "update", { ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied" });
window.location.reload();
}
}

var STYLE = [
".oyd-consent{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;padding:0 12px 12px;pointer-events:none}",
".oyd-consent[hidden]{display:none}",
".oyd-consent .oc-box{pointer-events:auto;max-width:920px;margin:0 auto;background:var(--bg,#fff);color:var(--fg,#1d2330);",
"border:1px solid var(--line,#d9dde5);border-radius:12px;box-shadow:0 6px 24px rgba(0,0,0,.18);padding:18px 20px;",
"font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,sans-serif}",
".oyd-consent h2{font-size:16px;margin:0 0 6px}",
".oyd-consent p{margin:0 0 12px;color:var(--muted,#5b6475)}",
".oyd-consent a{color:var(--accent,#2457a6)}",
".oyd-consent .oc-actions{display:flex;gap:12px;flex-wrap:wrap}",
".oyd-consent button{flex:1 1 160px;max-width:240px;padding:10px 18px;border-radius:8px;font-family:inherit;font-size:15px;font-weight:600;line-height:1.2;cursor:pointer;",
"border:1px solid var(--accent,#2457a6);background:var(--accent,#2457a6);color:var(--on-accent,#fff)}",
".oyd-consent button:focus-visible{outline:2px solid var(--fg,#1d2330);outline-offset:2px}",
".oyd-consent .oc-current{font-size:13px;margin:-4px 0 12px}"
].join("");

function hideBanner() {
if (banner) banner.hidden = true;
}

function showBanner(focus) {
if (!banner) {
var style = document.createElement("style");
style.textContent = STYLE;
document.head.appendChild(style);
banner = document.createElement("section");
banner.className = "oyd-consent";
banner.setAttribute("aria-labelledby", "oyd-consent-title");
banner.innerHTML =
'<div class="oc-box">' +
'<h2 id="oyd-consent-title">Measuring our ads</h2>' +
"<p>OwnYourData advertises these free tools with Google Ads. With your consent, Google’s conversion tag tells us " +
"when a visit from one of our ads leads to a check in the DPP Linter. The product identifier you enter and the " +
"result of the check are not sent to Google. Google sets cookies and receives your IP address and browser " +
"details, possibly in the USA. No analytics, no remarketing. Without your consent nothing is loaded from Google. " +
"You can change your choice at any time under “Cookie settings” at the bottom of the page. " +
'<a href="' + PRIVACY_URL + '">Privacy policy</a></p>' +
'<p class="oc-current" hidden></p>' +
'<div class="oc-actions">' +
'<button type="button" data-choice="decline">Decline</button>' +
'<button type="button" data-choice="accept">Accept</button>' +
"</div></div>";
banner.addEventListener("click", function (event) {
var button = event.target.closest ? event.target.closest("button[data-choice]") : null;
if (button) choose(button.getAttribute("data-choice") === "accept");
});
document.body.appendChild(banner);
}
var current = banner.querySelector(".oc-current");
var choice = readChoice();
current.hidden = choice === null;
current.textContent = choice === true ? "Your current choice: accepted." : choice === false ? "Your current choice: declined." : "";
banner.hidden = false;
if (focus) banner.querySelector("button").focus();
}

function init() {
var settings = document.querySelectorAll("[data-consent-settings]");
for (var i = 0; i < settings.length; i++) {
settings[i].hidden = false;
settings[i].addEventListener("click", function (event) {
event.preventDefault();
showBanner(true);
});
}

var choice = readChoice();
if (choice === true) {
loadTag();
} else if (choice === null) {
showBanner(false);
}
}

window.oydConsent = { open: function () { showBanner(true); }, convert: convert };

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
Loading
Loading