1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
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
121 protected SearchServiceImpl searchService;
122
123
124 protected SearchServiceIndex searchIndex;
125
126
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
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
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
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
524
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
557
558 @Override
559 public JobProducer getService() {
560 return searchService;
561 }
562
563
564
565
566
567
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 }