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