PlaylistService.java

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community License, Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of the License
 * at:
 *
 *   http://opensource.org/licenses/ecl2.txt
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations under
 * the License.
 *
 */
package org.opencastproject.playlists;

import static org.opencastproject.security.api.SecurityConstants.GLOBAL_ADMIN_ROLE;

import org.opencastproject.elasticsearch.api.SearchIndexException;
import org.opencastproject.elasticsearch.api.SearchResult;
import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
import org.opencastproject.elasticsearch.index.objects.event.Event;
import org.opencastproject.elasticsearch.index.objects.event.EventSearchQuery;
import org.opencastproject.playlists.persistence.PlaylistDatabaseException;
import org.opencastproject.playlists.persistence.PlaylistDatabaseService;
import org.opencastproject.playlists.serialization.JaxbPlaylist;
import org.opencastproject.playlists.serialization.JaxbPlaylistEntry;
import org.opencastproject.security.api.AccessControlEntry;
import org.opencastproject.security.api.AccessControlList;
import org.opencastproject.security.api.AuthorizationService;
import org.opencastproject.security.api.Organization;
import org.opencastproject.security.api.Permissions;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.UnauthorizedException;
import org.opencastproject.security.api.User;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.requests.SortCriterion;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule;

import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Component(
    property = {
        "service.description=Playlist Service",
        "service.pid=org.opencastproject.playlists.PlaylistService"
    },
    immediate = true,
    service = { PlaylistService.class }
)
public class PlaylistService {
  /** Logging facility */
  private static final Logger logger = LoggerFactory.getLogger(PlaylistService.class);

  /** Persistent storage */
  protected PlaylistDatabaseService persistence;

  /** The security service */
  protected SecurityService securityService;

  /** The authorization service */
  protected AuthorizationService authorizationService = null;

  private ElasticsearchIndex elasticsearchIndex;

  /**
   * Callback to set the playlist database
   *
   * @param persistence
   *          the playlist database
   */
  @Reference(name = "playlist-persistence")
  public void setPersistence(PlaylistDatabaseService persistence) {
    this.persistence = persistence;
  }

  /**
   * OSGi callback to set the security service.
   *
   * @param securityService
   *          the securityService to set
   */
  @Reference(name = "security-service")
  public void setSecurityService(SecurityService securityService) {
    this.securityService = securityService;
  }

  /**
   * Callback for setting the authorization service.
   *
   * @param authorizationService
   *          the authorizationService to set
   */
  @Reference
  public void setAuthorizationService(AuthorizationService authorizationService) {
    this.authorizationService = authorizationService;
  }

  @Reference
  void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
    this.elasticsearchIndex = elasticsearchIndex;
  }

  @Activate
  public void activate(ComponentContext cc) throws Exception {
    logger.info("Activating Playlist Service");
  }

  /**
   * Returns a playlist from the database by its id
   * @param id playlist id
   * @return The {@link Playlist} belonging to the id
   * @throws NotFoundException If no playlist with the given id could be found
   * @throws IllegalStateException If something went wrong in the database service
   * @throws UnauthorizedException If the user does not have read access for the playlist
   */
  public Playlist getPlaylistById(String id) throws NotFoundException, IllegalStateException, UnauthorizedException {
    try {
      Playlist playlist = persistence.getPlaylist(id);
      if (!checkPermission(playlist, Permissions.Action.READ)) {
        throw new UnauthorizedException("User does not have read permissions");
      }
      return playlist;
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not get playlist from database with id ");
    }
  }

  /**
   * Get multiple playlists from the database
   * @param limit The maximum amount of playlists to get with one request.
   * @param offset The index of the first result to return.
   * @return A list of {@link Playlist}s
   * @throws IllegalStateException If something went wrong in the database service
   */
  public List<Playlist> getPlaylists(int limit, int offset) throws IllegalStateException {
    return getPlaylists(limit, offset, new SortCriterion("", SortCriterion.Order.None));
  }

  public List<Playlist> getPlaylists(int limit, int offset, SortCriterion sortCriterion)
          throws IllegalStateException {
    try {
      List<Playlist> playlists = persistence.getPlaylists(limit, offset, sortCriterion);
      playlists.removeIf(playlist -> !checkPermission(playlist, Permissions.Action.READ));
      return playlists;
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not get playlist from database with id ");
    }
  }

  public List<Playlist> getAllForAdministrativeRead(Date from, Date to, int limit)
          throws IllegalStateException, UnauthorizedException {
    final var user = securityService.getUser();
    final var orgAdminRole = securityService.getOrganization().getAdminRole();
    if (!user.hasRole(GLOBAL_ADMIN_ROLE) && !user.hasRole(orgAdminRole)) {
      throw new UnauthorizedException("Only (org-)admins can call this method");
    }

    try {
      return persistence.getAllForAdministrativeRead(from, to, limit);
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not get playlist from database", e);
    }
  }

  /**
   * Persist a new playlist in the database or update an existing one
   * @param playlist The {@link Playlist} to create or update with
   * @return The updated {@link Playlist}
   * @throws IllegalStateException If something went wrong in the database service
   * @throws UnauthorizedException If the user does not have write access for an existing playlist
   */
  public Playlist update(Playlist playlist)
          throws IllegalStateException, UnauthorizedException, IllegalArgumentException {
    try {
      Playlist existingPlaylist = persistence.getPlaylist(playlist.getId());
      if (!checkPermission(existingPlaylist, Permissions.Action.WRITE)) {
        throw new UnauthorizedException("User does not have write permissions");
      }

      // Validate entry IDs
      for (PlaylistEntry entry : playlist.getEntries()) {
        if (existingPlaylist.getEntries().stream().noneMatch(e -> entry.getId() == e.getId())) {
          if (entry.getId() != 0) {
            throw new IllegalArgumentException("When updating a playlist, entries should either have the id of an "
                + "existing entry, or no id at all.");
          }
        }
      }
      for (PlaylistAccessControlEntry entry : playlist.getAccessControlEntries()) {
        if (existingPlaylist.getAccessControlEntries().stream().noneMatch(e -> entry.getId() == e.getId())) {
          if (entry.getId() != 0) {
            throw new IllegalArgumentException("When updating a playlist, ACL entries should either have the id of an "
                + "existing entry, or no id at all.");
          }
        }
      }
    } catch (NotFoundException e) {
      // This means we are creating a new playlist
      for (PlaylistEntry entry : playlist.getEntries()) {
        if (entry.getId() != 0) {
          throw new IllegalArgumentException("Entries for new playlists should not have identifiers set");
        }
      }
      for (PlaylistAccessControlEntry entry : playlist.getAccessControlEntries()) {
        if (entry.getId() != 0) {
          throw new IllegalArgumentException("ACL Entries for new playlists should not have identifiers set");
        }
      }
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not get playlist from database with id ");
    }

    if (playlist.getOrganization() == null) {
      playlist.setOrganization(securityService.getOrganization().getId());
    }
    for (PlaylistEntry entry : playlist.getEntries()) {
      entry.setPlaylist(playlist);
    }
    for (PlaylistAccessControlEntry entry : playlist.getAccessControlEntries()) {
      entry.setPlaylist(playlist);
    }

    try {
      playlist = persistence.updatePlaylist(playlist, securityService.getOrganization().getId());
      return playlist;
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not update playlist from database with id ");
    }
  }

  /**
   * Overwrite an existing playlist with a playlist in JSON format.
   * Only fields present in the JSON will be overwritten! Conversely, if a field is not present in the JSON,
   * the field in the existing playlist will not change.
   * @param id Identifier of the playlist to update.
   * @param json JSON containing data to update the playlist with.
   * @return The updated {@link Playlist}
   */
  public Playlist updateWithJson(String id, String json) throws JsonProcessingException, UnauthorizedException {
    try {
      Playlist existingPlaylist = persistence.getPlaylist(id);
      if (!checkPermission(existingPlaylist, Permissions.Action.WRITE)) {
        throw new UnauthorizedException("User does not have write permissions");
      }

      JaxbAnnotationModule module = new JaxbAnnotationModule();
      ObjectMapper objectMapper = new ObjectMapper();
      objectMapper.registerModule(module);
      objectMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);

      ObjectReader updater = objectMapper.readerForUpdating(new JaxbPlaylist(existingPlaylist));
      JaxbPlaylist merged = updater.readValue(json);

      return update(merged.toPlaylist());
    } catch (NotFoundException | PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not get playlist from database with id " + id);
    }
  }

  /**
   * Deletes a playlist from the database
   * @param playlistId The playlist identifier
   * @return The removed {@link Playlist}
   * @throws NotFoundException If no playlist with the given id could be found
   * @throws IllegalStateException If something went wrong in the database service
   * @throws UnauthorizedException If the user does not have write access for the playlist
   */
  public Playlist remove(String playlistId)
          throws NotFoundException, IllegalStateException, UnauthorizedException {
    try {
      Playlist playlist = persistence.getPlaylist(playlistId);
      if (!checkPermission(playlist, Permissions.Action.WRITE)) {
        throw new UnauthorizedException("User does not have write permissions");
      }
      playlist = persistence.deletePlaylist(playlist, securityService.getOrganization().getId());
      return playlist;
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not delete playlist from database with id " + playlistId);
    }
  }

  /**
   * Replaces the entries in the playlist with the given entries
   * @param playlistId identifier of the playlist to modify
   * @param playlistEntries the new playlist entries
   * @return {@link Playlist} with the new entries
   * @throws NotFoundException If no playlist with the given id could be found
   * @throws IllegalStateException If something went wrong in the database service
   * @throws UnauthorizedException If the user does not have write access for the playlist
   */
  public Playlist updateEntries(String playlistId, List<PlaylistEntry> playlistEntries)
          throws NotFoundException, IllegalStateException, UnauthorizedException {
    Playlist playlist;
    try {
      playlist = persistence.getPlaylist(playlistId);
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException(e);
    }
    if (!checkPermission(playlist, Permissions.Action.WRITE)) {
      throw new UnauthorizedException("User does not have write permissions");
    }
    playlist.setEntries(playlistEntries);

    try {
      playlist = persistence.updatePlaylist(playlist, securityService.getOrganization().getId());

      return playlist;
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not delete playlist from database with id " + playlistId);
    }
  }

  /**
   * Adds a new entry at the end of a playlist and persists it
   * @param playlistId The playlist identifier
   * @param contentId content (e.g. mediapacakge) identifier
   * @param type arbitrary string
   * @return {@link Playlist} with the new entry
   * @throws NotFoundException If no playlist with the given id could be found
   * @throws IllegalStateException If something went wrong in the database service
   * @throws UnauthorizedException If the user does not have write access for the playlist
   */
  public Playlist addEntry(String playlistId, String contentId, PlaylistEntryType type)
          throws NotFoundException, IllegalStateException, UnauthorizedException {
    Playlist playlist;
    try {
      playlist = persistence.getPlaylist(playlistId);
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException(e);
    }
    if (!checkPermission(playlist, Permissions.Action.WRITE)) {
      throw new UnauthorizedException("User does not have write permissions");
    }
    PlaylistEntry playlistEntry = new PlaylistEntry();
    playlistEntry.setContentId(contentId);
    playlistEntry.setType(type);
    playlist.addEntry(playlistEntry);

    try {
      playlist = persistence.updatePlaylist(playlist, securityService.getOrganization().getId());

      return playlist;
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not delete playlist from database with id " + playlistId);
    }
  }

  /**
   * Removes an entry with the given id from the playlist and persists it
   * @param playlistId The playlist identifier
   * @param entryId The entry identifier
   * @return {@link Playlist} without the entry
   * @throws NotFoundException If no playlist with the given id could be found
   * @throws IllegalStateException If something went wrong in the database service
   * @throws UnauthorizedException If the user does not have write access for the playlist
   */
  public Playlist removeEntry(String playlistId, long entryId)
          throws NotFoundException, IllegalStateException, UnauthorizedException {
    Playlist playlist;
    try {
      playlist = persistence.getPlaylist(playlistId);
      if (!checkPermission(playlist, Permissions.Action.WRITE)) {
        throw new UnauthorizedException("User does not have write permissions");
      }
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException(e);
    }

    playlist.removeEntry(
        playlist.getEntries()
            .stream()
            .filter(e -> e.getId() == entryId)
            .findFirst()
            .get()
    );

    try {
      playlist = persistence.updatePlaylist(playlist, securityService.getOrganization().getId());
      return playlist;
    } catch (PlaylistDatabaseException e) {
      throw new IllegalStateException("Could not delete playlist from database with id " + playlistId);
    }
  }

  /**
   * Enrich each entry of a playlist with information about the content. Intended to be used by endpoints when
   * returning information about a playlist. Currently only adds publication information for entries of type EVENT.
   * @param playlist The playlist to enrich
   * @return The serialization class of the playlist, since the added information does not belong to the playlist
   * itself.
   */
  public JaxbPlaylist enrich(Playlist playlist) {
    var jaxbPlaylist = new JaxbPlaylist(playlist);
    var org = securityService.getOrganization().getId();
    var user = securityService.getUser();

    // Add additional infos about events
    List<JaxbPlaylistEntry> jaxbPlaylistEntries = jaxbPlaylist.getEntries();
    for (JaxbPlaylistEntry entry : jaxbPlaylistEntries) {
      String contentId = entry.getContentId();

      if (contentId == null || contentId.isEmpty()) {
        entry.setType(PlaylistEntryType.INACCESSIBLE);
        logger.warn("Entry {} has no content, marking as inaccessible", entry.getId());
        continue;
      }

      try {
        if (entry.getType() == PlaylistEntryType.EVENT) {

          // We only get an event from the index if we have permission to do so (and if it exists ofc)
          SearchResult<Event> result = elasticsearchIndex.getByQuery(
              new EventSearchQuery(org, user).withIdentifier(contentId));
          if (result.getPageSize() != 0) {
            Event event = result.getItems()[0].getSource();
            entry.setPublications(event.getPublications());
          } else {
            entry.setType(PlaylistEntryType.INACCESSIBLE);
          }
        }
      } catch (SearchIndexException e) {
        throw new RuntimeException(e);
      }
    }
    jaxbPlaylist.setEntries(jaxbPlaylistEntries);

    return jaxbPlaylist;
  }

  /**
   * Runs a permission check on the given playlist for the given action
   * @param playlist {@link Playlist} to check permission for
   * @param action Action to check permission for
   * @return True if action is permitted on the {@link Playlist}, else false
   */
  private boolean checkPermission(Playlist playlist, Permissions.Action action) {
    User currentUser = securityService.getUser();
    Organization currentOrg = securityService.getOrganization();
    String currentOrgAdminRole = currentOrg.getAdminRole();
    String currentOrgId = currentOrg.getId();

    return currentUser.hasRole(GLOBAL_ADMIN_ROLE)
        || (currentUser.hasRole(currentOrgAdminRole) && currentOrgId.equals(playlist.getOrganization()))
        || authorizationService.hasPermission(getAccessControlList(playlist), action.toString());
  }

  /**
   * Parse the access information for a playlist from its database format into an {@link AccessControlList}
   * @param playlist The {@link Playlist} to get the {@link AccessControlList} for
   * @return The {@link AccessControlList} for the given {@link Playlist}
   */
  private AccessControlList getAccessControlList(Playlist playlist) {
    List<AccessControlEntry> accessControlEntries = new ArrayList<>();
    for (PlaylistAccessControlEntry entry : playlist.getAccessControlEntries()) {
      accessControlEntries.add(entry.toAccessControlEntry());
    }
    return new AccessControlList(accessControlEntries);
  }
}