1/*
2 * Copyright (C) 2019 Apple Inc. All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions
6 * are met:
7 * 1. Redistributions of source code must retain the above copyright
8 * notice, this list of conditions and the following disclaimer.
9 * 2. Redistributions in binary form must reproduce the above copyright
10 * notice, this list of conditions and the following disclaimer in the
11 * documentation and/or other materials provided with the distribution.
12 *
13 * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17 * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23 * THE POSSIBILITY OF SUCH DAMAGE.
24 */
25
26#include "config.h"
27#include "AdClickAttributionManager.h"
28
29#include "Logging.h"
30#include <WebCore/FetchOptions.h>
31#include <WebCore/FormData.h>
32#include <WebCore/ResourceError.h>
33#include <WebCore/ResourceRequest.h>
34#include <WebCore/ResourceResponse.h>
35#include <WebCore/RuntimeApplicationChecks.h>
36#include <WebCore/RuntimeEnabledFeatures.h>
37#include <wtf/text/StringBuilder.h>
38#include <wtf/text/StringHash.h>
39
40namespace WebKit {
41using namespace WebCore;
42
43using Source = AdClickAttribution::Source;
44using Destination = AdClickAttribution::Destination;
45using DestinationMap = HashMap<Destination, AdClickAttribution>;
46using Conversion = AdClickAttribution::Conversion;
47
48constexpr Seconds debugModeSecondsUntilSend { 60_s };
49
50void AdClickAttributionManager::storeUnconverted(AdClickAttribution&& attribution)
51{
52 clearExpired();
53
54 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Storing an ad click.");
55 m_unconvertedAdClickAttributionMap.set(std::make_pair(attribution.source(), attribution.destination()), WTFMove(attribution));
56}
57
58void AdClickAttributionManager::handleConversion(Conversion&& conversion, const URL& requestURL, const WebCore::ResourceRequest& redirectRequest)
59{
60 if (m_sessionID.isEphemeral())
61 return;
62
63 RegistrableDomain redirectDomain { redirectRequest.url() };
64 auto& firstPartyURL = redirectRequest.firstPartyForCookies();
65
66 if (!redirectDomain.matches(requestURL)) {
67 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Conversion was not accepted because the HTTP redirect was not same-site.");
68 return;
69 }
70
71 if (redirectDomain.matches(firstPartyURL)) {
72 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Conversion was not accepted because it was requested in an HTTP redirect that is same-site as the first-party.");
73 return;
74 }
75
76 convert(AdClickAttribution::Source { WTFMove(redirectDomain) }, AdClickAttribution::Destination { firstPartyURL }, WTFMove(conversion));
77}
78
79void AdClickAttributionManager::startTimer(Seconds seconds)
80{
81 m_firePendingConversionRequestsTimer.startOneShot(m_isRunningTest ? 0_s : seconds);
82}
83
84void AdClickAttributionManager::convert(const Source& source, const Destination& destination, Conversion&& conversion)
85{
86 clearExpired();
87
88 if (!conversion.isValid()) {
89 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Got an invalid conversion.");
90 return;
91 }
92
93#if !RELEASE_LOG_DISABLED
94 auto conversionData = conversion.data;
95 auto conversionPriority = conversion.priority;
96#endif
97
98 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Got a conversion with conversion data: %{public}u and priority: %{public}u.", conversionData, conversionPriority);
99
100 auto secondsUntilSend = Seconds::infinity();
101
102 auto pair = std::make_pair(source, destination);
103 auto previouslyUnconvertedAttribution = m_unconvertedAdClickAttributionMap.take(pair);
104 auto previouslyConvertedAttributionIter = m_convertedAdClickAttributionMap.find(pair);
105
106 if (!previouslyUnconvertedAttribution.isEmpty()) {
107 // Always convert the pending attribution and remove it from the unconverted map.
108 if (auto optionalSecondsUntilSend = previouslyUnconvertedAttribution.convertAndGetEarliestTimeToSend(WTFMove(conversion))) {
109 secondsUntilSend = *optionalSecondsUntilSend;
110 ASSERT(secondsUntilSend != Seconds::infinity());
111 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Converted a stored ad click with conversion data: %{public}u and priority: %{public}u.", conversionData, conversionPriority);
112 }
113
114 if (previouslyConvertedAttributionIter == m_convertedAdClickAttributionMap.end())
115 m_convertedAdClickAttributionMap.add(pair, WTFMove(previouslyUnconvertedAttribution));
116 else if (previouslyUnconvertedAttribution.hasHigherPriorityThan(previouslyConvertedAttributionIter->value)) {
117 // If the newly converted attribution has higher priority, replace the old one.
118 m_convertedAdClickAttributionMap.set(pair, WTFMove(previouslyUnconvertedAttribution));
119 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Replaced a previously converted ad click with a new one with conversion data: %{public}u and priority: %{public}u because it had higher priority.", conversionData, conversionPriority);
120 }
121 } else if (previouslyConvertedAttributionIter != m_convertedAdClickAttributionMap.end()) {
122 // If we have no newly converted attribution, re-convert the old one to respect the new priority.
123 if (auto optionalSecondsUntilSend = previouslyConvertedAttributionIter->value.convertAndGetEarliestTimeToSend(WTFMove(conversion))) {
124 secondsUntilSend = *optionalSecondsUntilSend;
125 ASSERT(secondsUntilSend != Seconds::infinity());
126 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Re-converted an ad click with a new one with conversion data: %{public}u and priority: %{public}u because it had higher priority.", conversionData, conversionPriority);
127 }
128 }
129
130 if (secondsUntilSend == Seconds::infinity())
131 return;
132
133 if (m_firePendingConversionRequestsTimer.isActive() && m_firePendingConversionRequestsTimer.nextFireInterval() < secondsUntilSend)
134 return;
135
136 if (debugModeEnabled()) {
137 RELEASE_LOG_INFO(AdClickAttribution, "Setting timer for firing conversion requests to the debug mode timeout of %{public}f seconds where the regular timeout would have been %{public}f seconds.", debugModeSecondsUntilSend.seconds(), secondsUntilSend.seconds());
138 secondsUntilSend = debugModeSecondsUntilSend;
139 }
140
141 startTimer(secondsUntilSend);
142}
143
144void AdClickAttributionManager::fireConversionRequest(const AdClickAttribution& attribution)
145{
146 auto conversionURL = m_conversionBaseURLForTesting ? attribution.urlForTesting(*m_conversionBaseURLForTesting) : attribution.url();
147 if (conversionURL.isEmpty() || !conversionURL.isValid())
148 return;
149
150 auto conversionReferrerURL = attribution.referrer();
151 if (conversionReferrerURL.isEmpty() || !conversionReferrerURL.isValid())
152 return;
153
154 ResourceRequest request { conversionURL };
155
156 request.setHTTPMethod("POST"_s);
157 request.setHTTPHeaderField(HTTPHeaderName::CacheControl, "max-age=0"_s);
158 request.setHTTPReferrer(conversionReferrerURL.string());
159
160 FetchOptions options;
161 options.credentials = FetchOptions::Credentials::Omit;
162 options.redirect = FetchOptions::Redirect::Error;
163
164 static uint64_t identifier = 0;
165
166 NetworkResourceLoadParameters loadParameters;
167 loadParameters.identifier = ++identifier;
168 loadParameters.request = request;
169 loadParameters.sourceOrigin = SecurityOrigin::create(conversionReferrerURL);
170 loadParameters.parentPID = presentingApplicationPID();
171 loadParameters.sessionID = PAL::SessionID::defaultSessionID();
172 loadParameters.storedCredentialsPolicy = StoredCredentialsPolicy::EphemeralStatelessCookieless;
173 loadParameters.options = options;
174 loadParameters.shouldClearReferrerOnHTTPSToHTTPRedirect = true;
175 loadParameters.shouldRestrictHTTPResponseAccess = false;
176
177#if ENABLE(CONTENT_EXTENSIONS)
178 loadParameters.mainDocumentURL = WTFMove(conversionReferrerURL);
179#endif
180
181 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "About to fire an attribution request for a conversion.");
182
183 m_pingLoadFunction(WTFMove(loadParameters), [](const WebCore::ResourceError& error, const WebCore::ResourceResponse& response) {
184#if PLATFORM(COCOA)
185 RELEASE_LOG_ERROR_IF(!error.isNull(), AdClickAttribution, "Received error: '%{public}s' for ad click attribution request.", error.localizedDescription().utf8().data());
186#else
187 RELEASE_LOG_ERROR_IF(!error.isNull(), AdClickAttribution, "Received error: '%s' for ad click attribution request.", error.localizedDescription().utf8().data());
188#endif
189 UNUSED_PARAM(response);
190 UNUSED_PARAM(error);
191 });
192}
193
194void AdClickAttributionManager::firePendingConversionRequests()
195{
196 auto nextTimeToFire = Seconds::infinity();
197 for (auto& attribution : m_convertedAdClickAttributionMap.values()) {
198 if (attribution.wasConversionSent()) {
199 ASSERT_NOT_REACHED();
200 continue;
201 }
202 auto earliestTimeToSend = attribution.earliestTimeToSend();
203 if (!earliestTimeToSend) {
204 ASSERT_NOT_REACHED();
205 continue;
206 }
207
208 auto now = WallTime::now();
209 if (*earliestTimeToSend <= now || m_isRunningTest || debugModeEnabled()) {
210 fireConversionRequest(attribution);
211 attribution.markConversionAsSent();
212 continue;
213 }
214
215 auto seconds = *earliestTimeToSend - now;
216 nextTimeToFire = std::min(nextTimeToFire, seconds);
217 }
218
219 m_convertedAdClickAttributionMap.removeIf([](auto& keyAndValue) {
220 return keyAndValue.value.wasConversionSent();
221 });
222
223 if (nextTimeToFire < Seconds::infinity())
224 startTimer(nextTimeToFire);
225}
226
227void AdClickAttributionManager::clear()
228{
229 m_firePendingConversionRequestsTimer.stop();
230 m_unconvertedAdClickAttributionMap.clear();
231 m_convertedAdClickAttributionMap.clear();
232}
233
234void AdClickAttributionManager::clearForRegistrableDomain(const RegistrableDomain& domain)
235{
236 m_unconvertedAdClickAttributionMap.removeIf([&domain](auto& keyAndValue) {
237 return keyAndValue.key.first.registrableDomain == domain || keyAndValue.key.second.registrableDomain == domain;
238 });
239
240 m_convertedAdClickAttributionMap.removeIf([&domain](auto& keyAndValue) {
241 return keyAndValue.key.first.registrableDomain == domain || keyAndValue.key.second.registrableDomain == domain;
242 });
243}
244
245void AdClickAttributionManager::clearExpired()
246{
247 m_unconvertedAdClickAttributionMap.removeIf([](auto& keyAndValue) {
248 return keyAndValue.value.hasExpired();
249 });
250}
251
252void AdClickAttributionManager::toString(CompletionHandler<void(String)>&& completionHandler) const
253{
254 if (m_unconvertedAdClickAttributionMap.isEmpty() && m_convertedAdClickAttributionMap.isEmpty())
255 return completionHandler("\nNo stored Ad Click Attribution data.\n"_s);
256
257 unsigned unconvertedAttributionNumber = 0;
258 StringBuilder builder;
259 for (auto& attribution : m_unconvertedAdClickAttributionMap.values()) {
260 if (!unconvertedAttributionNumber)
261 builder.appendLiteral("Unconverted Ad Click Attributions:\n");
262 else
263 builder.append('\n');
264 builder.appendLiteral("WebCore::AdClickAttribution ");
265 builder.appendNumber(++unconvertedAttributionNumber);
266 builder.append('\n');
267 builder.append(attribution.toString());
268}
269
270 unsigned convertedAttributionNumber = 0;
271 for (auto& attribution : m_convertedAdClickAttributionMap.values()) {
272 if (unconvertedAttributionNumber)
273 builder.append('\n');
274 if (!convertedAttributionNumber)
275 builder.appendLiteral("Converted Ad Click Attributions:\n");
276 else
277 builder.append('\n');
278 builder.appendLiteral("WebCore::AdClickAttribution ");
279 builder.appendNumber(++convertedAttributionNumber + unconvertedAttributionNumber);
280 builder.append('\n');
281 builder.append(attribution.toString());
282 }
283
284 completionHandler(builder.toString());
285}
286
287void AdClickAttributionManager::setConversionURLForTesting(URL&& testURL)
288{
289 if (testURL.isEmpty())
290 m_conversionBaseURLForTesting = { };
291 else
292 m_conversionBaseURLForTesting = WTFMove(testURL);
293}
294
295void AdClickAttributionManager::markAllUnconvertedAsExpiredForTesting()
296{
297 for (auto& attribution : m_unconvertedAdClickAttributionMap.values())
298 attribution.markAsExpired();
299}
300
301bool AdClickAttributionManager::debugModeEnabled() const
302{
303 return RuntimeEnabledFeatures::sharedFeatures().adClickAttributionDebugModeEnabled() && !m_sessionID.isEphemeral();
304}
305
306} // namespace WebKit
307