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.search.endpoint;
23  
24  import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
25  import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
26  import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
27  import static org.opencastproject.util.RestUtil.R.forbidden;
28  import static org.opencastproject.util.RestUtil.R.noContent;
29  import static org.opencastproject.util.RestUtil.R.notFound;
30  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
31  
32  import org.opencastproject.job.api.JobProducer;
33  import org.opencastproject.metadata.dublincore.DublinCore;
34  import org.opencastproject.rest.AbstractJobProducerEndpoint;
35  import org.opencastproject.search.api.SearchException;
36  import org.opencastproject.search.api.SearchResult;
37  import org.opencastproject.search.api.SearchResultList;
38  import org.opencastproject.search.api.SearchService;
39  import org.opencastproject.search.impl.SearchServiceImpl;
40  import org.opencastproject.search.impl.SearchServiceIndex;
41  import org.opencastproject.security.api.Role;
42  import org.opencastproject.security.api.SecurityConstants;
43  import org.opencastproject.security.api.SecurityService;
44  import org.opencastproject.security.api.UnauthorizedException;
45  import org.opencastproject.security.urlsigning.exception.UrlSigningException;
46  import org.opencastproject.security.urlsigning.service.UrlSigningService;
47  import org.opencastproject.security.urlsigning.utils.UrlSigningServiceOsgiUtil;
48  import org.opencastproject.serviceregistry.api.ServiceRegistry;
49  import org.opencastproject.util.NotFoundException;
50  import org.opencastproject.util.doc.rest.RestParameter;
51  import org.opencastproject.util.doc.rest.RestQuery;
52  import org.opencastproject.util.doc.rest.RestResponse;
53  import org.opencastproject.util.doc.rest.RestService;
54  
55  import com.google.gson.Gson;
56  
57  import org.apache.commons.lang3.StringUtils;
58  import org.apache.commons.lang3.math.NumberUtils;
59  import org.elasticsearch.index.query.Operator;
60  import org.elasticsearch.index.query.QueryBuilders;
61  import org.elasticsearch.search.SearchHit;
62  import org.elasticsearch.search.builder.SearchSourceBuilder;
63  import org.elasticsearch.search.sort.SortOrder;
64  import org.osgi.service.component.annotations.Component;
65  import org.osgi.service.component.annotations.Reference;
66  import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
67  import org.slf4j.Logger;
68  import org.slf4j.LoggerFactory;
69  
70  import java.util.Arrays;
71  import java.util.Collections;
72  import java.util.List;
73  import java.util.Map;
74  import java.util.stream.Collectors;
75  
76  import javax.servlet.http.HttpServletResponse;
77  import javax.ws.rs.FormParam;
78  import javax.ws.rs.GET;
79  import javax.ws.rs.POST;
80  import javax.ws.rs.Path;
81  import javax.ws.rs.Produces;
82  import javax.ws.rs.QueryParam;
83  import javax.ws.rs.WebApplicationException;
84  import javax.ws.rs.core.MediaType;
85  import javax.ws.rs.core.Response;
86  
87  /**
88   * The REST endpoint
89   */
90  @Path("/search")
91  @RestService(
92      name = "search",
93      title = "Search Service",
94      abstractText = "This service indexes and queries available (distributed) episodes.",
95      notes = {
96          "All paths above are relative to the REST endpoint base (something like http://your.server/files)",
97          "If the service is down or not working it will return a status 503, this means the the "
98              + "underlying service is not working and is either restarting or has failed",
99          "A status code 500 means a general failure has occurred which is not recoverable and was "
100             + "not anticipated. In other words, there is a bug! You should file an error report "
101             + "with your server logs from the time when the error occurred: "
102             + "<a href=\"https://github.com/opencast/opencast/issues\">Opencast Issue Tracker</a>"
103     }
104 )
105 @Component(
106     immediate = true,
107     service = SearchRestService.class,
108     property = {
109         "service.description=Search REST Endpoint",
110         "opencast.service.type=org.opencastproject.search",
111         "opencast.service.path=/search",
112         "opencast.service.jobproducer=true"
113     }
114 )
115 @JaxrsResource
116 public class SearchRestService extends AbstractJobProducerEndpoint {
117 
118   private static final Logger logger = LoggerFactory.getLogger(SearchRestService.class);
119 
120   /** The search service which talks to the database.  Only needed for the JobProducer bits. */
121   protected SearchServiceImpl searchService;
122 
123   /** The connector to the actual index */
124   protected SearchServiceIndex searchIndex;
125 
126   /** The service registry */
127   private ServiceRegistry serviceRegistry;
128 
129   private SecurityService securityService;
130 
131   private final Gson gson = new Gson();
132 
133   private UrlSigningService urlSigningService;
134 
135   @GET
136   @Path("series.json")
137   @Produces(MediaType.APPLICATION_JSON)
138   @RestQuery(
139       name = "get_series",
140       description = "Search for series matching the query parameters.",
141       restParameters = {
142           @RestParameter(
143               name = "id",
144               isRequired = false,
145               type = RestParameter.Type.STRING,
146               description = "The series ID. If the additional boolean parameter \"episodes\" is \"true\", "
147                   + "the result set will include this series episodes."
148           ),
149           @RestParameter(
150               name = "q",
151               isRequired = false,
152               type = RestParameter.Type.STRING,
153               description = "Any series that matches this free-text query."
154           ),
155           @RestParameter(
156               name = "sort",
157               isRequired = false,
158               type = RestParameter.Type.STRING,
159               description = "The sort order.  May include any of the following dublin core metadata: "
160               + "identifier, title, contributor, creator, created, modified. "
161               + "Add ' asc' or ' desc' to specify the sort order (e.g. 'title desc')."
162           ),
163           @RestParameter(
164               name = "limit",
165               isRequired = false,
166               type = RestParameter.Type.INTEGER,
167               defaultValue = "20",
168               description = "The maximum number of items to return per page."
169           ),
170           @RestParameter(
171               name = "offset",
172               isRequired = false,
173               type = RestParameter.Type.INTEGER,
174               defaultValue = "0",
175               description = "The page number."
176           )
177       },
178       responses = {
179           @RestResponse(
180               description = "The request was processed successfully.",
181               responseCode = HttpServletResponse.SC_OK
182           )
183       },
184       returnDescription = "The search results, formatted as XML or JSON."
185   )
186   public Response getSeries(
187       @QueryParam("id")       String  id,
188       @QueryParam("q")        String  text,
189       @QueryParam("sort")     String  sort,
190       @QueryParam("limit")    String  limit,
191       @QueryParam("offset")   String  offset
192   ) throws SearchException {
193 
194     final var org = securityService.getOrganization().getId();
195     final var type = SearchService.IndexEntryType.Series.name();
196     final var query = QueryBuilders.boolQuery()
197         .must(QueryBuilders.termQuery(SearchResult.ORG, org))
198         .must(QueryBuilders.termQuery(SearchResult.TYPE, type))
199         .mustNot(QueryBuilders.existsQuery(SearchResult.DELETED_DATE));
200 
201     if (StringUtils.isNotEmpty(id)) {
202       query.must(QueryBuilders.idsQuery().addIds(id));
203     }
204 
205     if (StringUtils.isNotEmpty(text)) {
206       query.must(QueryBuilders.wildcardQuery("fulltext", "*" + text.toLowerCase() + "*"));
207     }
208 
209     var user = securityService.getUser();
210     var orgAdminRole = securityService.getOrganization().getAdminRole();
211     if (!user.hasRole(SecurityConstants.GLOBAL_ADMIN_ROLE) && !user.hasRole(orgAdminRole)) {
212       query.must(QueryBuilders.termsQuery(
213               SearchResult.INDEX_ACL + ".read",
214               user.getRoles().stream().map(Role::getName).collect(Collectors.toList())
215       ));
216     }
217 
218     var size = NumberUtils.toInt(limit, 20);
219     var from = NumberUtils.toInt(offset);
220     if (size < 0 || from < 0) {
221       return Response.status(Response.Status.BAD_REQUEST)
222           .entity("Limit and offset may not be negative.")
223           .build();
224     }
225     var searchSource = new SearchSourceBuilder()
226         .query(query)
227         .from(from)
228         .size(size);
229 
230     if (StringUtils.isNotEmpty(sort)) {
231       var sortParam = StringUtils.split(sort.toLowerCase());
232       var validSort = Arrays.asList("identifier", "title", "contributor", "creator", "created", "modified")
233               .contains(sortParam[0]);
234       var validOrder = sortParam.length < 2 || Arrays.asList("asc", "desc").contains(sortParam[1]);
235       if (sortParam.length > 2 || !validSort || !validOrder) {
236         return Response.status(Response.Status.BAD_REQUEST)
237             .entity("Invalid sort parameter")
238             .build();
239       }
240       var order = SortOrder.fromString(sortParam.length > 1 ? sortParam[1] : "asc");
241       if ("modified".equals(sortParam[0])) {
242         searchSource.sort(sortParam[0], order);
243       } else {
244         searchSource.sort(SearchResult.DUBLINCORE + "." + sortParam[0], order);
245       }
246     }
247 
248     var hits = searchIndex.search(searchSource).getHits();
249     var result = Arrays.stream(hits.getHits())
250         .map(SearchHit::getSourceAsMap)
251         .peek(hit -> hit.remove(SearchResult.TYPE))
252         .collect(Collectors.toList());
253 
254     var total = hits.getTotalHits().value;
255     var json = gson.toJsonTree(Map.of(
256         "offset", from,
257         "total", total,
258         "result", result,
259         "limit", size));
260     return Response.ok(gson.toJson(json)).build();
261 
262   }
263 
264   @GET
265   @Path("episode.json")
266   @Produces(MediaType.APPLICATION_JSON)
267   @RestQuery(
268       name = "search_episodes",
269       description = "Search for episodes matching the query parameters.",
270       restParameters = {
271           @RestParameter(
272               name = "id",
273               isRequired = false,
274               type = RestParameter.Type.STRING,
275               description = "The ID of the single episode to be returned, if it exists."
276           ),
277           @RestParameter(
278               name = "q",
279               isRequired = false,
280               type = RestParameter.Type.STRING,
281               description = "Any episode that matches this free-text query."
282           ),
283           @RestParameter(
284               name = "sid",
285               isRequired = false,
286               type = RestParameter.Type.STRING,
287               description = "Any episode that belongs to specified series id."
288           ),
289           @RestParameter(
290               name = "sname",
291               isRequired = false,
292               type = RestParameter.Type.STRING,
293               description = "Any episode that belongs to specified series name (note that the "
294                   + "specified series name must be unique)."
295           ),
296           @RestParameter(
297               name = "sort",
298               isRequired = false,
299               type = RestParameter.Type.STRING,
300               description = "The sort order.  May include any of the following dublin core metadata: "
301                   + "title, contributor, creator, created, modified. "
302                   + "Add ' asc' or ' desc' to specify the sort order (e.g. 'title desc')."
303           ),
304           @RestParameter(
305               name = "limit",
306               isRequired = false,
307               type = RestParameter.Type.INTEGER,
308               defaultValue = "20",
309               description = "The maximum number of items to return per page. Limited to 250 for non-admins."
310           ),
311           @RestParameter(
312               name = "offset",
313               isRequired = false,
314               type = RestParameter.Type.INTEGER,
315               defaultValue = "0",
316               description = "The page number."
317           ),
318           @RestParameter(
319               name = "sign",
320               isRequired = false,
321               type = RestParameter.Type.BOOLEAN,
322               defaultValue = "true",
323               description = "If results are to be signed"
324           ),
325           @RestParameter(
326               name = "live",
327               isRequired = false,
328               type = RestParameter.Type.BOOLEAN,
329               description = "If the result should only consist of live episodes (true) or not live episodes (false)"
330           )
331       },
332       responses = {
333           @RestResponse(
334               description = "The request was processed successfully.",
335               responseCode = HttpServletResponse.SC_OK
336           )
337       },
338       returnDescription = "The search results, formatted as xml or json."
339   )
340   public Response getEpisodes(
341       @QueryParam("id") String id,
342       @QueryParam("q") String text,
343       @QueryParam("sid") String seriesId,
344       @QueryParam("sname") String seriesName,
345       @QueryParam("sort") String sort,
346       @QueryParam("limit") String limit,
347       @QueryParam("offset") String offset,
348       @QueryParam("sign") String sign,
349       @QueryParam("live") Boolean live
350   ) throws SearchException {
351 
352     // There can only be one, sid or sname
353     if (StringUtils.isNoneEmpty(seriesName, seriesId)) {
354       return Response.status(Response.Status.BAD_REQUEST)
355           .entity("invalid request, both 'sid' and 'sname' specified")
356           .build();
357     }
358     final var org = securityService.getOrganization().getId();
359     final var type = SearchService.IndexEntryType.Episode.name();
360 
361     boolean snameNotFound = false;
362     List<String> series = Collections.emptyList();
363     if (StringUtils.isNotEmpty(seriesName)) {
364       var seriesSearchSource = new SearchSourceBuilder().query(QueryBuilders.boolQuery()
365           .filter(QueryBuilders.termQuery(SearchResult.ORG, org))
366           .filter(QueryBuilders.termQuery(SearchResult.TYPE, SearchService.IndexEntryType.Series))
367           .filter(QueryBuilders.termQuery(SearchResult.DUBLINCORE + ".title", seriesName))
368           .mustNot(QueryBuilders.existsQuery(SearchResult.DELETED_DATE)));
369       series = searchService.search(seriesSearchSource).getHits().stream()
370           .map(h -> h.getDublinCore().getFirst(DublinCore.PROPERTY_IDENTIFIER))
371           .collect(Collectors.toList());
372       //If there is no series matching the sname provided
373       if (series.isEmpty()) {
374         snameNotFound = true;
375       }
376     }
377 
378     var query = QueryBuilders.boolQuery()
379         .filter(QueryBuilders.termQuery(SearchResult.ORG, org))
380         .filter(QueryBuilders.termQuery(SearchResult.TYPE, type))
381         .mustNot(QueryBuilders.existsQuery(SearchResult.DELETED_DATE));
382 
383     if (StringUtils.isNotEmpty(id)) {
384       query.filter(QueryBuilders.idsQuery().addIds(id));
385     }
386 
387     if (StringUtils.isNotEmpty(seriesId)) {
388       series = Collections.singletonList(seriesId);
389     }
390     if (!series.isEmpty()) {
391       if (series.size() == 1) {
392         query.must(QueryBuilders.termQuery(SearchResult.DUBLINCORE + ".isPartOf", series.get(0)));
393       } else {
394         var seriesQuery = QueryBuilders.boolQuery();
395         for (var sid : series) {
396           seriesQuery.should(QueryBuilders.termQuery(SearchResult.DUBLINCORE + ".isPartOf", sid));
397         }
398         query.must(seriesQuery);
399       }
400     }
401 
402     if (StringUtils.isNotEmpty(text)) {
403       query.minimumShouldMatch(1);
404       query.should(
405           QueryBuilders.matchQuery("fulltext", text)
406               .fuzziness("AUTO")
407               .operator(Operator.AND)
408       );
409     }
410 
411     if (live != null) {
412       query.filter(QueryBuilders.termQuery("live", live));
413     }
414 
415     var user = securityService.getUser();
416     var orgAdminRole = securityService.getOrganization().getAdminRole();
417     var admin = user.hasRole(SecurityConstants.GLOBAL_ADMIN_ROLE) || user.hasRole(orgAdminRole);
418     if (!admin) {
419       query.must(QueryBuilders.termsQuery(
420               SearchResult.INDEX_ACL + ".read",
421               user.getRoles().stream().map(Role::getName).collect(Collectors.toList())
422       ));
423     }
424 
425     logger.debug("limit: {}, offset: {}", limit, offset);
426 
427     var size = NumberUtils.toInt(limit, 20);
428     var from = NumberUtils.toInt(offset);
429     if (size < 0 || from < 0) {
430       return Response.status(Response.Status.BAD_REQUEST)
431           .entity("Limit and offset may not be negative.")
432           .build();
433     }
434     if (!admin && size > 250) {
435       return Response.status(Response.Status.BAD_REQUEST)
436           .entity("Only admins are allowed to request more than 250 items.")
437           .build();
438     }
439 
440     var searchSource = new SearchSourceBuilder()
441         .query(query)
442         .from(from)
443         .size(size);
444 
445     if (StringUtils.isNotEmpty(sort)) {
446       var sortParam = StringUtils.split(sort.toLowerCase());
447       var validSort = Arrays.asList("title", "contributor", "creator", "created", "modified").contains(sortParam[0]);
448       var validOrder = sortParam.length < 2 || Arrays.asList("asc", "desc").contains(sortParam[1]);
449       if (sortParam.length > 2 || !validSort || !validOrder) {
450         return Response.status(Response.Status.BAD_REQUEST)
451             .entity("Invalid sort parameter")
452             .build();
453       }
454       var order = SortOrder.fromString(sortParam.length > 1 ? sortParam[1] : "asc");
455       if ("modified".equals(sortParam[0])) {
456         searchSource.sort(sortParam[0], order);
457       } else {
458         searchSource.sort(SearchResult.DUBLINCORE + "." + sortParam[0], order);
459       }
460     }
461 
462     List<Map<String, Object>> result = null;
463     long total = 0;
464     if (snameNotFound) {
465       result = Collections.emptyList();
466     } else {
467       SearchResultList hits = searchService.search(searchSource);
468       result = hits.getHits().stream()
469           .map(SearchResult::dehydrateForREST)
470           .collect(Collectors.toList());
471 
472       // Sign urls if sign-parameter is not false
473       if (!"false".equals(sign) && this.urlSigningService != null) {
474         this.findURLsAndSign(result);
475       }
476 
477       total = hits.getTotalHits();
478     }
479     var json = gson.toJson(Map.of(
480         "offset", from,
481         "total", total,
482         "result", result,
483         "limit", size));
484 
485     return Response.ok(json).build();
486   }
487 
488   @POST
489   @Path("updateIndex")
490   @RestQuery(name = "updateIndex",
491           description = "Trigger search index update for event. The usage of this is limited to global administrators.",
492           restParameters = {
493                   @RestParameter(
494                           name = "id",
495                           isRequired = true,
496                           type = STRING,
497                           description = "The event ID to trigger an index update for.")},
498           responses = {
499                   @RestResponse(
500                           description = "Update successfully triggered.",
501                           responseCode = SC_NO_CONTENT),
502                   @RestResponse(
503                           description = "Not allowed to trigger update.",
504                           responseCode = SC_FORBIDDEN),
505                   @RestResponse(
506                           description = "No such event found.",
507                           responseCode = SC_NOT_FOUND)},
508           returnDescription = "No content is returned.")
509   public Response indexUpdate(@FormParam("id") final String id) {
510     try {
511       searchIndex.indexMediaPackage(id);
512       return noContent();
513     } catch (UnauthorizedException e) {
514       return forbidden();
515     } catch (NotFoundException e) {
516       return notFound();
517     } catch (Exception e) {
518       throw new WebApplicationException(e, Response.Status.INTERNAL_SERVER_ERROR);
519     }
520   }
521 
522   /**
523    * Iterate recursively through Object List and sign all Strings with key=url
524    * @param obj
525    */
526   private void findURLsAndSign(Object obj) {
527     if (obj instanceof Map) {
528       Map<String, Object> map = (Map<String, Object>) obj;
529       for (Map.Entry<String, Object> entry : map.entrySet()) {
530         if (entry.getKey().equals("url") && entry.getValue() instanceof String) {
531           String urlToSign = (String) entry.getValue();
532           if (this.urlSigningService.accepts(urlToSign)) {
533             try {
534               String signedUrl = this.urlSigningService.sign(
535                   urlToSign,
536                   UrlSigningServiceOsgiUtil.DEFAULT_URL_SIGNING_EXPIRE_DURATION,
537                   null,
538                   null);
539               map.put(entry.getKey(), signedUrl);
540             } catch (UrlSigningException e) {
541               logger.debug("Unable to sign url '{}'.", urlToSign);
542             }
543           }
544         } else {
545           findURLsAndSign(entry.getValue());
546         }
547       }
548     } else if (obj instanceof List) {
549       for (Object item : (List<?>) obj) {
550         findURLsAndSign(item);
551       }
552     }
553   }
554 
555   /**
556    * @see org.opencastproject.rest.AbstractJobProducerEndpoint#getService()
557    */
558   @Override
559   public JobProducer getService() {
560     return searchService;
561   }
562 
563   /**
564    * Callback from OSGi to set the search service implementation.
565    *
566    * @param searchService
567    *          the service implementation
568    */
569   @Reference
570   public void setSearchService(SearchServiceImpl searchService) {
571     this.searchService = searchService;
572   }
573 
574   @Reference
575   public void setSearchIndex(SearchServiceIndex searchIndex) {
576     this.searchIndex = searchIndex;
577   }
578 
579   @Reference
580   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
581     this.serviceRegistry = serviceRegistry;
582   }
583 
584   @Override
585   public ServiceRegistry getServiceRegistry() {
586     return serviceRegistry;
587   }
588 
589   @Reference
590   public void setSecurityService(SecurityService securityService) {
591     this.securityService = securityService;
592   }
593 
594   @Reference
595   void setUrlSigningService(UrlSigningService service) {
596     this.urlSigningService = service;
597   }
598 
599 }