SearchServiceDatabaseImpl.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.search.impl.persistence;

import static org.opencastproject.db.Queries.namedQuery;
import static org.opencastproject.security.api.Permissions.Action.CONTRIBUTE;
import static org.opencastproject.security.api.Permissions.Action.READ;
import static org.opencastproject.security.api.Permissions.Action.WRITE;
import static org.opencastproject.security.api.SecurityConstants.GLOBAL_CAPTURE_AGENT_ROLE;

import org.opencastproject.db.DBSession;
import org.opencastproject.db.DBSessionFactory;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.mediapackage.MediaPackageParser;
import org.opencastproject.security.api.AccessControlList;
import org.opencastproject.security.api.AccessControlParser;
import org.opencastproject.security.api.AccessControlParsingException;
import org.opencastproject.security.api.AccessControlUtil;
import org.opencastproject.security.api.Organization;
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.data.Tuple;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
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.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Stream;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.TypedQuery;

/**
 * Implements {@link SearchServiceDatabase}. Defines permanent storage for series.
 */
@Component(
    immediate = true,
    service = SearchServiceDatabase.class,
    property = {
        "service.description=Search Service Persistence"
    }
)
public class SearchServiceDatabaseImpl implements SearchServiceDatabase {

  /** JPA persistence unit name */
  public static final String PERSISTENCE_UNIT = "org.opencastproject.search.impl.persistence";

  /** Logging utilities */
  private static final Logger logger = LoggerFactory.getLogger(SearchServiceDatabaseImpl.class);

  /** Factory used to create {@link EntityManager}s for transactions */
  protected EntityManagerFactory emf;

  protected DBSessionFactory dbSessionFactory;

  protected DBSession db;

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

  /** OSGi DI */
  @Reference(target = "(osgi.unit.name=org.opencastproject.search.impl.persistence)")
  public void setEntityManagerFactory(EntityManagerFactory emf) {
    this.emf = emf;
  }

  @Reference
  public void setDBSessionFactory(DBSessionFactory dbSessionFactory) {
    this.dbSessionFactory = dbSessionFactory;
  }

  /**
   * Creates {@link EntityManagerFactory} using persistence provider and properties passed via OSGi.
   *
   * @param cc
   * @throws SearchServiceDatabaseException
   */
  @Activate
  public void activate(ComponentContext cc) throws SearchServiceDatabaseException {
    logger.info("Activating persistence manager for search service");
    db = dbSessionFactory.createSession(emf);
    this.populateSeriesData();
  }

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

  private void populateSeriesData() throws SearchServiceDatabaseException {
    try {
      db.execTxChecked(em -> {
        TypedQuery<SearchEntity> q = em.createNamedQuery("Search.getNoSeries", SearchEntity.class);
        List<SearchEntity> seriesList = q.getResultList();
        for (SearchEntity series : seriesList) {
          String mpSeriesId = MediaPackageParser.getFromXml(series.getMediaPackageXML()).getSeries();
          if (StringUtils.isNotBlank(mpSeriesId) && !mpSeriesId.equals(series.getSeriesId())) {
            logger.info("Fixing missing series ID for episode {}, series is {}", series.getMediaPackageId(),
                mpSeriesId);
            series.setSeriesId(mpSeriesId);
            em.merge(series);
          }
        }
      });
    } catch (Exception e) {
      logger.error("Could not update media package: {}", e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#deleteMediaPackage(String, Date)
   */
  @Override
  public void deleteMediaPackage(String mediaPackageId, Date deletionDate) throws SearchServiceDatabaseException,
          NotFoundException, UnauthorizedException {
    try {
      db.execTxChecked(em -> {
        Optional<SearchEntity> searchEntity = getSearchEntityQuery(mediaPackageId).apply(em);
        if (searchEntity.isEmpty()) {
          throw new NotFoundException("No media package with id=" + mediaPackageId + " exists");
        }

        // Ensure this user is allowed to delete this episode
        User currentUser = securityService.getUser();
        Organization currentOrg = securityService.getOrganization();
        MediaPackage searchMp = MediaPackageParser.getFromXml(searchEntity.get().getMediaPackageXML());
        String accessControlXml = searchEntity.get().getAccessControl();

        // allow ca users to retract live publications without putting them into the ACL
        if (!(searchMp.isLive() && currentUser.hasRole(GLOBAL_CAPTURE_AGENT_ROLE)) && accessControlXml != null) {
          AccessControlList acl = AccessControlParser.parseAcl(accessControlXml);
          if (!AccessControlUtil.isAuthorized(acl, currentUser, currentOrg, WRITE.toString(), mediaPackageId)) {
            throw new UnauthorizedException(
                currentUser + " is not authorized to delete media package " + mediaPackageId);
          }
        }

        searchEntity.get().setDeletionDate(deletionDate);
        searchEntity.get().setModificationDate(deletionDate);
        em.merge(searchEntity.get());
      });
    } catch (NotFoundException | UnauthorizedException e) {
      throw e;
    } catch (Exception e) {
      logger.error("Could not delete episode {}: {}", mediaPackageId, e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#countMediaPackages()
   */
  @Override
  public int countMediaPackages() throws SearchServiceDatabaseException {
    try {
      return db.exec(namedQuery.find("Search.getCount", Long.class)).intValue();
    } catch (Exception e) {
      logger.error("Could not find number of mediapackages", e);
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getAllMediaPackages(int, int)
   */
  @Override
  public Stream<Tuple<MediaPackage, String>> getAllMediaPackages(int pagesize, int offset)
          throws SearchServiceDatabaseException {
    List<SearchEntity> searchEntities;
    try {
      int firstResult = pagesize * offset;
      searchEntities = db.exec(namedQuery.findSome("Search.findAll", firstResult, pagesize, SearchEntity.class));
    } catch (Exception e) {
      logger.error("Could not retrieve all episodes: {}", e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }

    try {
      return searchEntities.stream()
            .map(entity -> {
              try {
                MediaPackage mediaPackage = MediaPackageParser.getFromXml(entity.getMediaPackageXML());
                return Tuple.tuple(mediaPackage, entity.getOrganization().getId());
              } catch (Exception e) {
                logger.error("Could not parse series entity: {}", e.getMessage());
                throw new RuntimeException(e);
              }
            });
    } catch (Exception e) {
      logger.error("Could not parse series entity: {}", e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getAccessControlList(String)
   */
  @Override
  public AccessControlList getAccessControlList(String mediaPackageId) throws NotFoundException,
          SearchServiceDatabaseException {
    try {
      Optional<SearchEntity> entity = db.exec(getSearchEntityQuery(mediaPackageId));
      if (entity.isEmpty()) {
        throw new NotFoundException("Could not found media package with ID " + mediaPackageId);
      }
      if (entity.get().getAccessControl() == null) {
        return null;
      } else {
        return AccessControlParser.parseAcl(entity.get().getAccessControl());
      }
    } catch (NotFoundException e) {
      throw e;
    } catch (Exception e) {
      logger.error("Could not retrieve ACL {}", mediaPackageId, e);
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getAccessControlLists(String, String...)
   */
  @Override
  public Collection<Pair<String, AccessControlList>> getAccessControlLists(final String seriesId, String ... excludeIds)
          throws SearchServiceDatabaseException {
    List<String> excludes = Arrays.asList(excludeIds);
    List<Pair<String,AccessControlList>> accessControlLists = new ArrayList<>();
    try {
      List<SearchEntity> result = db.exec(namedQuery.findAll(
          "Search.findBySeriesId",
          SearchEntity.class,
          Pair.of("seriesId", seriesId)
      ));
      for (SearchEntity entity: result) {
        if (entity.getAccessControl() != null && !excludes.contains(entity.getMediaPackageId())) {
          accessControlLists.add(Pair.of(
              entity.getMediaPackageId(),
              AccessControlParser.parseAcl(entity.getAccessControl()))
          );
        }
      }
    } catch (IOException | AccessControlParsingException e) {
      throw new SearchServiceDatabaseException(e);
    }
    return accessControlLists;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getSeries(String)
   */
  public Collection<Pair<Organization, MediaPackage>> getSeries(final String seriesId)
          throws SearchServiceDatabaseException {
    List<Pair<Organization, MediaPackage>> episodes = new ArrayList<>();
    EntityManager em = emf.createEntityManager();
    TypedQuery<SearchEntity> q = em.createNamedQuery("Search.findBySeriesId", SearchEntity.class)
        .setParameter("seriesId", seriesId);
    try {
      for (SearchEntity entity: q.getResultList()) {
        if (entity.getMediaPackageXML() != null) {
          episodes.add(Pair.of(
              entity.getOrganization(),
              MediaPackageParser.getFromXml(entity.getMediaPackageXML())));
        }
      }
    } catch (MediaPackageException e) {
      throw new SearchServiceDatabaseException(e);
    } finally {
      em.close();
    }
    return episodes;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#storeMediaPackage(MediaPackage,
   *      AccessControlList, Date)
   */
  @Override
  public void storeMediaPackage(MediaPackage mediaPackage, AccessControlList acl, Date now)
          throws SearchServiceDatabaseException, UnauthorizedException {
    String mediaPackageXML = MediaPackageParser.getAsXml(mediaPackage);
    String mediaPackageId = mediaPackage.getIdentifier().toString();
    try {
      db.execTxChecked(em -> {
        Optional<SearchEntity> entity = getSearchEntityQuery(mediaPackageId).apply(em);
        if (entity.isEmpty()) {
          // Create new search entity
          SearchEntity searchEntity = new SearchEntity();
          searchEntity.setOrganization(securityService.getOrganization());
          searchEntity.setMediaPackageId(mediaPackageId);
          searchEntity.setMediaPackageXML(mediaPackageXML);
          searchEntity.setAccessControl(AccessControlParser.toXml(acl));
          searchEntity.setModificationDate(now);
          searchEntity.setSeriesId(mediaPackage.getSeries());
          em.persist(searchEntity);
        } else {
          // Ensure this user is allowed to update this media package
          // If user has ROLE_EPISODE_<ID>_WRITE, no further permission checks are necessary
          String accessControlXml = entity.get().getAccessControl();
          if (accessControlXml != null && entity.get().getDeletionDate() == null) {
            AccessControlList accessList = AccessControlParser.parseAcl(accessControlXml);
            User currentUser = securityService.getUser();
            Organization currentOrg = securityService.getOrganization();
            if (!AccessControlUtil.isAuthorized(accessList, currentUser, currentOrg, WRITE.toString(),
                mediaPackageId)) {
              throw new UnauthorizedException(currentUser + " is not authorized to update media package "
                  + mediaPackageId);
            }
          }
          entity.get().setOrganization(securityService.getOrganization());
          entity.get().setMediaPackageId(mediaPackageId);
          entity.get().setMediaPackageXML(mediaPackageXML);
          entity.get().setAccessControl(AccessControlParser.toXml(acl));
          entity.get().setModificationDate(now);
          entity.get().setDeletionDate(null);
          entity.get().setSeriesId(mediaPackage.getSeries());
          em.merge(entity.get());
        }
      });
    } catch (UnauthorizedException e) {
      throw e;
    } catch (Exception e) {
      logger.error("Could not update media package: {}", e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getMediaPackage(String)
   */
  @Override
  public MediaPackage getMediaPackage(String mediaPackageId)
          throws NotFoundException, SearchServiceDatabaseException, UnauthorizedException {
    try {
      return db.execTxChecked(em -> {
        Optional<SearchEntity> episodeEntity = getSearchEntityQuery(mediaPackageId).apply(em);
        if (episodeEntity.isEmpty() || episodeEntity.get().getDeletionDate() != null) {
          throw new NotFoundException("No episode with id=" + mediaPackageId + " exists");
        }

        String accessControlXml = episodeEntity.get().getAccessControl();
        if (accessControlXml != null) {
          AccessControlList acl = AccessControlParser.parseAcl(accessControlXml);
          User currentUser = securityService.getUser();
          Organization currentOrg = securityService.getOrganization();
          // There are several reasons a user may need to load a episode: to read content, to edit it, or add content
          if (!AccessControlUtil.isAuthorized(acl, currentUser, currentOrg, READ.toString(), mediaPackageId)
                  && !AccessControlUtil.isAuthorized(acl, currentUser, currentOrg, CONTRIBUTE.toString(),
              mediaPackageId)
                  && !AccessControlUtil.isAuthorized(acl, currentUser, currentOrg, WRITE.toString(), mediaPackageId)) {
            throw new UnauthorizedException(currentUser + " is not authorized to see episode " + mediaPackageId);
          }
        }
        return MediaPackageParser.getFromXml(episodeEntity.get().getMediaPackageXML());
      });
    } catch (NotFoundException | UnauthorizedException e) {
      throw e;
    } catch (Exception e) {
      logger.error("Could not get episode {} from database: {} ", mediaPackageId, e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getModificationDate(String)
   */
  @Override
  public Date getModificationDate(String mediaPackageId) throws NotFoundException, SearchServiceDatabaseException {
    try {
      return db.execTxChecked(em -> {
        Optional<SearchEntity> searchEntity = getSearchEntityQuery(mediaPackageId).apply(em);
        if (searchEntity.isEmpty()) {
          throw new NotFoundException("No media package with id=" + mediaPackageId + " exists");
        }
        // Ensure this user is allowed to read this media package
        String accessControlXml = searchEntity.get().getAccessControl();
        if (accessControlXml != null) {
          AccessControlList acl = AccessControlParser.parseAcl(accessControlXml);
          User currentUser = securityService.getUser();
          Organization currentOrg = securityService.getOrganization();
          if (!AccessControlUtil.isAuthorized(acl, currentUser, currentOrg, READ.toString(), mediaPackageId)) {
            throw new UnauthorizedException(
                currentUser + " is not authorized to read media package " + mediaPackageId);
          }
        }
        return searchEntity.get().getModificationDate();
      });
    } catch (NotFoundException e) {
      throw e;
    } catch (Exception e) {
      logger.error("Could not get modification date {}: {}", mediaPackageId, e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getDeletionDate(String)
   */
  @Override
  public Date getDeletionDate(String mediaPackageId) throws NotFoundException, SearchServiceDatabaseException {
    try {
      return db.execTxChecked(em -> {
        Optional<SearchEntity> searchEntity = getSearchEntityQuery(mediaPackageId).apply(em);
        if (searchEntity.isEmpty()) {
          throw new NotFoundException("No media package with id=" + mediaPackageId + " exists");
        }
        // Ensure this user is allowed to read this media package
        String accessControlXml = searchEntity.get().getAccessControl();
        if (accessControlXml != null) {
          AccessControlList acl = AccessControlParser.parseAcl(accessControlXml);
          User currentUser = securityService.getUser();
          Organization currentOrg = securityService.getOrganization();
          if (!AccessControlUtil.isAuthorized(acl, currentUser, currentOrg, READ.toString(), mediaPackageId)) {
            throw new UnauthorizedException(
                currentUser + " is not authorized to read media package " + mediaPackageId);
          }
        }
        return searchEntity.get().getDeletionDate();
      });
    } catch (NotFoundException e) {
      throw e;
    } catch (Exception e) {
      logger.error("Could not get deletion date {}: {}", mediaPackageId, e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#isAvailable(String)
   */
  public boolean isAvailable(String mediaPackageId) throws SearchServiceDatabaseException {
    try {
      return db.execTxChecked(em -> {
        Optional<SearchEntity> searchEntity = getSearchEntityQuery(mediaPackageId).apply(em);
        return searchEntity.stream().anyMatch(entity -> entity.getDeletionDate() == null);
      });
    } catch (Exception e) {
      logger.error("Error while checking if mediapackage {} exists in database: {}", mediaPackageId, e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getOrganizationId(String)
   */
  @Override
  public String getOrganizationId(String mediaPackageId) throws NotFoundException, SearchServiceDatabaseException {
    try {
      return db.execTxChecked(em -> {
        Optional<SearchEntity> searchEntity = getSearchEntityQuery(mediaPackageId).apply(em);
        if (searchEntity.isEmpty()) {
          throw new NotFoundException("No media package with id=" + mediaPackageId + " exists");
        }
        // Ensure this user is allowed to read this media package
        String accessControlXml = searchEntity.get().getAccessControl();
        if (accessControlXml != null) {
          AccessControlList acl = AccessControlParser.parseAcl(accessControlXml);
          User currentUser = securityService.getUser();
          Organization currentOrg = securityService.getOrganization();
          if (!AccessControlUtil.isAuthorized(acl, currentUser, currentOrg, READ.toString(), mediaPackageId)) {
            throw new UnauthorizedException(
                currentUser + " is not authorized to read media package " + mediaPackageId);
          }
        }
        return searchEntity.get().getOrganization().getId();
      });
    } catch (NotFoundException e) {
      throw e;
    } catch (Exception e) {
      logger.error("Could not get deletion date {}: {}", mediaPackageId, e.getMessage());
      throw new SearchServiceDatabaseException(e);
    }
  }

  /**
   * Gets a search entity by it's id, using the current organizational context.
   *
   * @param id
   *          the media package identifier
   * @return the search entity, or null if not found
   */
  private Function<EntityManager, Optional<SearchEntity>> getSearchEntityQuery(String id) {
    return namedQuery.findOpt(
        "Search.findById",
        SearchEntity.class,
        Pair.of("mediaPackageId", id)
    );
  }
}