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