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.adminui.endpoint;
23  
24  import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
25  import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
26  import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
27  import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
28  import static javax.servlet.http.HttpServletResponse.SC_OK;
29  import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
30  import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
31  import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
32  import static javax.ws.rs.core.Response.Status.NOT_FOUND;
33  import static javax.ws.rs.core.Response.Status.NO_CONTENT;
34  import static org.apache.commons.lang3.StringUtils.trimToNull;
35  import static org.opencastproject.adminui.endpoint.EndpointUtil.transformAccessControList;
36  import static org.opencastproject.index.service.util.JSONUtils.collectionToJsonArray;
37  import static org.opencastproject.index.service.util.JSONUtils.safeString;
38  import static org.opencastproject.index.service.util.RestUtils.notFound;
39  import static org.opencastproject.index.service.util.RestUtils.okJson;
40  import static org.opencastproject.index.service.util.RestUtils.okJsonList;
41  import static org.opencastproject.util.DateTimeSupport.toUTC;
42  import static org.opencastproject.util.RestUtil.R.badRequest;
43  import static org.opencastproject.util.RestUtil.R.conflict;
44  import static org.opencastproject.util.RestUtil.R.forbidden;
45  import static org.opencastproject.util.RestUtil.R.notFound;
46  import static org.opencastproject.util.RestUtil.R.ok;
47  import static org.opencastproject.util.RestUtil.R.serverError;
48  import static org.opencastproject.util.doc.rest.RestParameter.Type.BOOLEAN;
49  import static org.opencastproject.util.doc.rest.RestParameter.Type.INTEGER;
50  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
51  import static org.opencastproject.util.doc.rest.RestParameter.Type.TEXT;
52  
53  import org.opencastproject.adminui.impl.AdminUIConfiguration;
54  import org.opencastproject.adminui.tobira.TobiraException;
55  import org.opencastproject.adminui.tobira.TobiraService;
56  import org.opencastproject.authorization.xacml.manager.api.AclService;
57  import org.opencastproject.authorization.xacml.manager.api.AclServiceFactory;
58  import org.opencastproject.authorization.xacml.manager.api.ManagedAcl;
59  import org.opencastproject.authorization.xacml.manager.util.AccessInformationUtil;
60  import org.opencastproject.elasticsearch.api.SearchIndexException;
61  import org.opencastproject.elasticsearch.api.SearchResult;
62  import org.opencastproject.elasticsearch.api.SearchResultItem;
63  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
64  import org.opencastproject.elasticsearch.index.objects.event.Event;
65  import org.opencastproject.elasticsearch.index.objects.event.EventSearchQuery;
66  import org.opencastproject.elasticsearch.index.objects.series.Series;
67  import org.opencastproject.elasticsearch.index.objects.series.SeriesIndexSchema;
68  import org.opencastproject.elasticsearch.index.objects.series.SeriesSearchQuery;
69  import org.opencastproject.index.service.api.IndexService;
70  import org.opencastproject.index.service.exception.IndexServiceException;
71  import org.opencastproject.index.service.util.RestUtils;
72  import org.opencastproject.list.api.ListProviderException;
73  import org.opencastproject.list.api.ListProvidersService;
74  import org.opencastproject.list.common.provider.SeriesListProvider;
75  import org.opencastproject.list.common.query.SeriesListQuery;
76  import org.opencastproject.metadata.dublincore.DublinCore;
77  import org.opencastproject.metadata.dublincore.DublinCoreMetadataCollection;
78  import org.opencastproject.metadata.dublincore.MetadataField;
79  import org.opencastproject.metadata.dublincore.MetadataJson;
80  import org.opencastproject.metadata.dublincore.MetadataList;
81  import org.opencastproject.metadata.dublincore.SeriesCatalogUIAdapter;
82  import org.opencastproject.rest.BulkOperationResult;
83  import org.opencastproject.security.api.AccessControlList;
84  import org.opencastproject.security.api.AccessControlParser;
85  import org.opencastproject.security.api.Permissions;
86  import org.opencastproject.security.api.SecurityService;
87  import org.opencastproject.security.api.UnauthorizedException;
88  import org.opencastproject.security.api.UserDirectoryService;
89  import org.opencastproject.series.api.SeriesException;
90  import org.opencastproject.series.api.SeriesService;
91  import org.opencastproject.systems.OpencastConstants;
92  import org.opencastproject.themes.Theme;
93  import org.opencastproject.themes.ThemesServiceDatabase;
94  import org.opencastproject.themes.persistence.ThemesServiceDatabaseException;
95  import org.opencastproject.util.NotFoundException;
96  import org.opencastproject.util.RestUtil;
97  import org.opencastproject.util.UrlSupport;
98  import org.opencastproject.util.data.Tuple;
99  import org.opencastproject.util.doc.rest.RestParameter;
100 import org.opencastproject.util.doc.rest.RestParameter.Type;
101 import org.opencastproject.util.doc.rest.RestQuery;
102 import org.opencastproject.util.doc.rest.RestResponse;
103 import org.opencastproject.util.doc.rest.RestService;
104 import org.opencastproject.util.requests.SortCriterion;
105 import org.opencastproject.util.requests.SortCriterion.Order;
106 import org.opencastproject.workflow.api.WorkflowInstance;
107 
108 import com.google.gson.JsonObject;
109 
110 import org.apache.commons.lang3.BooleanUtils;
111 import org.apache.commons.lang3.StringUtils;
112 import org.json.simple.JSONArray;
113 import org.json.simple.JSONObject;
114 import org.json.simple.parser.JSONParser;
115 import org.osgi.service.component.ComponentContext;
116 import org.osgi.service.component.annotations.Activate;
117 import org.osgi.service.component.annotations.Component;
118 import org.osgi.service.component.annotations.Modified;
119 import org.osgi.service.component.annotations.Reference;
120 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
121 import org.slf4j.Logger;
122 import org.slf4j.LoggerFactory;
123 
124 import java.io.IOException;
125 import java.net.URI;
126 import java.util.ArrayList;
127 import java.util.Date;
128 import java.util.List;
129 import java.util.Map;
130 import java.util.Objects;
131 import java.util.Optional;
132 import java.util.regex.Pattern;
133 
134 import javax.servlet.http.HttpServletResponse;
135 import javax.ws.rs.DELETE;
136 import javax.ws.rs.DefaultValue;
137 import javax.ws.rs.FormParam;
138 import javax.ws.rs.GET;
139 import javax.ws.rs.POST;
140 import javax.ws.rs.PUT;
141 import javax.ws.rs.Path;
142 import javax.ws.rs.PathParam;
143 import javax.ws.rs.Produces;
144 import javax.ws.rs.QueryParam;
145 import javax.ws.rs.WebApplicationException;
146 import javax.ws.rs.core.MediaType;
147 import javax.ws.rs.core.Response;
148 import javax.ws.rs.core.Response.Status;
149 
150 @Path("/admin-ng/series")
151 @RestService(
152     name = "SeriesProxyService",
153     title = "UI Series",
154     abstractText = "This service provides the series data for the UI.",
155     notes = { "This service offers the series CRUD Operations for the admin UI.",
156               "<strong>Important:</strong> "
157                 + "<em>This service is for exclusive use by the module admin-ui. Its API might change "
158                 + "anytime without prior notice. Any dependencies other than the admin UI will be strictly ignored. "
159                 + "DO NOT use this for integration of third-party applications.<em>"})
160 @Component(
161     immediate = true,
162     service = SeriesEndpoint.class,
163     property = {
164         "service.description=Admin UI - SeriesEndpoint Endpoint",
165         "opencast.service.type=org.opencastproject.adminui.SeriesEndpoint",
166         "opencast.service.path=/admin-ng/series",
167     }
168 )
169 @JaxrsResource
170 public class SeriesEndpoint {
171 
172   private static final Logger logger = LoggerFactory.getLogger(SeriesEndpoint.class);
173 
174   private static final int CREATED_BY_UI_ORDER = 9;
175 
176   /** Default number of items on page */
177   private static final int DEFAULT_LIMIT = 100;
178 
179   public static final String THEME_KEY = "theme";
180 
181   private Boolean deleteSeriesWithEventsAllowed = true;
182   private Boolean onlySeriesWithWriteAccessSeriesTab = false;
183   private Boolean onlySeriesWithWriteAccessEventsFilter = false;
184 
185   public static final String SERIES_HASEVENTS_DELETE_ALLOW_KEY = "series.hasEvents.delete.allow";
186   public static final String SERIESTAB_ONLYSERIESWITHWRITEACCESS_KEY = "seriesTab.onlySeriesWithWriteAccess";
187   public static final String EVENTSFILTER_ONLYSERIESWITHWRITEACCESS_KEY = "eventsFilter.onlySeriesWithWriteAccess";
188   public static final Pattern TOBIRA_CONFIG =
189       Pattern.compile("^tobira\\.(?<organization>.*)\\.(?<key>origin|trustedKey)$");
190 
191   private SeriesService seriesService;
192   private SecurityService securityService;
193   private AclServiceFactory aclServiceFactory;
194   private IndexService indexService;
195   private ListProvidersService listProvidersService;
196   private ElasticsearchIndex searchIndex;
197   private AdminUIConfiguration adminUIConfiguration;
198   private ThemesServiceDatabase themesServiceDatabase;
199   private UserDirectoryService userDirectoryService;
200 
201   /** Default server URL */
202   private String serverUrl = "http://localhost:8080";
203 
204   /** OSGi callback for the series service. */
205   @Reference
206   public void setSeriesService(SeriesService seriesService) {
207     this.seriesService = seriesService;
208   }
209 
210   /** OSGi callback for the search index. */
211   @Reference
212   public void setIndex(ElasticsearchIndex index) {
213     this.searchIndex = index;
214   }
215 
216   /** OSGi DI. */
217   @Reference
218   public void setIndexService(IndexService indexService) {
219     this.indexService = indexService;
220   }
221 
222   /** OSGi callback for the list provider service */
223   @Reference
224   public void setListProvidersService(ListProvidersService listProvidersService) {
225     this.listProvidersService = listProvidersService;
226   }
227 
228   /** OSGi callback for the security service */
229   @Reference
230   public void setSecurityService(SecurityService securityService) {
231     this.securityService = securityService;
232   }
233 
234   /** OSGi callback for the acl service factory */
235   @Reference
236   public void setAclServiceFactory(AclServiceFactory aclServiceFactory) {
237     this.aclServiceFactory = aclServiceFactory;
238   }
239 
240   private AclService getAclService() {
241     return aclServiceFactory.serviceFor(securityService.getOrganization());
242   }
243 
244 
245   /** OSGi DI. */
246   @Reference
247   public void setAdminUIConfiguration(AdminUIConfiguration adminUIConfiguration) {
248     this.adminUIConfiguration = adminUIConfiguration;
249   }
250 
251   /** OSGi callback for the themes service database. */
252   @Reference
253   public void setThemesServiceDatabase(ThemesServiceDatabase themesServiceDatabase) {
254     this.themesServiceDatabase = themesServiceDatabase;
255   }
256 
257   /** Sets the user directory service */
258   @Reference
259   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
260     this.userDirectoryService = userDirectoryService;
261   }
262 
263   @Activate
264   protected void activate(ComponentContext cc, Map<String, Object> properties) {
265     if (cc != null) {
266       String ccServerUrl = cc.getBundleContext().getProperty(OpencastConstants.SERVER_URL_PROPERTY);
267       logger.debug("Configured server url is {}", ccServerUrl);
268       if (ccServerUrl != null) {
269         this.serverUrl = ccServerUrl;
270       }
271 
272       modified(properties);
273     }
274     logger.info("Activate series endpoint");
275   }
276 
277   /** OSGi callback if properties file is present */
278   @Modified
279   public void modified(Map<String, Object> properties) {
280     if (properties == null) {
281       logger.info("No configuration available, using defaults");
282       return;
283     }
284 
285     Object mapValue = properties.get(SERIES_HASEVENTS_DELETE_ALLOW_KEY);
286     if (mapValue != null) {
287       deleteSeriesWithEventsAllowed = BooleanUtils.toBoolean(mapValue.toString());
288     }
289 
290     mapValue = properties.get(SERIESTAB_ONLYSERIESWITHWRITEACCESS_KEY);
291     onlySeriesWithWriteAccessSeriesTab = BooleanUtils.toBoolean(Objects.toString(mapValue, "true"));
292 
293     mapValue = properties.get(EVENTSFILTER_ONLYSERIESWITHWRITEACCESS_KEY);
294     onlySeriesWithWriteAccessEventsFilter = BooleanUtils.toBoolean(Objects.toString(mapValue, "true"));
295 
296     properties.forEach((key, value) -> {
297       var matches = TOBIRA_CONFIG.matcher(key);
298       if (!matches.matches()) {
299         return;
300       }
301       var tobira = TobiraService.getTobira(matches.group("organization"));
302       switch (matches.group("key")) {
303         case "origin":
304           tobira.setOrigin((String) value);
305           break;
306         case "trustedKey":
307           tobira.setTrustedKey((String) value);
308           break;
309         default:
310           throw new RuntimeException("unhandled Tobira config key");
311       }
312     });
313 
314     logger.info("Configuration updated");
315   }
316 
317   @GET
318   @Path("{seriesId}/access.json")
319   @SuppressWarnings("unchecked")
320   @Produces(MediaType.APPLICATION_JSON)
321   @RestQuery(
322       name = "getseriesaccessinformation",
323       description = "Get the access information of a series",
324       returnDescription = "The access information",
325       pathParameters = {
326           @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier",
327               type = Type.STRING)
328       }, responses = {
329           @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required form params were missing in the "
330               + "request."),
331           @RestResponse(responseCode = SC_NOT_FOUND, description = "If the series has not been found."),
332           @RestResponse(responseCode = SC_OK, description = "The access information ")
333       })
334   public Response getSeriesAccessInformation(@PathParam("seriesId") String seriesId) throws NotFoundException {
335     if (StringUtils.isBlank(seriesId)) {
336       return RestUtil.R.badRequest("Path parameter series ID is missing");
337     }
338 
339     boolean hasProcessingEvents = hasProcessingEvents(seriesId);
340 
341     // Add all available ACLs to the response
342     JSONArray systemAclsJson = new JSONArray();
343     List<ManagedAcl> acls = getAclService().getAcls();
344     for (ManagedAcl acl : acls) {
345       systemAclsJson.add(AccessInformationUtil.serializeManagedAcl(acl));
346     }
347 
348     JSONObject seriesAccessJson = new JSONObject();
349     try {
350       AccessControlList seriesAccessControl = seriesService.getSeriesAccessControl(seriesId);
351       Optional<ManagedAcl> currentAcl = AccessInformationUtil.matchAclsLenient(acls, seriesAccessControl,
352               adminUIConfiguration.getMatchManagedAclRolePrefixes());
353       seriesAccessJson.put("current_acl", currentAcl.isPresent() ? currentAcl.get().getId() : 0);
354       seriesAccessJson.put("privileges", AccessInformationUtil.serializePrivilegesByRole(seriesAccessControl));
355       seriesAccessJson.put("acl", transformAccessControList(seriesAccessControl, userDirectoryService));
356       seriesAccessJson.put("locked", hasProcessingEvents);
357     } catch (SeriesException e) {
358       logger.error("Unable to get ACL from series {}", seriesId, e);
359       return RestUtil.R.serverError();
360     }
361 
362     JSONObject jsonReturnObj = new JSONObject();
363     jsonReturnObj.put("system_acls", systemAclsJson);
364     jsonReturnObj.put("series_access", seriesAccessJson);
365 
366     return Response.ok(jsonReturnObj.toString()).build();
367   }
368 
369   @GET
370   @Produces(MediaType.APPLICATION_JSON)
371   @Path("{seriesId}/metadata.json")
372   @RestQuery(
373       name = "getseriesmetadata",
374       description = "Returns the series metadata as JSON",
375       returnDescription = "Returns the series metadata as JSON",
376       pathParameters = {
377           @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING)
378       },
379       responses = {
380           @RestResponse(responseCode = SC_OK, description = "The series metadata as JSON."),
381           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series has not been found"),
382           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
383               + "perform this action")
384       })
385   public Response getSeriesMetadata(@PathParam("seriesId") String series) throws UnauthorizedException,
386           NotFoundException, SearchIndexException {
387     Optional<Series> optSeries = searchIndex.getSeries(series, securityService.getOrganization(),
388         securityService.getUser());
389     if (optSeries.isEmpty()) {
390       return notFound("Cannot find a series with id '%s'.", series);
391     }
392 
393     MetadataList metadataList = new MetadataList();
394     List<SeriesCatalogUIAdapter> catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
395     catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
396     for (SeriesCatalogUIAdapter adapter : catalogUIAdapters) {
397       final Optional<DublinCoreMetadataCollection> optSeriesMetadata = adapter.getFields(series);
398       if (optSeriesMetadata.isPresent()) {
399         metadataList.add(adapter.getFlavor().toString(), adapter.getUITitle(), optSeriesMetadata.get());
400       }
401     }
402     metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), getSeriesMetadata(optSeries.get()));
403     return okJson(MetadataJson.listToJson(metadataList, true));
404   }
405 
406   /**
407    * Loads the metadata for the given series
408    *
409    * @param series
410    *          the source {@link Series}
411    * @return a {@link DublinCoreMetadataCollection} instance with all the series metadata
412    */
413   private DublinCoreMetadataCollection getSeriesMetadata(Series series) {
414     DublinCoreMetadataCollection metadata = indexService.getCommonSeriesCatalogUIAdapter().getRawFields();
415 
416     MetadataField title = metadata.getOutputFields().get(DublinCore.PROPERTY_TITLE.getLocalName());
417     metadata.removeField(title);
418     MetadataField newTitle = new MetadataField(title);
419     newTitle.setValue(series.getTitle());
420     metadata.addField(newTitle);
421 
422     MetadataField subject = metadata.getOutputFields().get(DublinCore.PROPERTY_SUBJECT.getLocalName());
423     metadata.removeField(subject);
424     MetadataField newSubject = new MetadataField(subject);
425     newSubject.setValue(series.getSubject());
426     metadata.addField(newSubject);
427 
428     MetadataField description = metadata.getOutputFields().get(DublinCore.PROPERTY_DESCRIPTION.getLocalName());
429     metadata.removeField(description);
430     MetadataField newDescription = new MetadataField(description);
431     newDescription.setValue(series.getDescription());
432     metadata.addField(newDescription);
433 
434     MetadataField language = metadata.getOutputFields().get(DublinCore.PROPERTY_LANGUAGE.getLocalName());
435     metadata.removeField(language);
436     MetadataField newLanguage = new MetadataField(language);
437     newLanguage.setValue(series.getLanguage());
438     metadata.addField(newLanguage);
439 
440     MetadataField rightsHolder = metadata.getOutputFields().get(DublinCore.PROPERTY_RIGHTS_HOLDER.getLocalName());
441     metadata.removeField(rightsHolder);
442     MetadataField newRightsHolder = new MetadataField(rightsHolder);
443     newRightsHolder.setValue(series.getRightsHolder());
444     metadata.addField(newRightsHolder);
445 
446     MetadataField license = metadata.getOutputFields().get(DublinCore.PROPERTY_LICENSE.getLocalName());
447     metadata.removeField(license);
448     MetadataField newLicense = new MetadataField(license);
449     newLicense.setValue(series.getLicense());
450     metadata.addField(newLicense);
451 
452     MetadataField organizers = metadata.getOutputFields().get(DublinCore.PROPERTY_CREATOR.getLocalName());
453     metadata.removeField(organizers);
454     MetadataField newOrganizers = new MetadataField(organizers);
455     newOrganizers.setValue(series.getOrganizers());
456     metadata.addField(newOrganizers);
457 
458     MetadataField contributors = metadata.getOutputFields().get(DublinCore.PROPERTY_CONTRIBUTOR.getLocalName());
459     metadata.removeField(contributors);
460     MetadataField newContributors = new MetadataField(contributors);
461     newContributors.setValue(series.getContributors());
462     metadata.addField(newContributors);
463 
464     MetadataField publishers = metadata.getOutputFields().get(DublinCore.PROPERTY_PUBLISHER.getLocalName());
465     metadata.removeField(publishers);
466     MetadataField newPublishers = new MetadataField(publishers);
467     newPublishers.setValue(series.getPublishers());
468     metadata.addField(newPublishers);
469 
470     // Admin UI only field
471     MetadataField createdBy = new MetadataField(
472         "createdBy",
473         null,
474         "EVENTS.SERIES.DETAILS.METADATA.CREATED_BY",
475         true,
476         false,
477         null,
478         null,
479         MetadataField.Type.TEXT,
480         null,
481         null,
482         CREATED_BY_UI_ORDER,
483         null,
484         null,
485         null,
486         null);
487     createdBy.setValue(series.getCreator());
488     metadata.addField(createdBy);
489 
490     MetadataField uid = metadata.getOutputFields().get(DublinCore.PROPERTY_IDENTIFIER.getLocalName());
491     metadata.removeField(uid);
492     MetadataField newUID = new MetadataField(uid);
493     newUID.setValue(series.getIdentifier());
494     metadata.addField(newUID);
495 
496     return metadata;
497   }
498 
499   @PUT
500   @Path("{seriesId}/metadata")
501   @RestQuery(
502       name = "updateseriesmetadata",
503       description = "Update the series metadata with the one given JSON",
504       returnDescription = "Returns OK if the metadata have been saved.",
505       pathParameters = {
506           @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING)
507       },
508       restParameters = {
509           @RestParameter(name = "metadata", isRequired = true, type = RestParameter.Type.TEXT, description = "The list "
510               + "of metadata to update")
511       },
512       responses = {
513           @RestResponse(responseCode = SC_OK, description = "The series metadata as JSON."),
514           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series has not been found"),
515           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
516               + "perform this action")
517       })
518   public Response updateSeriesMetadata(@PathParam("seriesId") String seriesID,
519           @FormParam("metadata") String metadataJSON) throws UnauthorizedException, NotFoundException,
520           SearchIndexException {
521     try {
522       MetadataList metadataList = indexService.updateAllSeriesMetadata(seriesID, metadataJSON, searchIndex);
523       return okJson(MetadataJson.listToJson(metadataList, true));
524     } catch (IllegalArgumentException e) {
525       return RestUtil.R.badRequest(e.getMessage());
526     } catch (IndexServiceException e) {
527       return RestUtil.R.serverError();
528     }
529   }
530 
531   @GET
532   @Path("new/metadata")
533   @RestQuery(
534       name = "getNewMetadata",
535       description = "Returns all the data related to the metadata tab in the new series modal as JSON",
536       returnDescription = "All the data related to the series metadata tab as JSON",
537       responses = {
538           @RestResponse(responseCode = SC_OK, description = "Returns all the data related to the series metadata tab "
539               + "as JSON")
540       })
541   public Response getNewMetadata() {
542     MetadataList metadataList = indexService.getMetadataListWithAllSeriesCatalogUIAdapters();
543     final DublinCoreMetadataCollection metadataByAdapter = metadataList
544             .getMetadataByAdapter(indexService.getCommonSeriesCatalogUIAdapter());
545     if (metadataByAdapter != null) {
546       DublinCoreMetadataCollection collection = metadataByAdapter;
547       safelyRemoveField(collection, "identifier");
548       metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), collection);
549     }
550     return okJson(MetadataJson.listToJson(metadataList, true));
551   }
552 
553   private void safelyRemoveField(DublinCoreMetadataCollection collection, String fieldName) {
554     MetadataField metadataField = collection.getOutputFields().get(fieldName);
555     if (metadataField != null) {
556       collection.removeField(metadataField);
557     }
558   }
559 
560   @GET
561   @Path("new/themes")
562   @SuppressWarnings("unchecked")
563   @RestQuery(
564       name = "getNewThemes",
565       description = "Returns all the data related to the themes tab in the new series modal as JSON",
566       returnDescription = "All the data related to the series themes tab as JSON",
567       responses = {
568           @RestResponse(responseCode = SC_OK, description = "Returns all the data related to the series themes tab as "
569               + "JSON")
570       })
571   public Response getNewThemes() {
572     SortCriterion sortCriterion = new SortCriterion("name", Order.Ascending);
573     ArrayList<SortCriterion> sortCriteria = new ArrayList<>();
574     sortCriteria.add(sortCriterion);
575     List<Theme> results = themesServiceDatabase.findThemes(
576         Optional.ofNullable(Integer.MAX_VALUE),
577         Optional.ofNullable(0),
578         sortCriteria,
579         Optional.empty(),
580         Optional.empty()
581     );
582 
583     JSONObject themesJson = new JSONObject();
584     for (Theme theme : results) {
585       JSONObject themeInfoJson = new JSONObject();
586       themeInfoJson.put("name", theme.getName());
587       themeInfoJson.put("description", theme.getDescription());
588       themesJson.put(theme.getId().get(), themeInfoJson);
589     }
590 
591     return Response.ok(themesJson.toJSONString()).build();
592   }
593 
594   private TobiraService getTobira() {
595     return TobiraService.getTobira(securityService.getOrganization().getId());
596   }
597 
598   @GET
599   @Path("new/tobira/page")
600   @Produces(MediaType.APPLICATION_JSON)
601   @RestQuery(
602       name = "getTobiraPage",
603       description = "Returns data about the page tree of a connected Tobira instance for use in the series creation "
604           + "wizard",
605       returnDescription = "Information about a given page in Tobira, and its direct children",
606       restParameters = {
607           @RestParameter(
608               name = "path",
609               isRequired = true,
610               type = STRING,
611               description = "The path of the page you want information about"
612       )},
613       responses = {
614               @RestResponse(
615                   responseCode = SC_OK,
616                   description = "Data about the given page in Tobira. Note that this does not mean the page exists!"),
617               @RestResponse(
618                   responseCode = SC_NOT_FOUND,
619                   description = "Nonexistent `path`"),
620               @RestResponse(
621                   responseCode = SC_BAD_REQUEST,
622                   description = "missing `path`"),
623               @RestResponse(
624                   responseCode = SC_SERVICE_UNAVAILABLE,
625                   description = "Tobira is not configured (correctly)")
626       })
627   public Response getTobiraPage(@QueryParam("path") String path) throws IOException, InterruptedException {
628     if (path == null) {
629       throw new WebApplicationException("`path` missing", BAD_REQUEST);
630     }
631 
632     var tobira = getTobira();
633     if (!tobira.ready()) {
634       return Response.status(Status.SERVICE_UNAVAILABLE)
635               .entity("Tobira is not configured (correctly)")
636               .build();
637     }
638 
639     try {
640       var page = (JSONObject) tobira.getPage(path).get("page");
641       if (page == null) {
642         throw new WebApplicationException(NOT_FOUND);
643       }
644       return Response.ok(page.toJSONString()).build();
645     } catch (TobiraException e) {
646       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
647     }
648   }
649 
650   @POST
651   @Path("new")
652   @RestQuery(
653       name = "createNewSeries",
654       description = "Creates a new series by the given metadata as JSON",
655       returnDescription = "The created series id",
656       restParameters = {
657           @RestParameter(name = "metadata", isRequired = true, description = "The metadata as JSON",
658               type = RestParameter.Type.TEXT)
659       },
660       responses = {
661           @RestResponse(responseCode = HttpServletResponse.SC_CREATED, description = "Returns the created series id"),
662           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST, description = "he request could not be "
663               + "fulfilled due to the incorrect syntax of the request"),
664           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If user doesn't have rights to create the "
665               + "series")
666       })
667   public Response createNewSeries(@FormParam("metadata") String metadata) throws UnauthorizedException {
668     try {
669       JSONObject metadataJson;
670       try {
671         metadataJson = (JSONObject) new JSONParser().parse(metadata);
672       } catch (Exception e) {
673         throw new IllegalArgumentException("Unable to parse metadata " + metadata, e);
674       }
675 
676       if (metadataJson == null) {
677         throw new IllegalArgumentException("No metadata set to create series");
678       }
679 
680       String seriesId = indexService.createSeries(metadataJson);
681 
682       var mounted = mountSeriesInTobira(seriesId, metadataJson);
683 
684       var responseObject = new JSONObject();
685       responseObject.put("id", seriesId);
686       responseObject.put("mounted", mounted);
687 
688       return Response.created(URI.create(UrlSupport.concat(serverUrl, "admin-ng/series/", seriesId, "metadata.json")))
689               .entity(responseObject.toString()).build();
690     } catch (IllegalArgumentException e) {
691       return RestUtil.R.badRequest(e.getMessage());
692     } catch (IndexServiceException e) {
693       return RestUtil.R.serverError();
694     }
695   }
696 
697   private boolean mountSeriesInTobira(String seriesId, JSONObject params) {
698     var tobira = getTobira();
699     if (!tobira.ready()) {
700       return false;
701     }
702 
703     var tobiraParams = params.get("tobira");
704     if (tobiraParams == null) {
705       return false;
706     }
707     if (!(tobiraParams instanceof JSONObject)) {
708       return false;
709     }
710     var tobiraParamsObject = (JSONObject) tobiraParams;
711 
712     var metadataCatalogs = (JSONArray) params.get("metadata");
713     var firstCatalog = (JSONObject) metadataCatalogs.get(0);
714     var metadataFields = (List<JSONObject>) firstCatalog.get("fields");
715     var title = metadataFields.stream()
716             .filter(field -> field.get("id").equals("title"))
717             .findAny()
718             .map(field -> field.get("value"))
719             .map(String.class::cast)
720             .get();
721 
722     var series = new JSONObject(Map.of(
723             "opencastId", seriesId,
724             "title", title));
725     tobiraParamsObject.put("series", series);
726 
727     try {
728       tobira.mount(tobiraParamsObject);
729     } catch (TobiraException e) {
730       return false;
731     }
732 
733     return true;
734   }
735 
736   @DELETE
737   @Path("{seriesId}")
738   @Produces(MediaType.APPLICATION_JSON)
739   @RestQuery(
740       name = "deleteseries",
741       description = "Delete a series.",
742       returnDescription = "Ok if the series has been deleted.",
743       pathParameters = {
744           @RestParameter(name = "seriesId", isRequired = true, description = "The id of the series to delete.",
745               type = STRING),
746       },
747       responses = {
748           @RestResponse(responseCode = SC_OK, description = "The series has been deleted."),
749           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "The series could not be found.")
750       })
751   public Response deleteSeries(@PathParam("seriesId") String id) throws NotFoundException {
752     try {
753       indexService.removeSeries(id);
754       return Response.ok().build();
755     } catch (NotFoundException e) {
756       throw e;
757     } catch (Exception e) {
758       logger.error("Unable to delete the series '{}' due to", id, e);
759       return Response.serverError().build();
760     }
761   }
762 
763   @POST
764   @Path("deleteSeries")
765   @Produces(MediaType.APPLICATION_JSON)
766   @RestQuery(
767       name = "deletemultipleseries",
768       description = "Deletes a json list of series by their given ids e.g. [\"Series-1\", \"Series-2\"]",
769       returnDescription = "A JSON object with arrays that show whether a series was deleted, was not found or there "
770           + "was an error deleting it.",
771       responses = {
772           @RestResponse(description = "Series have been deleted", responseCode = HttpServletResponse.SC_OK),
773           @RestResponse(description = "The list of ids could not be parsed into a json list.",
774               responseCode = HttpServletResponse.SC_BAD_REQUEST)
775       })
776   public Response deleteMultipleSeries(String seriesIdsContent) throws NotFoundException {
777     if (StringUtils.isBlank(seriesIdsContent)) {
778       return Response.status(Status.BAD_REQUEST).build();
779     }
780 
781     JSONParser parser = new JSONParser();
782     JSONArray seriesIdsArray;
783     try {
784       seriesIdsArray = (JSONArray) parser.parse(seriesIdsContent);
785     } catch (org.json.simple.parser.ParseException e) {
786       logger.error("Unable to parse '{}'", seriesIdsContent, e);
787       return Response.status(Status.BAD_REQUEST).build();
788     } catch (ClassCastException e) {
789       logger.error("Unable to cast '{}' to a JSON array", seriesIdsContent, e);
790       return Response.status(Status.BAD_REQUEST).build();
791     }
792 
793     BulkOperationResult result = new BulkOperationResult();
794     for (Object seriesId : seriesIdsArray) {
795       try {
796         indexService.removeSeries(seriesId.toString());
797         result.addOk(seriesId.toString());
798       } catch (NotFoundException e) {
799         result.addNotFound(seriesId.toString());
800       } catch (Exception e) {
801         logger.error("Unable to remove the series '{}'", seriesId.toString(), e);
802         result.addServerError(seriesId.toString());
803       }
804     }
805     return Response.ok(result.toJson()).build();
806   }
807 
808   @GET
809   @Produces(MediaType.APPLICATION_JSON)
810   @Path("series.json")
811   @RestQuery(
812       name = "listSeriesAsJson",
813       description = "Returns the series matching the query parameters",
814       returnDescription = "Returns the series search results as JSON",
815       restParameters = {
816           @RestParameter(name = "sortorganizer", isRequired = false, description = "The sort type to apply to the "
817               + "series organizer or organizers either Ascending or Descending.", type = STRING),
818           @RestParameter(name = "sort", description = "The order instructions used to sort the query result. Must be "
819               + "in the form '<field name>:(ASC|DESC)'", isRequired = false, type = STRING),
820           @RestParameter(name = "filter", isRequired = false, description = "The filter used for the query. They "
821               + "should be formated like that: 'filter1:value1,filter2,value2'", type = STRING),
822           @RestParameter(name = "offset", isRequired = false, description = "The page offset", type = INTEGER,
823               defaultValue = "0"),
824           @RestParameter(name = "limit", isRequired = false, description = "The limit to define the number of "
825               + "returned results (-1 for all)", type = INTEGER, defaultValue = "100")
826       },
827       responses = {
828           @RestResponse(responseCode = SC_OK, description = "The access control list."),
829           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
830               + "perform this action")
831       })
832   public Response getSeries(@QueryParam("filter") String filter, @QueryParam("sort") String sort,
833       @QueryParam("offset") int offset, @QueryParam("limit") int limit)
834           throws UnauthorizedException {
835     try {
836       logger.debug("Requested series list");
837       SeriesSearchQuery query = new SeriesSearchQuery(securityService.getOrganization().getId(),
838               securityService.getUser());
839       Optional<String> optSort = Optional.ofNullable(trimToNull(sort));
840 
841       if (offset != 0) {
842         query.withOffset(offset);
843       }
844 
845       // If limit is 0, we set the default limit
846       query.withLimit(limit == 0 ? DEFAULT_LIMIT : limit);
847 
848       Map<String, String> filters = RestUtils.parseFilter(filter);
849       for (String name : filters.keySet()) {
850         if (SeriesListQuery.FILTER_ACL_NAME.equals(name)) {
851           query.withManagedAcl(filters.get(name));
852         } else if (SeriesListQuery.FILTER_CONTRIBUTORS_NAME.equals(name)) {
853           query.withContributor(filters.get(name));
854         } else if (SeriesListQuery.FILTER_CREATIONDATE_NAME.equals(name)) {
855           try {
856             Tuple<Date, Date> fromAndToCreationRange = RestUtils.getFromAndToDateRange(filters.get(name));
857             query.withCreatedFrom(fromAndToCreationRange.getA());
858             query.withCreatedTo(fromAndToCreationRange.getB());
859           } catch (IllegalArgumentException e) {
860             return RestUtil.R.badRequest(e.getMessage());
861           }
862         } else if (SeriesListQuery.FILTER_CREATOR_NAME.equals(name)) {
863           query.withCreator(filters.get(name));
864         } else if (SeriesListQuery.FILTER_TEXT_NAME.equals(name)) {
865           query.withText(filters.get(name));
866         } else if (SeriesListQuery.FILTER_LANGUAGE_NAME.equals(name)) {
867           query.withLanguage(filters.get(name));
868         } else if (SeriesListQuery.FILTER_LICENSE_NAME.equals(name)) {
869           query.withLicense(filters.get(name));
870         } else if (SeriesListQuery.FILTER_ORGANIZERS_NAME.equals(name)) {
871           query.withOrganizer(filters.get(name));
872         } else if (SeriesListQuery.FILTER_SUBJECT_NAME.equals(name)) {
873           query.withSubject(filters.get(name));
874         } else if (SeriesListQuery.FILTER_TITLE_NAME.equals(name)) {
875           query.withTitle(filters.get(name));
876         } else if (SeriesListQuery.FILTER_READ_ACCESS_NAME.equals(name)) {
877           query.withAccessControlEntry(filters.get(name), Permissions.Action.READ);
878         } else if (SeriesListQuery.FILTER_WRITE_ACCESS_NAME.equals(name)) {
879           query.withAccessControlEntry(filters.get(name), Permissions.Action.WRITE);
880         }
881       }
882 
883       if (optSort.isPresent()) {
884         ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
885         for (SortCriterion criterion : sortCriteria) {
886 
887           switch (criterion.getFieldName()) {
888             case SeriesIndexSchema.TITLE:
889               query.sortByTitle(criterion.getOrder());
890               break;
891             case SeriesIndexSchema.CONTRIBUTORS:
892               query.sortByContributors(criterion.getOrder());
893               break;
894             case SeriesIndexSchema.ORGANIZERS:
895               query.sortByOrganizers(criterion.getOrder());
896               break;
897             case SeriesIndexSchema.CREATED_DATE_TIME:
898               query.sortByCreatedDateTime(criterion.getOrder());
899               break;
900             case SeriesIndexSchema.MANAGED_ACL:
901               query.sortByManagedAcl(criterion.getOrder());
902               break;
903             default:
904               logger.info("Unknown filter criteria {}", criterion.getFieldName());
905               return Response.status(SC_BAD_REQUEST).build();
906           }
907         }
908       }
909 
910       // We search for write actions
911       if (onlySeriesWithWriteAccessSeriesTab) {
912         query.withoutActions();
913         query.withAction(Permissions.Action.WRITE);
914         query.withAction(Permissions.Action.READ);
915       }
916 
917       logger.trace("Using Query: " + query.toString());
918 
919       SearchResult<Series> result = searchIndex.getByQuery(query);
920       if (logger.isDebugEnabled()) {
921         logger.debug("Found {} results in {} ms", result.getDocumentCount(), result.getSearchTime());
922       }
923 
924       List<JsonObject> series = new ArrayList<>();
925       for (SearchResultItem<Series> item : result.getItems()) {
926         JsonObject sJson = new JsonObject();
927         Series s = item.getSource();
928 
929         sJson.addProperty("id", s.getIdentifier());
930         sJson.addProperty("title", safeString(s.getTitle()));
931         sJson.add("organizers", collectionToJsonArray(s.getOrganizers()));
932         sJson.add("contributors", collectionToJsonArray(s.getContributors()));
933         if (s.getCreator() != null) {
934           sJson.addProperty("createdBy", s.getCreator());
935         }
936         if (s.getCreatedDateTime() != null) {
937           sJson.addProperty("creation_date", toUTC(s.getCreatedDateTime().getTime()));
938         }
939         if (s.getLanguage() != null) {
940           sJson.addProperty("language", s.getLanguage());
941         }
942         if (s.getLicense() != null) {
943           sJson.addProperty("license", s.getLicense());
944         }
945         if (s.getRightsHolder() != null) {
946           sJson.addProperty("rightsHolder", s.getRightsHolder());
947         }
948         if (StringUtils.isNotBlank(s.getManagedAcl())) {
949           sJson.addProperty("managedAcl", s.getManagedAcl());
950         }
951 
952         series.add(sJson);
953       }
954 
955       logger.debug("Request done");
956 
957       return okJsonList(series, offset, limit, result.getHitCount());
958     } catch (Exception e) {
959       logger.warn("Could not perform search query", e);
960       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
961     }
962   }
963 
964   /**
965    * Search all user series with write or read-only permissions.
966    *
967    * @param writeAccess
968    *         true: write access
969    *         false: read-only access
970    * @return user series with write or read-only access,
971    *         depending on the parameter
972    */
973   public Map<String, String> getUserSeriesByAccess(boolean writeAccess) {
974     SeriesListQuery query = new SeriesListQuery();
975     if (writeAccess) {
976       query.withoutPermissions();
977       query.withReadPermission(true);
978       query.withWritePermission(true);
979     }
980     try {
981       return listProvidersService.getList(SeriesListProvider.PROVIDER_PREFIX, query, true);
982     } catch (ListProviderException e) {
983       logger.warn("Could not perform search query.", e);
984       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
985     }
986   }
987 
988   @SuppressWarnings("unchecked")
989   @GET
990   @Produces(MediaType.APPLICATION_JSON)
991   @Path("{id}/properties")
992   @RestQuery(
993       name = "getSeriesProperties",
994       description = "Returns the series properties",
995       returnDescription = "Returns the series properties as JSON",
996       pathParameters = {
997           @RestParameter(name = "id", description = "ID of series", isRequired = true, type = Type.STRING)
998       },
999       responses = {
1000           @RestResponse(responseCode = SC_OK, description = "The access control list."),
1001           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
1002               + "perform this action")
1003       })
1004   public Response getSeriesPropertiesAsJson(@PathParam("id") String seriesId) throws UnauthorizedException,
1005           NotFoundException {
1006     if (StringUtils.isBlank(seriesId)) {
1007       logger.warn("Series id parameter is blank '{}'.", seriesId);
1008       return Response.status(BAD_REQUEST).build();
1009     }
1010     try {
1011       Map<String, String> properties = seriesService.getSeriesProperties(seriesId);
1012       JSONArray jsonProperties = new JSONArray();
1013       for (String name : properties.keySet()) {
1014         JSONObject property = new JSONObject();
1015         property.put(name, properties.get(name));
1016         jsonProperties.add(property);
1017       }
1018       return Response.ok(jsonProperties.toString()).build();
1019     } catch (UnauthorizedException e) {
1020       throw e;
1021     } catch (NotFoundException e) {
1022       throw e;
1023     } catch (Exception e) {
1024       logger.warn("Could not perform search query: {}", e.getMessage());
1025     }
1026     throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1027   }
1028 
1029   @GET
1030   @Produces(MediaType.APPLICATION_JSON)
1031   @Path("{seriesId}/property/{propertyName}.json")
1032   @RestQuery(
1033       name = "getSeriesProperty",
1034       description = "Returns a series property value",
1035       returnDescription = "Returns the series property value",
1036       pathParameters = {
1037           @RestParameter(name = "seriesId", description = "ID of series", isRequired = true, type = Type.STRING),
1038           @RestParameter(name = "propertyName", description = "Name of series property", isRequired = true,
1039               type = Type.STRING)
1040       },
1041       responses = {
1042           @RestResponse(responseCode = SC_OK, description = "The access control list."),
1043           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
1044               + "perform this action")
1045       })
1046   public Response getSeriesProperty(@PathParam("seriesId") String seriesId,
1047       @PathParam("propertyName") String propertyName) throws UnauthorizedException, NotFoundException {
1048     if (StringUtils.isBlank(seriesId)) {
1049       logger.warn("Series id parameter is blank '{}'.", seriesId);
1050       return Response.status(BAD_REQUEST).build();
1051     }
1052     if (StringUtils.isBlank(propertyName)) {
1053       logger.warn("Series property name parameter is blank '{}'.", propertyName);
1054       return Response.status(BAD_REQUEST).build();
1055     }
1056     try {
1057       String propertyValue = seriesService.getSeriesProperty(seriesId, propertyName);
1058       return Response.ok(propertyValue).build();
1059     } catch (UnauthorizedException e) {
1060       throw e;
1061     } catch (NotFoundException e) {
1062       throw e;
1063     } catch (Exception e) {
1064       logger.warn("Could not perform search query", e);
1065     }
1066     throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1067   }
1068 
1069   @POST
1070   @Path("/{seriesId}/property")
1071   @RestQuery(
1072       name = "updateSeriesProperty",
1073       description = "Updates a series property",
1074       returnDescription = "No content.",
1075       restParameters = {
1076           @RestParameter(name = "name", isRequired = true, description = "The property's name", type = TEXT),
1077           @RestParameter(name = "value", isRequired = true, description = "The property's value", type = TEXT)
1078       },
1079       pathParameters = {
1080           @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING)
1081       },
1082       responses = {
1083           @RestResponse(responseCode = SC_NOT_FOUND, description = "No series with this identifier was found."),
1084           @RestResponse(responseCode = SC_NO_CONTENT, description = "The access control list has been updated."),
1085           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
1086               + "perform this action"),
1087           @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required path or form params were missing "
1088               + "in the request.")
1089       })
1090   public Response updateSeriesProperty(@PathParam("seriesId") String seriesId, @FormParam("name") String name,
1091       @FormParam("value") String value) throws UnauthorizedException {
1092     if (StringUtils.isBlank(seriesId)) {
1093       logger.warn("Series id parameter is blank '{}'.", seriesId);
1094       return Response.status(BAD_REQUEST).build();
1095     }
1096     if (StringUtils.isBlank(name)) {
1097       logger.warn("Name parameter is blank '{}'.", name);
1098       return Response.status(BAD_REQUEST).build();
1099     }
1100     if (StringUtils.isBlank(value)) {
1101       logger.warn("Series id parameter is blank '{}'.", value);
1102       return Response.status(BAD_REQUEST).build();
1103     }
1104     try {
1105       seriesService.updateSeriesProperty(seriesId, name, value);
1106       return Response.status(NO_CONTENT).build();
1107     } catch (NotFoundException e) {
1108       return Response.status(NOT_FOUND).build();
1109     } catch (SeriesException e) {
1110       logger.warn("Could not update series property for series {} property {}:{}", seriesId, name, value, e);
1111     }
1112     throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1113   }
1114 
1115   @DELETE
1116   @Path("{seriesId}/property/{propertyName}")
1117   @RestQuery(
1118       name = "deleteSeriesProperty",
1119       description = "Deletes a series property",
1120       returnDescription = "No Content",
1121       pathParameters = {
1122           @RestParameter(name = "seriesId", description = "ID of series", isRequired = true, type = Type.STRING),
1123           @RestParameter(name = "propertyName", description = "Name of series property", isRequired = true,
1124               type = Type.STRING)
1125       },
1126       responses = {
1127           @RestResponse(responseCode = SC_NO_CONTENT, description = "The series property has been deleted."),
1128           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series or property has not been found."),
1129           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
1130               + "perform this action")
1131       })
1132   public Response deleteSeriesProperty(@PathParam("seriesId") String seriesId,
1133       @PathParam("propertyName") String propertyName) throws UnauthorizedException, NotFoundException {
1134     if (StringUtils.isBlank(seriesId)) {
1135       logger.warn("Series id parameter is blank '{}'.", seriesId);
1136       return Response.status(BAD_REQUEST).build();
1137     }
1138     if (StringUtils.isBlank(propertyName)) {
1139       logger.warn("Series property name parameter is blank '{}'.", propertyName);
1140       return Response.status(BAD_REQUEST).build();
1141     }
1142     try {
1143       seriesService.deleteSeriesProperty(seriesId, propertyName);
1144       return Response.status(NO_CONTENT).build();
1145     } catch (UnauthorizedException | NotFoundException e) {
1146       throw e;
1147     } catch (Exception e) {
1148       logger.warn("Could not delete series '{}' property '{}' query", seriesId, propertyName, e);
1149     }
1150     throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1151   }
1152 
1153   /**
1154    * Creates an ok response with the entity being the theme id and name.
1155    *
1156    * @param theme
1157    *          The theme to get the id and name from.
1158    * @return A {@link Response} with the theme id and name as json contents
1159    */
1160   private Response getSimpleThemeJsonResponse(Theme theme) {
1161     JsonObject json = new JsonObject();
1162     json.addProperty(Long.toString(theme.getId().get()), theme.getName());
1163     return okJson(json);
1164   }
1165 
1166   @GET
1167   @Produces(MediaType.APPLICATION_JSON)
1168   @Path("{seriesId}/theme.json")
1169   @RestQuery(
1170       name = "getSeriesTheme",
1171       description = "Returns the series theme id and name as JSON",
1172       returnDescription = "Returns the series theme name and id as JSON",
1173       pathParameters = {
1174           @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING)
1175       },
1176       responses = {
1177           @RestResponse(responseCode = SC_OK, description = "The series theme id and name as JSON."),
1178           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series or theme has not been found")
1179       })
1180   public Response getSeriesTheme(@PathParam("seriesId") String seriesId) {
1181     Long themeId;
1182     try {
1183       Optional<Series> series = searchIndex.getSeries(seriesId, securityService.getOrganization(),
1184           securityService.getUser());
1185       if (series.isEmpty()) {
1186         return notFound("Cannot find a series with id {}", seriesId);
1187       }
1188 
1189       themeId = series.get().getTheme();
1190     } catch (SearchIndexException e) {
1191       logger.error("Unable to get series {}", seriesId, e);
1192       throw new WebApplicationException(e);
1193     }
1194 
1195     // If no theme is set return empty JSON
1196     if (themeId == null) {
1197       return okJson(new JsonObject());
1198     }
1199 
1200     try {
1201       Theme theme = themesServiceDatabase.getTheme(themeId);
1202       return getSimpleThemeJsonResponse(theme);
1203     } catch (NotFoundException e) {
1204       return notFound("Cannot find a theme with id {}", themeId);
1205     } catch (ThemesServiceDatabaseException e) {
1206       logger.error("Unable to get theme {}", themeId, e);
1207       throw new WebApplicationException(e);
1208     }
1209   }
1210 
1211   @PUT
1212   @Path("{seriesId}/theme")
1213   @RestQuery(
1214       name = "updateSeriesTheme",
1215       description = "Update the series theme id",
1216       returnDescription = "Returns the id and name of the theme.",
1217       pathParameters = {
1218           @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING)
1219       },
1220       restParameters = {
1221           @RestParameter(name = "themeId", isRequired = true, type = RestParameter.Type.INTEGER, description = "The "
1222               + "id of the theme for this series")
1223       },
1224       responses = {
1225           @RestResponse(responseCode = SC_OK, description = "The series theme has been updated and the theme id and "
1226               + "name are returned as JSON."),
1227           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series or theme has not been found"),
1228           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
1229               + "perform this action")
1230       })
1231   public Response updateSeriesTheme(@PathParam("seriesId") String seriesID, @FormParam("themeId") long themeId)
1232           throws UnauthorizedException, NotFoundException {
1233     try {
1234       Theme theme = themesServiceDatabase.getTheme(themeId);
1235       seriesService.updateSeriesProperty(seriesID, THEME_KEY, Long.toString(themeId));
1236       return getSimpleThemeJsonResponse(theme);
1237     } catch (SeriesException e) {
1238       logger.error("Unable to update series theme {}", themeId, e);
1239       throw new WebApplicationException(e);
1240     } catch (ThemesServiceDatabaseException e) {
1241       logger.error("Unable to get theme {}", themeId, e);
1242       throw new WebApplicationException(e);
1243     }
1244   }
1245 
1246   @DELETE
1247   @Path("{seriesId}/theme")
1248   @RestQuery(
1249       name = "deleteSeriesTheme",
1250       description = "Removes the theme from the series",
1251       returnDescription = "Returns no content",
1252       pathParameters = {
1253           @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING)
1254       },
1255       responses = {
1256           @RestResponse(responseCode = SC_NO_CONTENT, description = "The series theme has been removed"),
1257           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series has not been found"),
1258           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
1259               + "perform this action")
1260       })
1261   public Response deleteSeriesTheme(@PathParam("seriesId") String seriesID) throws UnauthorizedException,
1262           NotFoundException {
1263     try {
1264       seriesService.deleteSeriesProperty(seriesID, THEME_KEY);
1265       return Response.noContent().build();
1266     } catch (SeriesException e) {
1267       logger.error("Unable to remove theme from series {}", seriesID, e);
1268       throw new WebApplicationException(e);
1269     }
1270   }
1271 
1272   @GET
1273   @Path("{seriesId}/tobira/pages")
1274   @RestQuery(
1275       name = "getSeriesHostPages",
1276       description = "Returns the pages of a connected Tobira instance that contain the given series",
1277       returnDescription = "The Tobira pages that contain the given series",
1278       pathParameters = {
1279           @RestParameter(
1280               name = "seriesId",
1281               isRequired = true,
1282               description = "The series identifier",
1283               type = STRING) },
1284       responses = {
1285           @RestResponse(
1286               responseCode = SC_OK,
1287               description = "The Tobira pages containing the given series"),
1288           @RestResponse(
1289               responseCode = SC_NOT_FOUND,
1290               description = "Tobira doesn't know about the given series"),
1291           @RestResponse(
1292               responseCode = SC_SERVICE_UNAVAILABLE,
1293               description = "Tobira is not configured (correctly)") })
1294 public Response getSeriesHostPages(@PathParam("seriesId") String seriesId) {
1295     var tobira = getTobira();
1296     if (!tobira.ready()) {
1297       return Response.status(Status.SERVICE_UNAVAILABLE)
1298               .entity("Tobira is not configured (correctly)")
1299               .build();
1300     }
1301 
1302     try {
1303       var seriesData = tobira.getHostPages(seriesId);
1304       if (seriesData == null) {
1305         throw new WebApplicationException(NOT_FOUND);
1306       }
1307       seriesData.put("baseURL", tobira.getOrigin());
1308       return Response.ok(seriesData.toJSONString()).build();
1309     } catch (TobiraException e) {
1310       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
1311     }
1312   }
1313 
1314   @POST
1315   @Path("{seriesId}/tobira/path")
1316   @RestQuery(
1317       name = "updateSeriesTobiraPath",
1318       description = "Updates the path of the given series in a connected Tobira instance",
1319       returnDescription = "Status code",
1320       pathParameters = {
1321           @RestParameter(
1322               name = "seriesId",
1323               isRequired = true,
1324               description = "The series id",
1325               type = STRING) },
1326       restParameters = {
1327           @RestParameter(
1328               name = "pathComponents",
1329               isRequired = true,
1330               description = "List of realms with name and path segment on path to series.",
1331               type = TEXT),
1332           @RestParameter(
1333               name = "currentPath",
1334               isRequired = false,
1335               description = "Path where the series is currently mounted.",
1336               type = STRING),
1337           @RestParameter(
1338               name = "targetPath",
1339               isRequired = true,
1340               description = "Path where the series will be mounted.",
1341               type = STRING) },
1342       responses = {
1343           @RestResponse(
1344               responseCode = SC_OK,
1345               description = "The path of the series has successfully been updated in Tobira."),
1346           @RestResponse(
1347               responseCode = SC_NOT_FOUND,
1348               description = "Tobira doesn't know about the given series"),
1349           @RestResponse(
1350               responseCode = SC_SERVICE_UNAVAILABLE,
1351               description = "Tobira is not configured (correctly)") })
1352   public Response updateSeriesTobiraPath(
1353       @PathParam("seriesId") String seriesId,
1354       @FormParam("pathComponents") String pathComponents,
1355       @FormParam("currentPath") String currentPath,
1356       @FormParam("targetPath") String targetPath
1357   ) throws IOException, InterruptedException {
1358     if (targetPath == null) {
1359       throw new WebApplicationException("target path is missing", BAD_REQUEST);
1360     }
1361 
1362     var tobira = getTobira();
1363     if (!tobira.ready()) {
1364       return Response.status(Status.SERVICE_UNAVAILABLE)
1365               .entity("Tobira is not configured (correctly)")
1366               .build();
1367     }
1368 
1369     try {
1370       var paths = (List<JSONObject>) new JSONParser().parse(pathComponents);
1371 
1372       var mountParams = new JSONObject();
1373       mountParams.put("seriesId", seriesId);
1374       mountParams.put("targetPath", targetPath);
1375 
1376       tobira.createRealmLineage(paths);
1377       tobira.addSeriesMountPoint(mountParams);
1378 
1379       if (currentPath != null && !currentPath.trim().isEmpty()) {
1380         var unmountParams = new JSONObject();
1381         unmountParams.put("seriesId", seriesId);
1382         unmountParams.put("currentPath", currentPath);
1383         tobira.removeSeriesMountPoint(unmountParams);
1384       }
1385 
1386       return ok();
1387     } catch (Exception e) {
1388       return Response.status(Status.INTERNAL_SERVER_ERROR)
1389               .entity("Internal server error: " + e.getMessage())
1390               .build();
1391     }
1392   }
1393 
1394   @DELETE
1395   @Path("{seriesId}/tobira/{currentPath}")
1396   @RestQuery(
1397       name = "removeSeriesTobiraPath",
1398       description = "Removes the path of the given series in a connected Tobira instance",
1399       returnDescription = "Status code",
1400       pathParameters = {
1401           @RestParameter(
1402               name = "seriesId",
1403               isRequired = true,
1404               description = "The series id",
1405               type = STRING),
1406           @RestParameter(
1407               name = "currentPath",
1408               isRequired = true,
1409               description = "URL encoded path where the series is currently mounted.",
1410               type = STRING) },
1411       responses = {
1412           @RestResponse(
1413               responseCode = SC_OK,
1414               description = "The path of the series has successfully been removed in Tobira."),
1415           @RestResponse(
1416               responseCode = SC_NOT_FOUND,
1417               description = "Tobira doesn't know about the given series"),
1418           @RestResponse(
1419               responseCode = SC_SERVICE_UNAVAILABLE,
1420               description = "Tobira is not configured (correctly)") })
1421   public Response removeSeriesTobiraPath(
1422       @PathParam("seriesId") String seriesId,
1423       @PathParam("currentPath") String currentPath
1424   ) throws IOException, InterruptedException {
1425     var tobira = getTobira();
1426     if (!tobira.ready()) {
1427       return Response.status(Status.SERVICE_UNAVAILABLE)
1428               .entity("Tobira is not configured (correctly)")
1429               .build();
1430     }
1431 
1432     try {
1433       if (currentPath != null && !currentPath.trim().isEmpty()) {
1434         var unmountParams = new JSONObject();
1435         unmountParams.put("seriesId", seriesId);
1436         unmountParams.put("currentPath", currentPath);
1437         tobira.removeSeriesMountPoint(unmountParams);
1438       }
1439 
1440       return ok();
1441     } catch (Exception e) {
1442       return Response.status(Status.INTERNAL_SERVER_ERROR)
1443               .entity("Internal server error: " + e.getMessage())
1444               .build();
1445     }
1446   }
1447 
1448   @POST
1449   @Path("/{seriesId}/access")
1450   @RestQuery(
1451       name = "applyAclToSeries",
1452       description = "Immediate application of an ACL to a series",
1453       returnDescription = "Status code",
1454       pathParameters = {
1455           @RestParameter(name = "seriesId", isRequired = true, description = "The series ID", type = STRING)
1456       },
1457       restParameters = {
1458           @RestParameter(name = "acl", isRequired = true, description = "The ACL to apply", type = STRING),
1459           @RestParameter(name = "override", isRequired = false, defaultValue = "false", description = "If true the "
1460               + "series ACL will take precedence over any existing episode ACL", type = BOOLEAN)
1461       },
1462       responses = {
1463           @RestResponse(responseCode = SC_OK, description = "The ACL has been successfully applied"),
1464           @RestResponse(responseCode = SC_BAD_REQUEST, description = "Unable to parse the given ACL"),
1465           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series has not been found"),
1466           @RestResponse(responseCode = SC_INTERNAL_SERVER_ERROR, description = "Internal error"),
1467           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to "
1468               + "perform this action")
1469       })
1470   public Response applyAclToSeries(@PathParam("seriesId") String seriesId, @FormParam("acl") String acl,
1471       @DefaultValue("false") @FormParam("override") boolean override) throws SearchIndexException {
1472 
1473     AccessControlList accessControlList;
1474     try {
1475       accessControlList = AccessControlParser.parseAcl(acl);
1476     } catch (Exception e) {
1477       logger.warn("Unable to parse ACL '{}'", acl);
1478       return badRequest();
1479     }
1480 
1481     if (!accessControlList.isValid()) {
1482       logger.debug("POST api/series/{}/access: Invalid series ACL detected", seriesId);
1483       return badRequest();
1484     }
1485 
1486     Optional<Series> series = searchIndex.getSeries(seriesId, securityService.getOrganization(),
1487         securityService.getUser());
1488     if (series.isEmpty()) {
1489       return notFound("Cannot find a series with id {}", seriesId);
1490     }
1491 
1492     if (hasProcessingEvents(seriesId)) {
1493       logger.warn("Can not update the ACL from series {}. Events being part of the series are currently processed.",
1494               seriesId);
1495       return conflict();
1496     }
1497 
1498     try {
1499       seriesService.updateAccessControl(seriesId, accessControlList, override);
1500       return ok();
1501     } catch (NotFoundException e) {
1502       logger.warn("Unable to find series '{}' to apply the ACL.", seriesId);
1503       return notFound();
1504     } catch (UnauthorizedException e) {
1505       return forbidden();
1506     } catch (SeriesException e) {
1507       logger.error("Error applying acl to series {}", seriesId);
1508       return serverError();
1509     }
1510   }
1511 
1512   /**
1513    * Check if the series with the given Id has events being currently processed
1514    *
1515    * @param seriesId
1516    *          the series Id
1517    * @return true if events being part of the series are currently processed
1518    */
1519   private boolean hasProcessingEvents(String seriesId) {
1520     EventSearchQuery query = new EventSearchQuery(securityService.getOrganization().getId(), securityService.getUser());
1521     long elementsCount = 0;
1522     query.withSeriesId(seriesId);
1523 
1524     try {
1525       query.withWorkflowState(WorkflowInstance.WorkflowState.RUNNING.toString());
1526       SearchResult<Event> events = searchIndex.getByQuery(query);
1527       elementsCount = events.getHitCount();
1528       query.withWorkflowState(WorkflowInstance.WorkflowState.INSTANTIATED.toString());
1529       events = searchIndex.getByQuery(query);
1530       elementsCount += events.getHitCount();
1531     } catch (SearchIndexException e) {
1532       logger.warn("Could not perform search query", e);
1533       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1534     }
1535 
1536     return elementsCount > 0;
1537   }
1538 
1539   @GET
1540   @Path("{seriesId}/hasEvents.json")
1541   @Produces(MediaType.APPLICATION_JSON)
1542   @RestQuery(
1543       name = "hasEvents",
1544       description = "Check if given series has events",
1545       returnDescription = "true if series has events, otherwise false",
1546       pathParameters = {
1547           @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier",
1548               type = Type.STRING)
1549       },
1550       responses = {
1551           @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required form params were missing in the "
1552               + "request."),
1553           @RestResponse(responseCode = SC_NOT_FOUND, description = "If the series has not been found."),
1554           @RestResponse(responseCode = SC_OK, description = "The access information ")
1555       })
1556   public Response getSeriesEvents(@PathParam("seriesId") String seriesId) throws Exception {
1557     if (StringUtils.isBlank(seriesId)) {
1558       return RestUtil.R.badRequest("Path parameter series ID is missing");
1559     }
1560 
1561     long elementsCount = 0;
1562 
1563     try {
1564       EventSearchQuery query = new EventSearchQuery(securityService.getOrganization().getId(),
1565           securityService.getUser());
1566       query.withSeriesId(seriesId);
1567       SearchResult<Event> result = searchIndex.getByQuery(query);
1568       elementsCount = result.getHitCount();
1569     } catch (SearchIndexException e) {
1570       logger.warn("Could not perform search query", e);
1571       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1572     }
1573 
1574     JSONObject jsonReturnObj = new JSONObject();
1575     jsonReturnObj.put("hasEvents", elementsCount > 0);
1576     return Response.ok(jsonReturnObj.toString()).build();
1577   }
1578 
1579   @GET
1580   @Path("configuration.json")
1581   @Produces(MediaType.APPLICATION_JSON)
1582   @RestQuery(
1583       name = "getseriesconfiguration",
1584       description = "Get the series configuration",
1585       returnDescription = "List of configuration keys",
1586       responses = {
1587           @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required form params were missing in the "
1588               + "request."),
1589           @RestResponse(responseCode = SC_NOT_FOUND, description = "If the series has not been found."),
1590           @RestResponse(responseCode = SC_OK, description = "The access information ")
1591       })
1592   public Response getSeriesOptions() {
1593     JSONObject jsonReturnObj = new JSONObject();
1594     jsonReturnObj.put("deleteSeriesWithEventsAllowed", deleteSeriesWithEventsAllowed);
1595     return Response.ok(jsonReturnObj.toString()).build();
1596   }
1597 
1598   public Boolean getOnlySeriesWithWriteAccessSeriesTab() {
1599     return onlySeriesWithWriteAccessSeriesTab;
1600   }
1601 
1602   public Boolean getOnlySeriesWithWriteAccessEventsFilter() {
1603     return onlySeriesWithWriteAccessEventsFilter;
1604   }
1605 }