| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package org.apache.unomi.rest.endpoints; |
| |
| import com.fasterxml.jackson.core.JsonProcessingException; |
| import com.fasterxml.jackson.databind.node.JsonNodeFactory; |
| import com.fasterxml.jackson.databind.node.ObjectNode; |
| import org.apache.commons.lang3.StringUtils; |
| import org.apache.cxf.rs.security.cors.CrossOriginResourceSharing; |
| import org.apache.unomi.api.*; |
| import org.apache.unomi.api.conditions.Condition; |
| import org.apache.unomi.api.services.*; |
| import org.apache.unomi.persistence.spi.CustomObjectMapper; |
| import org.apache.unomi.rest.exception.InvalidRequestException; |
| import org.apache.unomi.rest.service.RestServiceUtils; |
| import org.apache.unomi.schema.api.SchemaService; |
| import org.apache.unomi.utils.Changes; |
| import org.apache.unomi.utils.HttpUtils; |
| import org.osgi.service.component.annotations.Component; |
| import org.osgi.service.component.annotations.Reference; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import javax.jws.WebService; |
| import javax.servlet.ServletContext; |
| import javax.servlet.ServletRequest; |
| import javax.servlet.ServletResponse; |
| import javax.servlet.http.HttpServletRequest; |
| import javax.servlet.http.HttpServletResponse; |
| import javax.ws.rs.*; |
| import javax.ws.rs.core.Context; |
| import javax.ws.rs.core.MediaType; |
| import javax.ws.rs.core.Response; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Date; |
| import java.util.HashMap; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.UUID; |
| |
| @WebService |
| @Consumes(MediaType.APPLICATION_JSON) |
| @CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = true) |
| @Path("/") |
| @Component(service = ContextJsonEndpoint.class, property = "osgi.jaxrs.resource=true") |
| public class ContextJsonEndpoint { |
| private static final Logger logger = LoggerFactory.getLogger(ContextJsonEndpoint.class.getName()); |
| |
| private static final String DEFAULT_CLIENT_ID = "defaultClientId"; |
| |
| private boolean sanitizeConditions = Boolean |
| .parseBoolean(System.getProperty("org.apache.unomi.security.personalization.sanitizeConditions", "true")); |
| |
| @Context |
| ServletContext context; |
| @Context |
| HttpServletRequest request; |
| @Context |
| HttpServletResponse response; |
| |
| @Reference |
| private ProfileService profileService; |
| @Reference |
| private PrivacyService privacyService; |
| @Reference |
| private EventService eventService; |
| @Reference |
| private RulesService rulesService; |
| @Reference |
| private PersonalizationService personalizationService; |
| @Reference |
| private ConfigSharingService configSharingService; |
| @Reference |
| private RestServiceUtils restServiceUtils; |
| @Reference |
| private SchemaService schemaService; |
| |
| @OPTIONS |
| @Path("/context.js") |
| public Response contextJSAsOptions() { |
| return Response.status(Response.Status.NO_CONTENT).header("Access-Control-Allow-Origin", "*").build(); |
| } |
| |
| @OPTIONS |
| @Path("/context.json") |
| public Response contextJSONAsOptions() { |
| return contextJSAsOptions(); |
| } |
| |
| @POST |
| @Produces(MediaType.TEXT_PLAIN) |
| @Path("/context.js") |
| public Response contextJSAsPost(ContextRequest contextRequest, |
| @QueryParam("personaId") String personaId, |
| @QueryParam("sessionId") String sessionId, |
| @QueryParam("timestamp") Long timestampAsLong, @QueryParam("invalidateProfile") boolean invalidateProfile, |
| @QueryParam("invalidateSession") boolean invalidateSession) throws JsonProcessingException { |
| return contextJSAsGet(contextRequest, personaId, sessionId, timestampAsLong, invalidateProfile, invalidateSession); |
| } |
| |
| @GET |
| @Produces(MediaType.TEXT_PLAIN) |
| @Path("/context.js") |
| public Response contextJSAsGet(@QueryParam("payload") ContextRequest contextRequest, |
| @QueryParam("personaId") String personaId, |
| @QueryParam("sessionId") String sessionId, |
| @QueryParam("timestamp") Long timestampAsLong, @QueryParam("invalidateProfile") boolean invalidateProfile, |
| @QueryParam("invalidateSession") boolean invalidateSession) throws JsonProcessingException { |
| ContextResponse contextResponse = contextJSONAsPost(contextRequest, personaId, sessionId, timestampAsLong, invalidateProfile, |
| invalidateSession); |
| String contextAsJSONString = CustomObjectMapper.getObjectMapper().writeValueAsString(contextResponse); |
| StringBuilder responseAsString = new StringBuilder(); |
| responseAsString.append("window.digitalData = window.digitalData || {};\n").append("var cxs = ").append(contextAsJSONString) |
| .append(";\n"); |
| return Response.ok(responseAsString.toString()).build(); |
| } |
| |
| @GET |
| @Produces(MediaType.APPLICATION_JSON + ";charset=UTF-8") |
| @Path("/context.json") |
| public ContextResponse contextJSONAsGet(@QueryParam("payload") ContextRequest contextRequest, |
| @QueryParam("personaId") String personaId, |
| @QueryParam("sessionId") String sessionId, |
| @QueryParam("timestamp") Long timestampAsLong, @QueryParam("invalidateProfile") boolean invalidateProfile, |
| @QueryParam("invalidateSession") boolean invalidateSession) { |
| return contextJSONAsPost(contextRequest, personaId, sessionId, timestampAsLong, invalidateProfile, invalidateSession); |
| } |
| |
| @POST |
| @Produces(MediaType.APPLICATION_JSON + ";charset=UTF-8") |
| @Path("/context.json") |
| public ContextResponse contextJSONAsPost(ContextRequest contextRequest, |
| @QueryParam("personaId") String personaId, |
| @QueryParam("sessionId") String sessionId, |
| @QueryParam("timestamp") Long timestampAsLong, @QueryParam("invalidateProfile") boolean invalidateProfile, |
| @QueryParam("invalidateSession") boolean invalidateSession) { |
| |
| // Schema validation |
| ObjectNode paramsAsJson = JsonNodeFactory.instance.objectNode(); |
| if (personaId != null) { |
| paramsAsJson.put("personaId", personaId); |
| } |
| if (sessionId != null) { |
| paramsAsJson.put("sessionId", sessionId); |
| } |
| if (!schemaService.isValid(paramsAsJson.toString(), "https://unomi.apache.org/schemas/json/contextrequestparams/1-0-0")) { |
| throw new InvalidRequestException("Invalid parameter", "Invalid received data"); |
| } |
| Date timestamp = new Date(); |
| if (timestampAsLong != null) { |
| timestamp = new Date(timestampAsLong); |
| } |
| |
| // Handle persona |
| Profile profile = null; |
| Session session = null; |
| String profileId = null; |
| if (personaId != null) { |
| PersonaWithSessions personaWithSessions = profileService.loadPersonaWithSessions(personaId); |
| if (personaWithSessions == null) { |
| logger.error("Couldn't find persona, please check your personaId parameter"); |
| profile = null; |
| } else { |
| profile = personaWithSessions.getPersona(); |
| session = personaWithSessions.getLastSession(); |
| } |
| } |
| |
| String scope = null; |
| if (contextRequest != null) { |
| if (contextRequest.getSource() != null) { |
| scope = contextRequest.getSource().getScope(); |
| } |
| |
| if (contextRequest.getSessionId() != null) { |
| sessionId = contextRequest.getSessionId(); |
| } |
| |
| profileId = contextRequest.getProfileId(); |
| } |
| if (profileId == null) { |
| // Get profile id from the cookie |
| profileId = restServiceUtils.getProfileIdCookieValue(request); |
| } |
| |
| if (profileId == null && sessionId == null && personaId == null) { |
| logger.error( |
| "Couldn't find profileId, sessionId or personaId in incoming request! Stopped processing request. See debug level for more information"); |
| if (logger.isDebugEnabled()) { |
| logger.debug("Request dump: {}", HttpUtils.dumpRequestInfo(request)); |
| } |
| throw new BadRequestException("Couldn't find profileId, sessionId or personaId in incoming request!"); |
| } |
| |
| int changes = EventService.NO_CHANGE; |
| |
| // Not a persona, resolve profile now |
| boolean profileCreated = false; |
| |
| if (profile == null) { |
| if (profileId == null || invalidateProfile) { |
| // no profileId cookie was found or the profile has to be invalidated, we generate a new one and create the profile in the profile service |
| profile = createNewProfile(null, timestamp); |
| profileCreated = true; |
| } else { |
| profile = profileService.load(profileId); |
| if (profile == null) { |
| // this can happen if we have an old cookie but have reset the server, |
| // or if we merged the profiles and somehow this cookie didn't get updated. |
| profile = createNewProfile(profileId, timestamp); |
| profileCreated = true; |
| } else { |
| Changes changesObject = checkMergedProfile(profile, session); |
| changes |= changesObject.getChangeType(); |
| profile = changesObject.getProfile(); |
| } |
| } |
| |
| Profile sessionProfile; |
| if (StringUtils.isNotBlank(sessionId) && !invalidateSession) { |
| session = profileService.loadSession(sessionId, timestamp); |
| if (session != null) { |
| sessionProfile = session.getProfile(); |
| |
| boolean anonymousSessionProfile = sessionProfile.isAnonymousProfile(); |
| if (!profile.isAnonymousProfile() && !anonymousSessionProfile && !profile.getItemId() |
| .equals(sessionProfile.getItemId())) { |
| // Session user has been switched, profile id in cookie is not up to date |
| // We must reload the profile with the session ID as some properties could be missing from the session profile |
| // #personalIdentifier |
| profile = profileService.load(sessionProfile.getItemId()); |
| } |
| |
| // Handle anonymous situation |
| Boolean requireAnonymousBrowsing = privacyService.isRequireAnonymousBrowsing(profile); |
| if (requireAnonymousBrowsing && anonymousSessionProfile) { |
| // User wants to browse anonymously, anonymous profile is already set. |
| } else if (requireAnonymousBrowsing && !anonymousSessionProfile) { |
| // User wants to browse anonymously, update the sessionProfile to anonymous profile |
| sessionProfile = privacyService.getAnonymousProfile(profile); |
| session.setProfile(sessionProfile); |
| changes |= EventService.SESSION_UPDATED; |
| } else if (!requireAnonymousBrowsing && anonymousSessionProfile) { |
| // User does not want to browse anonymously anymore, update the sessionProfile to real profile |
| sessionProfile = profile; |
| session.setProfile(sessionProfile); |
| changes |= EventService.SESSION_UPDATED; |
| } else if (!requireAnonymousBrowsing && !anonymousSessionProfile) { |
| // User does not want to browse anonymously, use the real profile. Check that session contains the current profile. |
| sessionProfile = profile; |
| if (!session.getProfileId().equals(sessionProfile.getItemId())) { |
| changes |= EventService.SESSION_UPDATED; |
| } |
| session.setProfile(sessionProfile); |
| } |
| } |
| } |
| |
| if (session == null || invalidateSession) { |
| sessionProfile = privacyService.isRequireAnonymousBrowsing(profile) ? privacyService.getAnonymousProfile(profile) : profile; |
| |
| if (StringUtils.isNotBlank(sessionId)) { |
| // Only save session and send event if a session id was provided, otherwise keep transient session |
| session = new Session(sessionId, sessionProfile, timestamp, scope); |
| changes |= EventService.SESSION_UPDATED; |
| Event event = new Event("sessionCreated", session, profile, scope, null, session, timestamp); |
| if (sessionProfile.isAnonymousProfile()) { |
| // Do not keep track of profile in event |
| event.setProfileId(null); |
| } |
| event.getAttributes().put(Event.HTTP_REQUEST_ATTRIBUTE, request); |
| event.getAttributes().put(Event.HTTP_RESPONSE_ATTRIBUTE, response); |
| if (logger.isDebugEnabled()) { |
| logger.debug("Received event {} for profile={} session={} target={} timestamp={}", event.getEventType(), |
| profile.getItemId(), session.getItemId(), event.getTarget(), timestamp); |
| } |
| changes |= eventService.send(event); |
| } |
| } |
| |
| if (profileCreated) { |
| changes |= EventService.PROFILE_UPDATED; |
| |
| Event profileUpdated = new Event("profileUpdated", session, profile, scope, null, profile, timestamp); |
| profileUpdated.setPersistent(false); |
| profileUpdated.getAttributes().put(Event.HTTP_REQUEST_ATTRIBUTE, request); |
| profileUpdated.getAttributes().put(Event.HTTP_RESPONSE_ATTRIBUTE, response); |
| profileUpdated.getAttributes().put(Event.CLIENT_ID_ATTRIBUTE, DEFAULT_CLIENT_ID); |
| |
| if (logger.isDebugEnabled()) { |
| logger.debug("Received event {} for profile={} {} target={} timestamp={}", profileUpdated.getEventType(), |
| profile.getItemId(), " session=" + (session != null ? session.getItemId() : null), profileUpdated.getTarget(), |
| timestamp); |
| } |
| changes |= eventService.send(profileUpdated); |
| } |
| } |
| |
| ContextResponse contextResponse = new ContextResponse(); |
| contextResponse.setProfileId(profile.getItemId()); |
| if (session != null) { |
| contextResponse.setSessionId(session.getItemId()); |
| } else if (sessionId != null) { |
| contextResponse.setSessionId(sessionId); |
| } |
| |
| if (contextRequest != null) { |
| Changes changesObject = handleRequest(contextRequest, session, profile, contextResponse, request, response, timestamp); |
| changes |= changesObject.getChangeType(); |
| profile = changesObject.getProfile(); |
| } |
| |
| if ((changes & EventService.PROFILE_UPDATED) == EventService.PROFILE_UPDATED) { |
| profileService.save(profile); |
| contextResponse.setProfileId(profile.getItemId()); |
| |
| if (profileCreated) { |
| String clientId = contextRequest != null && contextRequest.getClientId() != null ? contextRequest.getClientId() : DEFAULT_CLIENT_ID; |
| String profileMasterId = profile.getMergedWith() != null ? profile.getMergedWith() : profile.getItemId(); |
| profileService.addAliasToProfile(profileMasterId, profile.getItemId(), clientId ); |
| } |
| } |
| if ((changes & EventService.SESSION_UPDATED) == EventService.SESSION_UPDATED && session != null) { |
| profileService.saveSession(session); |
| contextResponse.setSessionId(session.getItemId()); |
| } |
| |
| if ((changes & EventService.ERROR) == EventService.ERROR) { |
| response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); |
| } |
| // Set profile cookie |
| if (!(profile instanceof Persona)) { |
| response.setHeader("Set-Cookie", HttpUtils.getProfileCookieString(profile, configSharingService, request.isSecure())); |
| } |
| return contextResponse; |
| } |
| |
| private Changes checkMergedProfile(Profile profile, Session session) { |
| int changes = EventService.NO_CHANGE; |
| if (profile.getMergedWith() != null && !privacyService.isRequireAnonymousBrowsing(profile) && !profile.isAnonymousProfile()) { |
| Profile currentProfile = profile; |
| String masterProfileId = profile.getMergedWith(); |
| Profile masterProfile = profileService.load(masterProfileId); |
| if (masterProfile != null) { |
| logger.info("Current profile {} was merged with profile {}, replacing profile in session", currentProfile.getItemId(), |
| masterProfileId); |
| profile = masterProfile; |
| if (session != null) { |
| session.setProfile(profile); |
| changes = EventService.SESSION_UPDATED; |
| } |
| } else { |
| logger.warn("Couldn't find merged profile {}, falling back to profile {}", masterProfileId, currentProfile.getItemId()); |
| profile.setMergedWith(null); |
| changes = EventService.PROFILE_UPDATED; |
| } |
| } |
| |
| return new Changes(changes, profile); |
| } |
| |
| private Changes handleRequest(ContextRequest contextRequest, Session session, Profile profile, ContextResponse data, |
| ServletRequest request, ServletResponse response, Date timestamp) { |
| |
| processOverrides(contextRequest, profile, session); |
| |
| Changes changes = restServiceUtils.handleEvents(contextRequest.getEvents(), session, profile, request, response, timestamp); |
| data.setProcessedEvents(changes.getProcessedItems()); |
| |
| List<PersonalizationService.PersonalizedContent> filterNodes = contextRequest.getFilters(); |
| if (filterNodes != null) { |
| data.setFilteringResults(new HashMap<>()); |
| for (PersonalizationService.PersonalizedContent personalizedContent : sanitizePersonalizedContentObjects(filterNodes)) { |
| data.getFilteringResults() |
| .put(personalizedContent.getId(), personalizationService.filter(profile, session, personalizedContent)); |
| } |
| } |
| |
| List<PersonalizationService.PersonalizationRequest> personalizations = contextRequest.getPersonalizations(); |
| if (personalizations != null) { |
| data.setPersonalizations(new HashMap<>()); |
| for (PersonalizationService.PersonalizationRequest personalization : sanitizePersonalizations(personalizations)) { |
| PersonalizationResult personalizationResult = personalizationService.personalizeList(profile, session, personalization); |
| changes.setChangeType(changes.getChangeType() | personalizationResult.getChangeType()); |
| data.getPersonalizations() |
| .put(personalization.getId(), personalizationResult.getContentIds()); |
| } |
| } |
| |
| profile = changes.getProfile(); |
| |
| if (contextRequest.isRequireSegments()) { |
| data.setProfileSegments(profile.getSegments()); |
| } |
| if (contextRequest.isRequireScores()) { |
| data.setProfileScores(profile.getScores()); |
| } |
| |
| if (contextRequest.getRequiredProfileProperties() != null) { |
| Map<String, Object> profileProperties = new HashMap<>(profile.getProperties()); |
| if (!contextRequest.getRequiredProfileProperties().contains("*")) { |
| profileProperties.keySet().retainAll(contextRequest.getRequiredProfileProperties()); |
| } |
| data.setProfileProperties(profileProperties); |
| } |
| |
| if (session != null) { |
| data.setSessionId(session.getItemId()); |
| if (contextRequest.getRequiredSessionProperties() != null) { |
| Map<String, Object> sessionProperties = new HashMap<>(session.getProperties()); |
| if (!contextRequest.getRequiredSessionProperties().contains("*")) { |
| sessionProperties.keySet().retainAll(contextRequest.getRequiredSessionProperties()); |
| } |
| data.setSessionProperties(sessionProperties); |
| } |
| } |
| |
| if (!(profile instanceof Persona)) { |
| data.setTrackedConditions(rulesService.getTrackedConditions(contextRequest.getSource())); |
| } else { |
| data.setTrackedConditions(Collections.emptySet()); |
| } |
| |
| data.setAnonymousBrowsing(privacyService.isRequireAnonymousBrowsing(profile)); |
| data.setConsents(profile.getConsents()); |
| |
| return changes; |
| } |
| |
| /** |
| * This function will update the profile if it is from Persona instance. |
| * The profile will be updated using the overrides attributes : |
| * - profileOverrides for profile properties, segments and scores |
| * - sessionPropertiesOverrides for session properties |
| * |
| * @param contextRequest |
| * @param profile |
| * @param session |
| */ |
| private void processOverrides(ContextRequest contextRequest, Profile profile, Session session) { |
| if (profile instanceof Persona && contextRequest.getProfileOverrides() != null) { |
| if (contextRequest.getProfileOverrides().getScores() != null) { |
| profile.setScores(contextRequest.getProfileOverrides().getScores()); |
| } |
| if (contextRequest.getProfileOverrides().getSegments() != null) { |
| profile.setSegments(contextRequest.getProfileOverrides().getSegments()); |
| } |
| if (contextRequest.getProfileOverrides().getProperties() != null) { |
| profile.setProperties(contextRequest.getProfileOverrides().getProperties()); |
| } |
| if (contextRequest.getSessionPropertiesOverrides() != null && session != null) { |
| session.setProperties(contextRequest.getSessionPropertiesOverrides()); |
| } |
| } |
| } |
| |
| private Profile createNewProfile(String existingProfileId, Date timestamp) { |
| Profile profile; |
| String profileId = existingProfileId; |
| if (profileId == null) { |
| profileId = UUID.randomUUID().toString(); |
| } |
| profile = new Profile(profileId); |
| profile.setProperty("firstVisit", timestamp); |
| return profile; |
| } |
| |
| public void destroy() { |
| logger.info("Context servlet shutdown."); |
| } |
| |
| private List<PersonalizationService.PersonalizedContent> sanitizePersonalizedContentObjects( |
| List<PersonalizationService.PersonalizedContent> personalizedContentObjects) { |
| if (!sanitizeConditions) { |
| return personalizedContentObjects; |
| } |
| List<PersonalizationService.PersonalizedContent> result = new ArrayList<>(); |
| for (PersonalizationService.PersonalizedContent personalizedContentObject : personalizedContentObjects) { |
| boolean foundInvalidCondition = false; |
| if (personalizedContentObject.getFilters() != null) { |
| for (PersonalizationService.Filter filter : personalizedContentObject.getFilters()) { |
| if (sanitizeCondition(filter.getCondition()) == null) { |
| foundInvalidCondition = true; |
| break; |
| } |
| } |
| } |
| if (!foundInvalidCondition) { |
| result.add(personalizedContentObject); |
| } |
| } |
| |
| return result; |
| } |
| |
| private List<PersonalizationService.PersonalizationRequest> sanitizePersonalizations( |
| List<PersonalizationService.PersonalizationRequest> personalizations) { |
| if (!sanitizeConditions) { |
| return personalizations; |
| } |
| List<PersonalizationService.PersonalizationRequest> result = new ArrayList<>(); |
| for (PersonalizationService.PersonalizationRequest personalizationRequest : personalizations) { |
| List<PersonalizationService.PersonalizedContent> personalizedContents = sanitizePersonalizedContentObjects( |
| personalizationRequest.getContents()); |
| if (personalizedContents != null && !personalizedContents.isEmpty()) { |
| result.add(personalizationRequest); |
| } |
| } |
| return result; |
| } |
| |
| private Condition sanitizeCondition(Condition condition) { |
| Map<String, Object> newParameterValues = new LinkedHashMap<>(); |
| for (Map.Entry<String, Object> parameterEntry : condition.getParameterValues().entrySet()) { |
| Object sanitizedValue = sanitizeValue(parameterEntry.getValue()); |
| if (sanitizedValue != null) { |
| newParameterValues.put(parameterEntry.getKey(), parameterEntry.getValue()); |
| } else { |
| return null; |
| } |
| } |
| return condition; |
| } |
| |
| private Object sanitizeValue(Object value) { |
| if (value instanceof String) { |
| String stringValue = (String) value; |
| if (stringValue.startsWith("script::") || stringValue.startsWith("parameter::")) { |
| logger.warn("Scripting detected in context request, filtering out. See debug level for more information"); |
| if (logger.isDebugEnabled()) { |
| logger.debug("Scripting detected in context request with value {}, filtering out...", value); |
| } |
| return null; |
| } else { |
| return stringValue; |
| } |
| } else if (value instanceof List) { |
| List values = (List) value; |
| List newValues = new ArrayList(); |
| for (Object listObject : values) { |
| Object newObject = sanitizeValue(listObject); |
| if (newObject != null) { |
| newValues.add(newObject); |
| } |
| } |
| return values; |
| } else if (value instanceof Map) { |
| Map<Object, Object> newMap = new LinkedHashMap<>(); |
| ((Map<?, ?>) value).forEach((key, value1) -> { |
| Object newObject = sanitizeValue(value1); |
| if (newObject != null) { |
| newMap.put(key, newObject); |
| } |
| }); |
| return newMap; |
| } else if (value instanceof Condition) { |
| return sanitizeCondition((Condition) value); |
| } else { |
| return value; |
| } |
| } |
| |
| } |