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.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
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
120 protected SearchServiceImpl searchService;
121
122
123 protected SearchServiceIndex searchIndex;
124
125
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
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
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
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
507
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
540
541 @Override
542 public JobProducer getService() {
543 return searchService;
544 }
545
546
547
548
549
550
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 }