1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 package org.opencastproject.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
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
203 private String serverUrl = "http://localhost:8080";
204
205
206 @Reference
207 public void setSeriesService(SeriesService seriesService) {
208 this.seriesService = seriesService;
209 }
210
211
212 @Reference
213 public void setIndex(ElasticsearchIndex index) {
214 this.searchIndex = index;
215 }
216
217
218 @Reference
219 public void setIndexService(IndexService indexService) {
220 this.indexService = indexService;
221 }
222
223
224 @Reference
225 public void setListProvidersService(ListProvidersService listProvidersService) {
226 this.listProvidersService = listProvidersService;
227 }
228
229
230 @Reference
231 public void setSecurityService(SecurityService securityService) {
232 this.securityService = securityService;
233 }
234
235
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
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
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
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
375
376
377
378
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
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
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
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
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
859
860
861
862
863
864
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
1009
1010
1011
1012
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
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
1320
1321
1322
1323
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
1376
1377
1378
1379
1380
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 }