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 | |
40 | namespace WebKit { |
41 | using namespace WebCore; |
42 | |
43 | using Source = AdClickAttribution::Source; |
44 | using Destination = AdClickAttribution::Destination; |
45 | using DestinationMap = HashMap<Destination, AdClickAttribution>; |
46 | using Conversion = AdClickAttribution::Conversion; |
47 | |
48 | constexpr Seconds debugModeSecondsUntilSend { 60_s }; |
49 | |
50 | void 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 | |
58 | void 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 | |
79 | void AdClickAttributionManager::startTimer(Seconds seconds) |
80 | { |
81 | m_firePendingConversionRequestsTimer.startOneShot(m_isRunningTest ? 0_s : seconds); |
82 | } |
83 | |
84 | void 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 | |
144 | void 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 | |
194 | void 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 | |
227 | void AdClickAttributionManager::clear() |
228 | { |
229 | m_firePendingConversionRequestsTimer.stop(); |
230 | m_unconvertedAdClickAttributionMap.clear(); |
231 | m_convertedAdClickAttributionMap.clear(); |
232 | } |
233 | |
234 | void 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 | |
245 | void AdClickAttributionManager::clearExpired() |
246 | { |
247 | m_unconvertedAdClickAttributionMap.removeIf([](auto& keyAndValue) { |
248 | return keyAndValue.value.hasExpired(); |
249 | }); |
250 | } |
251 | |
252 | void 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 | |
287 | void AdClickAttributionManager::setConversionURLForTesting(URL&& testURL) |
288 | { |
289 | if (testURL.isEmpty()) |
290 | m_conversionBaseURLForTesting = { }; |
291 | else |
292 | m_conversionBaseURLForTesting = WTFMove(testURL); |
293 | } |
294 | |
295 | void AdClickAttributionManager::markAllUnconvertedAsExpiredForTesting() |
296 | { |
297 | for (auto& attribution : m_unconvertedAdClickAttributionMap.values()) |
298 | attribution.markAsExpired(); |
299 | } |
300 | |
301 | bool AdClickAttributionManager::debugModeEnabled() const |
302 | { |
303 | return RuntimeEnabledFeatures::sharedFeatures().adClickAttributionDebugModeEnabled() && !m_sessionID.isEphemeral(); |
304 | } |
305 | |
306 | } // namespace WebKit |
307 | |