View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.usertracking.endpoint;
23  
24  import static javax.servlet.http.HttpServletResponse.SC_CREATED;
25  import static javax.servlet.http.HttpServletResponse.SC_OK;
26  
27  import org.opencastproject.rest.RestConstants;
28  import org.opencastproject.security.api.SecurityService;
29  import org.opencastproject.systems.OpencastConstants;
30  import org.opencastproject.usertracking.api.UserSession;
31  import org.opencastproject.usertracking.api.UserTrackingException;
32  import org.opencastproject.usertracking.api.UserTrackingService;
33  import org.opencastproject.usertracking.impl.UserActionImpl;
34  import org.opencastproject.usertracking.impl.UserActionListImpl;
35  import org.opencastproject.usertracking.impl.UserSessionImpl;
36  import org.opencastproject.util.NotFoundException;
37  import org.opencastproject.util.UrlSupport;
38  import org.opencastproject.util.doc.rest.RestParameter;
39  import org.opencastproject.util.doc.rest.RestParameter.Type;
40  import org.opencastproject.util.doc.rest.RestQuery;
41  import org.opencastproject.util.doc.rest.RestResponse;
42  import org.opencastproject.util.doc.rest.RestService;
43  
44  import org.apache.commons.lang3.StringUtils;
45  import org.osgi.service.component.ComponentContext;
46  import org.osgi.service.component.annotations.Activate;
47  import org.osgi.service.component.annotations.Component;
48  import org.osgi.service.component.annotations.Reference;
49  import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
50  import org.slf4j.Logger;
51  import org.slf4j.LoggerFactory;
52  
53  import java.net.URI;
54  import java.net.URISyntaxException;
55  import java.text.ParseException;
56  
57  import javax.servlet.http.HttpServletRequest;
58  import javax.ws.rs.FormParam;
59  import javax.ws.rs.GET;
60  import javax.ws.rs.PUT;
61  import javax.ws.rs.Path;
62  import javax.ws.rs.PathParam;
63  import javax.ws.rs.Produces;
64  import javax.ws.rs.QueryParam;
65  import javax.ws.rs.WebApplicationException;
66  import javax.ws.rs.core.Context;
67  import javax.ws.rs.core.MediaType;
68  import javax.ws.rs.core.Response;
69  import javax.ws.rs.core.Response.Status;
70  
71  /**
72   * REST Endpoint for User Tracking Service
73   */
74  @Path("/usertracking")
75  @RestService(name = "usertracking", title = "User Tracking Service", abstractText = "This service is used for tracking user interaction creates, edits and retrieves user actions and "
76          + "viewing statistics.", notes = {
77          "All paths above are relative to the REST endpoint base (something like http://your.server/files)",
78          "If the service is down or not working it will return a status 503, this means the the underlying service is "
79                  + "not working and is either restarting or has failed",
80          "A status code 500 means a general failure has occurred which is not recoverable and was not anticipated. In "
81                  + "other words, there is a bug! You should file an error report with your server logs from the time when the "
82                  + "error occurred: <a href=\"https://github.com/opencast/opencast/issues\">Opencast Issue Tracker</a>" })
83  @Component(
84      immediate = true,
85      service = UserTrackingRestService.class,
86      property = {
87          "service.description=User Tracking REST Endpoint",
88          "opencast.service.type=org.opencastproject.usertracking",
89          "opencast.service.path=/usertracking"
90      }
91  )
92  @JaxrsResource
93  public class UserTrackingRestService {
94  
95    private static final Logger logger = LoggerFactory.getLogger(UserTrackingRestService.class);
96  
97    private UserTrackingService usertrackingService;
98  
99    protected SecurityService securityService;
100 
101   protected String serverUrl = UrlSupport.DEFAULT_BASE_URL;
102 
103   protected String serviceUrl = "/usertracking"; // set this to the default value initially
104 
105   /**
106    * Method to set the service this REST endpoint uses
107    *
108    * @param service
109    */
110   @Reference
111   public void setService(UserTrackingService service) {
112     this.usertrackingService = service;
113   }
114 
115   /**
116    * Sets the security service
117    *
118    * @param securityService
119    *          the securityService to set
120    */
121   @Reference
122   public void setSecurityService(SecurityService securityService) {
123     this.securityService = securityService;
124   }
125 
126   /**
127    * The method that is called, when the service is activated
128    *
129    * @param cc
130    *          The ComponentContext of this service
131    */
132   @Activate
133   public void activate(ComponentContext cc) {
134     // Get the configured server URL
135     if (cc == null) {
136       serverUrl = UrlSupport.DEFAULT_BASE_URL;
137     } else {
138       String ccServerUrl = cc.getBundleContext().getProperty(OpencastConstants.SERVER_URL_PROPERTY);
139       logger.info("configured server url is {}", ccServerUrl);
140       if (ccServerUrl == null) {
141         serverUrl = UrlSupport.DEFAULT_BASE_URL;
142       } else {
143         serverUrl = ccServerUrl;
144       }
145       serviceUrl = (String) cc.getProperties().get(RestConstants.SERVICE_PATH_PROPERTY);
146     }
147   }
148 
149   /**
150    * @return XML with all footprints
151    */
152   @GET
153   @Produces(MediaType.TEXT_XML)
154   @Path("/actions.xml")
155   @RestQuery(name = "actionsasxml", description = "Get user actions by type and day", returnDescription = "The user actions.", restParameters = {
156           @RestParameter(name = "type", description = "The type of the user action", isRequired = false, type = Type.STRING),
157           @RestParameter(name = "day", description = "The day of creation (format: YYYYMMDD)", isRequired = false, type = Type.STRING),
158           @RestParameter(name = "limit", description = "The maximum number of items to return per page", isRequired = false, type = Type.INTEGER),
159           @RestParameter(name = "offset", description = "The page number", isRequired = false, type = Type.INTEGER) }, responses = { @RestResponse(responseCode = SC_OK, description = "An XML representation of the user actions") })
160   public UserActionListImpl getUserActionsAsXml(@QueryParam("id") String id, @QueryParam("type") String type,
161           @QueryParam("day") String day, @QueryParam("limit") int limit, @QueryParam("offset") int offset) {
162 
163     // Are the values of offset and limit valid?
164     if (offset < 0 || limit < 0)
165       throw new WebApplicationException(Status.BAD_REQUEST);
166 
167     // Set default value of limit (max result value)
168     if (limit == 0)
169       limit = 10;
170     try {
171       if (!StringUtils.isEmpty(id) && !StringUtils.isEmpty(type))
172         return (UserActionListImpl) usertrackingService.getUserActionsByTypeAndMediapackageId(type, id, offset, limit);
173       else if (!StringUtils.isEmpty(type) && !StringUtils.isEmpty(day))
174         return (UserActionListImpl) usertrackingService.getUserActionsByTypeAndDay(type, day, offset, limit);
175       else if (!StringUtils.isEmpty(type))
176         return (UserActionListImpl) usertrackingService.getUserActionsByType(type, offset, limit);
177       else if (!StringUtils.isEmpty(day))
178         return (UserActionListImpl) usertrackingService.getUserActionsByDay(day, offset, limit);
179       else
180         return (UserActionListImpl) usertrackingService.getUserActions(offset, limit);
181     } catch (UserTrackingException e) {
182       throw new WebApplicationException(e);
183     }
184   }
185 
186   /**
187    * @return JSON with all footprints
188    */
189   @GET
190   @Produces(MediaType.APPLICATION_JSON)
191   @Path("/actions.json")
192   @RestQuery(name = "actionsasjson", description = "Get user actions by type and day", returnDescription = "The user actions.", restParameters = {
193           @RestParameter(name = "type", description = "The type of the user action", isRequired = false, type = Type.STRING),
194           @RestParameter(name = "day", description = "The day of creation (format: YYYYMMDD)", isRequired = false, type = Type.STRING),
195           @RestParameter(name = "limit", description = "The maximum number of items to return per page", isRequired = false, type = Type.INTEGER),
196           @RestParameter(name = "offset", description = "The page number", isRequired = false, type = Type.INTEGER) }, responses = { @RestResponse(responseCode = SC_OK, description = "A JSON representation of the user actions") })
197   public UserActionListImpl getUserActionsAsJson(@QueryParam("id") String id, @QueryParam("type") String type,
198           @QueryParam("day") String day, @QueryParam("limit") int limit, @QueryParam("offset") int offset) {
199     return getUserActionsAsXml(id, type, day, limit, offset); // same logic, different @Produces annotation
200   }
201 
202   @GET
203   @Produces(MediaType.TEXT_XML)
204   @Path("/stats.xml")
205   @RestQuery(name = "statsasxml", description = "Get the statistics for an episode", returnDescription = "The statistics.", restParameters = { @RestParameter(name = "id", description = "The ID of the single episode to return the statistics for, if it exists", isRequired = false, type = Type.STRING) }, responses = { @RestResponse(responseCode = SC_OK, description = "An XML representation of the episode's statistics") })
206   public StatsImpl statsAsXml(@QueryParam("id") String mediapackageId) {
207     StatsImpl s = new StatsImpl();
208     s.setMediapackageId(mediapackageId);
209     try {
210       s.setViews(usertrackingService.getViews(mediapackageId));
211     } catch (UserTrackingException e) {
212       throw new WebApplicationException(e);
213     }
214     return s;
215   }
216 
217   @GET
218   @Produces(MediaType.APPLICATION_JSON)
219   @Path("/stats.json")
220   @RestQuery(name = "statsasjson", description = "Get the statistics for an episode", returnDescription = "The statistics.", restParameters = { @RestParameter(name = "id", description = "The ID of the single episode to return the statistics for, if it exists", isRequired = false, type = Type.STRING) }, responses = { @RestResponse(responseCode = SC_OK, description = "A JSON representation of the episode's statistics") })
221   public StatsImpl statsAsJson(@QueryParam("id") String mediapackageId) {
222     return statsAsXml(mediapackageId); // same logic, different @Produces annotation
223   }
224 
225   @GET
226   @Produces(MediaType.TEXT_XML)
227   @Path("/report.xml")
228   @RestQuery(name = "reportasxml", description = "Get a report for a time range", returnDescription = "The report.", restParameters = {
229           @RestParameter(name = "from", description = "The beginning of the time range", isRequired = false, type = Type.STRING),
230           @RestParameter(name = "to", description = "The end of the time range", isRequired = false, type = Type.STRING),
231           @RestParameter(name = "limit", description = "The maximum number of items to return per page", isRequired = false, type = Type.INTEGER),
232           @RestParameter(name = "offset", description = "The page number", isRequired = false, type = Type.INTEGER) }, responses = { @RestResponse(responseCode = SC_OK, description = "An XML representation of the report") })
233   public ReportImpl reportAsXml(@QueryParam("from") String from, @QueryParam("to") String to,
234           @QueryParam("offset") int offset, @QueryParam("limit") int limit) {
235 
236     // Are the values of offset and limit valid?
237     if (offset < 0 || limit < 0)
238       throw new WebApplicationException(Status.BAD_REQUEST);
239 
240     // Set default value of limit (max result value)
241     if (limit == 0)
242       limit = 10;
243 
244     try {
245       if (from == null && to == null)
246         return (ReportImpl) usertrackingService.getReport(offset, limit);
247       else
248         return (ReportImpl) usertrackingService.getReport(from, to, offset, limit);
249     } catch (UserTrackingException e) {
250       throw new WebApplicationException(e);
251     } catch (ParseException e) {
252       throw new WebApplicationException(Status.BAD_REQUEST);
253     }
254   }
255 
256   @GET
257   @Produces(MediaType.APPLICATION_JSON)
258   @Path("/report.json")
259   @RestQuery(name = "reportasjson", description = "Get a report for a time range", returnDescription = "The report.", restParameters = {
260           @RestParameter(name = "from", description = "The beginning of the time range", isRequired = false, type = Type.STRING),
261           @RestParameter(name = "to", description = "The end of the time range", isRequired = false, type = Type.STRING),
262           @RestParameter(name = "limit", description = "The maximum number of items to return per page", isRequired = false, type = Type.INTEGER),
263           @RestParameter(name = "offset", description = "The page number", isRequired = false, type = Type.INTEGER) }, responses = { @RestResponse(responseCode = SC_OK, description = "A JSON representation of the report") })
264   public ReportImpl reportAsJson(@QueryParam("from") String from, @QueryParam("to") String to,
265           @QueryParam("offset") int offset, @QueryParam("limit") int limit) {
266     return reportAsXml(from, to, offset, limit); // same logic, different @Produces annotation
267   }
268 
269   @PUT
270   @Path("")
271   @Produces(MediaType.TEXT_XML)
272   @RestQuery(name = "add", description = "Record a user action", returnDescription = "An XML representation of the user action", restParameters = {
273           @RestParameter(name = "id", description = "The episode identifier", isRequired = true, type = Type.STRING),
274           @RestParameter(name = "type", description = "The episode identifier", isRequired = true, type = Type.STRING),
275           @RestParameter(name = "in", description = "The beginning of the time range", isRequired = true, type = Type.STRING),
276           @RestParameter(name = "out", description = "The end of the time range", isRequired = false, type = Type.STRING),
277           @RestParameter(name = "playing", description = "Whether the player is currently playing", isRequired = false, type = Type.STRING)}, responses = { @RestResponse(responseCode = SC_CREATED, description = "An XML representation of the user action") })
278   public Response addFootprint(@FormParam("id") String mediapackageId, @FormParam("in") String inString,
279           @FormParam("out") String outString, @FormParam("type") String type, @FormParam("playing") String isPlaying,
280           @Context HttpServletRequest request) {
281 
282     String sessionId = request.getSession().getId();
283     String userId = securityService.getUser().getUsername();
284 
285     // Parse the in and out strings, which might be empty (hence, we can't let jax-rs handle them properly)
286     if (StringUtils.isEmpty(inString)) {
287       throw new WebApplicationException(Response.status(Status.BAD_REQUEST).entity("in must be a non null integer")
288               .build());
289     }
290     Integer in = null;
291     try {
292       in = Integer.parseInt(StringUtils.trim(inString));
293     } catch (NumberFormatException e) {
294       throw new WebApplicationException(e, Response.status(Status.BAD_REQUEST).entity("in must be a non null integer").build());
295     }
296 
297     Integer out = null;
298     if (StringUtils.isEmpty(outString)) {
299       out = in;
300     } else {
301       try {
302         out = Integer.parseInt(StringUtils.trim(outString));
303       } catch (NumberFormatException e) {
304         throw new WebApplicationException(e, Response.status(Status.BAD_REQUEST).entity("out must be a non null integer").build());
305       }
306     }
307 
308     //MH-8616 the connection might be via a proxy
309     String clientIP = request.getHeader("X-FORWARDED-FOR");
310 
311     if (clientIP == null) {
312       clientIP = request.getRemoteAddr();
313     }
314     logger.debug("Got client ip: {}", clientIP);
315 
316     UserSession s = new UserSessionImpl();
317     s.setSessionId(sessionId);
318     s.setUserIp(clientIP);
319     s.setUserId(userId);
320     //Column length is currently 255, let's limit it to that.
321     String userAgent = StringUtils.trimToNull(request.getHeader("User-Agent"));
322     if (userAgent != null && userAgent.length() > 255) {
323       s.setUserAgent(userAgent.substring(0, 255));
324     } else {
325       s.setUserAgent(userAgent);
326     }
327 
328     UserActionImpl a = new UserActionImpl();
329     a.setMediapackageId(mediapackageId);
330     a.setSession(s);
331     a.setInpoint(in);
332     a.setOutpoint(out);
333     a.setType(type);
334     a.setIsPlaying(Boolean.valueOf(isPlaying));
335 
336     try {
337       if ("FOOTPRINT".equals(type)) {
338         a = (UserActionImpl) usertrackingService.addUserFootprint(a, s);
339       } else {
340         a = (UserActionImpl) usertrackingService.addUserTrackingEvent(a, s);
341       }
342     } catch (UserTrackingException e) {
343       throw new WebApplicationException(e);
344     }
345 
346     URI uri;
347     try {
348       uri = new URI(UrlSupport.concat(new String[] { serverUrl, serviceUrl, "action", a.getId().toString(), ".xml" }));
349     } catch (URISyntaxException e) {
350       throw new WebApplicationException(e);
351     }
352     return Response.created(uri).entity(a).build();
353   }
354 
355   @GET
356   @Produces(MediaType.TEXT_XML)
357   @Path("/action/{id}.xml")
358   @RestQuery(name = "add", description = "Record a user action", returnDescription = "An XML representation of the user action", pathParameters = { @RestParameter(name = "id", description = "The episode identifier", isRequired = true, type = Type.STRING) }, responses = { @RestResponse(responseCode = SC_OK, description = "An XML representation of the user action") })
359   public UserActionImpl getActionAsXml(@PathParam("id") String actionId) {
360     Long id = null;
361     try {
362       id = Long.parseLong(actionId);
363     } catch (NumberFormatException e) {
364       throw new WebApplicationException(e);
365     }
366     try {
367       return (UserActionImpl) usertrackingService.getUserAction(id);
368     } catch (UserTrackingException e) {
369       throw new WebApplicationException(e);
370     } catch (NotFoundException e) {
371       return null;
372     }
373   }
374 
375   @GET
376   @Produces(MediaType.APPLICATION_JSON)
377   @Path("/action/{id}.json")
378   @RestQuery(name = "add", description = "Record a user action", returnDescription = "A JSON representation of the user action", pathParameters = { @RestParameter(name = "id", description = "The episode identifier", isRequired = true, type = Type.STRING) }, responses = { @RestResponse(responseCode = SC_OK, description = "A JSON representation of the user action") })
379   public UserActionImpl getActionAsJson(@PathParam("id") String actionId) {
380     return getActionAsXml(actionId);
381   }
382 
383   @GET
384   @Produces(MediaType.TEXT_XML)
385   @Path("/footprint.xml")
386   @RestQuery(name = "footprintasxml", description = "Gets the 'footprint' action for an episode", returnDescription = "An XML representation of the footprints", restParameters = { @RestParameter(name = "id", description = "The episode identifier", isRequired = false, type = Type.STRING) }, responses = { @RestResponse(responseCode = SC_OK, description = "An XML representation of the footprints") })
387   public FootprintsListImpl getFootprintAsXml(@QueryParam("id") String mediapackageId) {
388     String userId = securityService.getUser().getUsername();
389 
390     // Is the mediapackageId passed
391     if (mediapackageId == null)
392       throw new WebApplicationException(Status.BAD_REQUEST);
393 
394     try {
395       return (FootprintsListImpl) usertrackingService.getFootprints(mediapackageId, userId);
396     } catch (UserTrackingException e) {
397       throw new WebApplicationException(e);
398     }
399   }
400 
401   @GET
402   @Produces(MediaType.APPLICATION_JSON)
403   @Path("/footprint.json")
404   @RestQuery(name = "footprintasxml", description = "Gets the 'footprint' action for an episode", returnDescription = "A JSON representation of the footprints", restParameters = { @RestParameter(name = "id", description = "The episode identifier", isRequired = false, type = Type.STRING) }, responses = { @RestResponse(responseCode = SC_OK, description = "A JSON representation of the footprints") })
405   public FootprintsListImpl getFootprintAsJson(@QueryParam("id") String mediapackageId) {
406     return getFootprintAsXml(mediapackageId); // this is the same logic... it's just annotated differently
407   }
408 
409   @GET
410   @Produces(MediaType.TEXT_PLAIN)
411   @Path("/detailenabled")
412   public Response getUserTrackingEnabled() {
413     return Response.ok(usertrackingService.getUserTrackingEnabled()).build();
414   }
415 }