Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,4 @@ public boolean isPrioritizedFamily(String cookieFamilyName) {
final String bidder = prioritizedCookieFamilyNameToBidderName.get(cookieFamilyName);
return prioritizedBidders.contains(bidder);
}

public boolean hasPrioritizedBidders() {
return !prioritizedBidders.isEmpty();
}
}
175 changes: 107 additions & 68 deletions src/main/java/org/prebid/server/cookie/UidsCookieService.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.time.Duration;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
Expand All @@ -34,15 +35,20 @@ public class UidsCookieService {
private static final Logger logger = LoggerFactory.getLogger(UidsCookieService.class);

private static final String COOKIE_NAME = "uids";
private static final String COOKIE_NAME_FORMAT = "uids%d";
private static final int MIN_COOKIE_SIZE_BYTES = 500;
private static final int MIN_NUMBER_OF_UID_COOKIES = 1;
private static final int MAX_NUMBER_OF_UID_COOKIES = 30;

private final String optOutCookieName;
private final String optOutCookieValue;
private final String hostCookieFamily;
private final String hostCookieName;
private final String hostCookieDomain;
private final long ttlSeconds;

private final int maxCookieSizeBytes;
private final int numberOfUidCookies;

private final PrioritizedCoopSyncProvider prioritizedCoopSyncProvider;
private final Metrics metrics;
Expand All @@ -55,6 +61,7 @@ public UidsCookieService(String optOutCookieName,
String hostCookieDomain,
int ttlDays,
int maxCookieSizeBytes,
int numberOfUidCookies,
PrioritizedCoopSyncProvider prioritizedCoopSyncProvider,
Metrics metrics,
JacksonMapper mapper) {
Expand All @@ -64,13 +71,20 @@ public UidsCookieService(String optOutCookieName,
"Configured cookie size is less than allowed minimum size of " + MIN_COOKIE_SIZE_BYTES);
}

if (numberOfUidCookies < MIN_NUMBER_OF_UID_COOKIES || numberOfUidCookies > MAX_NUMBER_OF_UID_COOKIES) {
throw new IllegalArgumentException(
"Configured number of uid cookies should be in the range from %d to %d"
.formatted(MIN_NUMBER_OF_UID_COOKIES, MAX_NUMBER_OF_UID_COOKIES));
}

this.optOutCookieName = optOutCookieName;
this.optOutCookieValue = optOutCookieValue;
this.hostCookieFamily = hostCookieFamily;
this.hostCookieName = hostCookieName;
this.hostCookieDomain = StringUtils.isNotBlank(hostCookieDomain) ? hostCookieDomain : null;
this.ttlSeconds = Duration.ofDays(ttlDays).getSeconds();
this.maxCookieSizeBytes = maxCookieSizeBytes;
this.numberOfUidCookies = numberOfUidCookies;
this.prioritizedCoopSyncProvider = Objects.requireNonNull(prioritizedCoopSyncProvider);
this.metrics = Objects.requireNonNull(metrics);
this.mapper = Objects.requireNonNull(mapper);
Expand Down Expand Up @@ -105,57 +119,66 @@ public UidsCookie parseFromRequest(HttpRequestContext httpRequest) {
*/
UidsCookie parseFromCookies(Map<String, String> cookies) {
final Uids parsedUids = parseUids(cookies);
final boolean isOptedOut = isOptedOut(cookies);

final Boolean optout;
final Map<String, UidWithExpiry> uidsMap;

if (isOptedOut(cookies)) {
optout = true;
uidsMap = Collections.emptyMap();
} else {
optout = parsedUids != null ? parsedUids.getOptout() : null;
uidsMap = enrichAndSanitizeUids(parsedUids, cookies);
}

final Uids uids = Uids.builder().uids(uidsMap).optout(optout).build();
final Uids uids = Uids.builder()
.uids(isOptedOut ? Collections.emptyMap() : enrichAndSanitizeUids(parsedUids, cookies))
.optout(isOptedOut)
.build();

return new UidsCookie(uids, mapper);
}

/**
* Parses cookies {@link Map} and composes {@link Uids} model.
*/
public Uids parseUids(Map<String, String> cookies) {
if (cookies.containsKey(COOKIE_NAME)) {
final String cookieValue = cookies.get(COOKIE_NAME);
try {
return mapper.decodeValue(Buffer.buffer(Base64.getUrlDecoder().decode(cookieValue)), Uids.class);
} catch (IllegalArgumentException | DecodeException e) {
logger.debug("Could not decode or parse {} cookie value {}", e, COOKIE_NAME, cookieValue);
private Uids parseUids(Map<String, String> cookies) {
final Map<String, UidWithExpiry> uids = new HashMap<>();

for (Map.Entry<String, String> cookie : cookies.entrySet()) {
final String cookieKey = cookie.getKey();
if (cookieKey.startsWith(COOKIE_NAME)) {
try {
final Uids parsedUids = mapper.decodeValue(
Buffer.buffer(Base64.getUrlDecoder().decode(cookie.getValue())), Uids.class);
if (parsedUids != null && parsedUids.getUids() != null) {
parsedUids.getUids().forEach((key, value) -> uids.merge(key, value, (newValue, oldValue) ->
newValue.getExpires().compareTo(oldValue.getExpires()) > 0 ? newValue : oldValue));
}
} catch (IllegalArgumentException | DecodeException e) {
logger.debug("Could not decode or parse {} cookie value {}", e, COOKIE_NAME, cookie.getValue());
}
}
}
return null;

return Uids.builder().uids(uids).build();
}

/**
* Creates a {@link Cookie} with 'uids' as a name and encoded JSON string representing supplied {@link UidsCookie}
* as a value.
*/
public Cookie toCookie(UidsCookie uidsCookie) {
return makeCookie(uidsCookie);
public Cookie makeCookie(String cookieName, UidsCookie uidsCookie) {
return Cookie
.cookie(cookieName, Base64.getUrlEncoder().encodeToString(uidsCookie.toJson().getBytes()))
.setPath("/")
.setSameSite(CookieSameSite.NONE)
.setSecure(true)
.setMaxAge(ttlSeconds)
.setDomain(hostCookieDomain);
}

private int cookieBytesLength(UidsCookie uidsCookie) {
return makeCookie(uidsCookie).encode().getBytes().length;
public Cookie makeCookie(UidsCookie uidsCookie) {
return makeCookie(COOKIE_NAME, uidsCookie);
}

private Cookie makeCookie(UidsCookie uidsCookie) {
public Cookie removeCookie(String cookieName) {
return Cookie
.cookie(COOKIE_NAME, Base64.getUrlEncoder().encodeToString(uidsCookie.toJson().getBytes()))
.cookie(cookieName, StringUtils.EMPTY)
.setPath("/")
.setSameSite(CookieSameSite.NONE)
.setSecure(true)
.setMaxAge(ttlSeconds)
.setMaxAge(0)
.setDomain(hostCookieDomain);
}

Expand Down Expand Up @@ -221,20 +244,18 @@ private static boolean facebookSentinelOrEmpty(Map.Entry<String, UidWithExpiry>

/***
* Removes expired {@link Uids}, updates {@link UidsCookie} with new uid for family name according to priority
* and trims it to the limit
*/
public UidsCookieUpdateResult updateUidsCookie(UidsCookie uidsCookie, String familyName, String uid) {
final UidsCookie initialCookie = trimToLimit(removeExpiredUids(uidsCookie)); // if already exceeded limit

if (StringUtils.isBlank(uid)) {
return UidsCookieUpdateResult.unaltered(initialCookie.deleteUid(familyName));
} else if (UidsCookie.isFacebookSentinel(familyName, uid)) {
// At the moment, Facebook calls /setuid with a UID of 0 if the user isn't logged into Facebook.
// They shouldn't be sending us a sentinel value... but since they are, we're refusing to save that ID.
return UidsCookieUpdateResult.unaltered(initialCookie);
final UidsCookie initialCookie = removeExpiredUids(uidsCookie);

// At the moment, Facebook calls /setuid with a UID of 0 if the user isn't logged into Facebook.
// They shouldn't be sending us a sentinel value... but since they are, we're refusing to save that ID.
if (StringUtils.isBlank(uid) || UidsCookie.isFacebookSentinel(familyName, uid)) {
return UidsCookieUpdateResult.failure(splitUids(initialCookie));
}

return updateUidsCookieByPriority(initialCookie, familyName, uid);
final UidsCookie updatedCookie = initialCookie.updateUid(familyName, uid);
return UidsCookieUpdateResult.success(splitUids(updatedCookie));
}

private static UidsCookie removeExpiredUids(UidsCookie uidsCookie) {
Expand All @@ -250,43 +271,53 @@ private static UidsCookie removeExpiredUids(UidsCookie uidsCookie) {
return updatedCookie;
}

private UidsCookieUpdateResult updateUidsCookieByPriority(UidsCookie uidsCookie, String familyName, String uid) {
final UidsCookie updatedCookie = uidsCookie.updateUid(familyName, uid);
if (!cookieExceededMaxLength(updatedCookie)) {
return UidsCookieUpdateResult.updated(updatedCookie);
}

if (!prioritizedCoopSyncProvider.hasPrioritizedBidders()
|| prioritizedCoopSyncProvider.isPrioritizedFamily(familyName)) {
return UidsCookieUpdateResult.updated(trimToLimit(updatedCookie));
} else {
metrics.updateUserSyncSizeBlockedMetric(familyName);
return UidsCookieUpdateResult.unaltered(uidsCookie);
}
}

private boolean cookieExceededMaxLength(UidsCookie uidsCookie) {
return maxCookieSizeBytes > 0 && cookieBytesLength(uidsCookie) > maxCookieSizeBytes;
}
public Map<String, UidsCookie> splitUids(UidsCookie uidsCookie) {
final Uids cookieUids = uidsCookie.getCookieUids();
final Map<String, UidWithExpiry> uids = cookieUids.getUids();
final boolean hasOptout = !uidsCookie.allowsSync();

final Iterator<String> cookieFamilyIterator = cookieFamilyNamesByDescPriorityAndExpiration(uidsCookie);
final Map<String, UidsCookie> splitCookies = new HashMap<>();

int uidsIndex = 0;
String nextCookieFamily = null;

while (uidsIndex < numberOfUidCookies) {
final String uidsName = uidsIndex == 0 ? COOKIE_NAME : COOKIE_NAME_FORMAT.formatted(uidsIndex + 1);
final UidsCookie tempUidsCookie = splitCookies.computeIfAbsent(
uidsName,
key -> new UidsCookie(Uids.builder().uids(new HashMap<>()).optout(hasOptout).build(), mapper));
final Map<String, UidWithExpiry> tempUids = tempUidsCookie.getCookieUids().getUids();

while (nextCookieFamily != null || cookieFamilyIterator.hasNext()) {
nextCookieFamily = nextCookieFamily == null ? cookieFamilyIterator.next() : nextCookieFamily;
tempUids.put(nextCookieFamily, uids.get(nextCookieFamily));
if (cookieExceededMaxLength(uidsName, tempUidsCookie)) {
tempUids.remove(nextCookieFamily);
break;
}

nextCookieFamily = null;
}

private UidsCookie trimToLimit(UidsCookie uidsCookie) {
if (!cookieExceededMaxLength(uidsCookie)) {
return uidsCookie;
uidsIndex++;
}

UidsCookie trimmedUids = uidsCookie;
final Iterator<String> familyToRemoveIterator = cookieFamilyNamesByAscendingPriority(uidsCookie);
while (nextCookieFamily != null || cookieFamilyIterator.hasNext()) {
nextCookieFamily = nextCookieFamily == null ? cookieFamilyIterator.next() : nextCookieFamily;
if (prioritizedCoopSyncProvider.isPrioritizedFamily(nextCookieFamily)) {
metrics.updateUserSyncSizedOutMetric(nextCookieFamily);
} else {
metrics.updateUserSyncSizeBlockedMetric(nextCookieFamily);
}

while (familyToRemoveIterator.hasNext() && cookieExceededMaxLength(trimmedUids)) {
final String familyToRemove = familyToRemoveIterator.next();
metrics.updateUserSyncSizedOutMetric(familyToRemove);
trimmedUids = trimmedUids.deleteUid(familyToRemove);
nextCookieFamily = null;
}

return trimmedUids;
return splitCookies;
}

private Iterator<String> cookieFamilyNamesByAscendingPriority(UidsCookie uidsCookie) {
private Iterator<String> cookieFamilyNamesByDescPriorityAndExpiration(UidsCookie uidsCookie) {
return uidsCookie.getCookieUids().getUids().entrySet().stream()
.sorted(this::compareCookieFamilyNames)
.map(Map.Entry::getKey)
Expand All @@ -303,12 +334,20 @@ private int compareCookieFamilyNames(Map.Entry<String, UidWithExpiry> left,
if ((leftPrioritized && rightPrioritized) || (!leftPrioritized && !rightPrioritized)) {
return left.getValue().getExpires().compareTo(right.getValue().getExpires());
} else if (leftPrioritized) {
return 1;
} else { // right is prioritized
return -1;
} else { // right is prioritized
return 1;
}
}

private boolean cookieExceededMaxLength(String name, UidsCookie uidsCookie) {
return maxCookieSizeBytes > 0 && cookieBytesLength(name, uidsCookie) > maxCookieSizeBytes;
}

private int cookieBytesLength(String cookieName, UidsCookie uidsCookie) {
return makeCookie(cookieName, uidsCookie).encode().getBytes().length;
}

public String hostCookieUidToSync(RoutingContext routingContext, String cookieFamilyName) {
if (!StringUtils.equals(cookieFamilyName, hostCookieFamily)) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@
import lombok.Value;
import org.prebid.server.cookie.UidsCookie;

import java.util.Map;

@Value(staticConstructor = "of")
public class UidsCookieUpdateResult {

boolean successfullyUpdated;

UidsCookie uidsCookie;
Map<String, UidsCookie> uidsCookies;

public static UidsCookieUpdateResult updated(UidsCookie uidsCookie) {
return of(true, uidsCookie);
public static UidsCookieUpdateResult success(Map<String, UidsCookie> uidsCookies) {
return of(true, uidsCookies);
}

public static UidsCookieUpdateResult unaltered(UidsCookie uidsCookie) {
return of(false, uidsCookie);
public static UidsCookieUpdateResult failure(Map<String, UidsCookie> uidsCookies) {
return of(false, uidsCookies);
}
}
3 changes: 2 additions & 1 deletion src/main/java/org/prebid/server/handler/OptoutHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ private Cookie optCookie(boolean optout, RoutingContext routingContext) {
final UidsCookie uidsCookie = uidsCookieService
.parseFromRequest(routingContext)
.updateOptout(optout);
return uidsCookieService.toCookie(uidsCookie);

return uidsCookieService.makeCookie(uidsCookie);
}

private String optUrl(boolean optout) {
Expand Down
16 changes: 12 additions & 4 deletions src/main/java/org/prebid/server/handler/SetuidHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,12 @@ private void respondWithCookie(SetuidContext setuidContext) {
final String uid = routingContext.request().getParam(UID_PARAM);
final String bidder = setuidContext.getCookieName();

final UidsCookieUpdateResult uidsCookieUpdateResult =
uidsCookieService.updateUidsCookie(setuidContext.getUidsCookie(), bidder, uid);
final Cookie updatedUidsCookie = uidsCookieService.toCookie(uidsCookieUpdateResult.getUidsCookie());
addCookie(routingContext, updatedUidsCookie);
final UidsCookieUpdateResult uidsCookieUpdateResult = uidsCookieService.updateUidsCookie(
setuidContext.getUidsCookie(), bidder, uid);

uidsCookieUpdateResult.getUidsCookies().entrySet().stream()
.map(entry -> toCookie(entry.getKey(), entry.getValue()))
.forEach(uidsCookie -> addCookie(routingContext, uidsCookie));

if (uidsCookieUpdateResult.isSuccessfullyUpdated()) {
metrics.updateUserSyncSetsMetric(bidder);
Expand All @@ -349,6 +351,12 @@ private void respondWithCookie(SetuidContext setuidContext) {
analyticsDelegator.processEvent(setuidEvent, tcfContext);
}

private Cookie toCookie(String cookieName, UidsCookie uidsCookie) {
return uidsCookie.getCookieUids().getUids().isEmpty()
? uidsCookieService.removeCookie(cookieName)
: uidsCookieService.makeCookie(cookieName, uidsCookie);
}

private Consumer<HttpServerResponse> buildCookieResponseConsumer(SetuidContext setuidContext,
int responseStatusCode) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ UidsCookieService uidsCookieService(
@Value("${host-cookie.domain:#{null}}") String hostCookieDomain,
@Value("${host-cookie.ttl-days}") Integer ttlDays,
@Value("${host-cookie.max-cookie-size-bytes}") Integer maxCookieSizeBytes,
@Value("${setuid.number-of-uid-cookies:1}") int numberOfUidCookies,
PrioritizedCoopSyncProvider prioritizedCoopSyncProvider,
Metrics metrics,
JacksonMapper mapper) {
Expand All @@ -670,6 +671,7 @@ UidsCookieService uidsCookieService(
hostCookieDomain,
ttlDays,
maxCookieSizeBytes,
numberOfUidCookies,
prioritizedCoopSyncProvider,
metrics,
mapper);
Expand Down
Loading
Loading