/** * 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.distribution.download; import static java.lang.String.format; import static org.opencastproject.util.EqualsUtil.ne; import static org.opencastproject.util.HttpUtil.waitForResource; import static org.opencastproject.util.PathSupport.path; import static org.opencastproject.util.RequireUtil.notNull; import org.opencastproject.distribution.api.AbstractDistributionService; import org.opencastproject.distribution.api.DistributionException; import org.opencastproject.distribution.api.DistributionService; import org.opencastproject.distribution.api.DownloadDistributionService; import org.opencastproject.job.api.Job; import org.opencastproject.mediapackage.MediaPackage; import org.opencastproject.mediapackage.MediaPackageElement; import org.opencastproject.mediapackage.MediaPackageElementParser; import org.opencastproject.mediapackage.MediaPackageException; import org.opencastproject.mediapackage.MediaPackageParser; import org.opencastproject.serviceregistry.api.ServiceRegistryException; import org.opencastproject.util.FileSupport; import org.opencastproject.util.LoadUtil; import org.opencastproject.util.NotFoundException; import org.opencastproject.util.OsgiUtil; import org.opencastproject.util.UrlSupport; import org.opencastproject.util.data.Effect; import org.opencastproject.util.data.functions.Misc; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedService; import org.osgi.service.component.ComponentContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.DirectoryStream; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.Dictionary; import java.util.HashSet; import java.util.List; import java.util.Set; import javax.servlet.http.HttpServletResponse; /** * Distributes media to the local media delivery directory. */ public class DownloadDistributionServiceImpl extends AbstractDistributionService implements DistributionService, DownloadDistributionService, ManagedService { /** Logging facility */ private static final Logger logger = LoggerFactory.getLogger(DownloadDistributionServiceImpl.class); /** List of available operations on jobs */ private enum Operation { Distribute, Retract } /** Receipt type */ public static final String JOB_TYPE = "org.opencastproject.distribution.download"; /** Timeout in millis for checking distributed file request */ private static final long TIMEOUT = 60000L; /** The load on the system introduced by creating a distribute job */ public static final float DEFAULT_DISTRIBUTE_JOB_LOAD = 0.1f; /** The load on the system introduced by creating a retract job */ public static final float DEFAULT_RETRACT_JOB_LOAD = 1.0f; /** The key to look for in the service configuration file to override the {@link DEFAULT_DISTRIBUTE_JOB_LOAD} */ public static final String DISTRIBUTE_JOB_LOAD_KEY = "job.load.download.distribute"; /** The key to look for in the service configuration file to override the {@link DEFAULT_RETRACT_JOB_LOAD} */ public static final String RETRACT_JOB_LOAD_KEY = "job.load.download.retract"; /** The load on the system introduced by creating a distribute job */ private float distributeJobLoad = DEFAULT_DISTRIBUTE_JOB_LOAD; /** The load on the system introduced by creating a retract job */ private float retractJobLoad = DEFAULT_RETRACT_JOB_LOAD; /** Interval time in millis for checking distributed file request */ private static final long INTERVAL = 300L; private Gson gson = new Gson(); /** * Creates a new instance of the download distribution service. */ public DownloadDistributionServiceImpl() { super(JOB_TYPE); } /** * Activate method for this OSGi service implementation. * * @param cc * the OSGi component context */ @Override public void activate(ComponentContext cc) { super.activate(cc); serviceUrl = cc.getBundleContext().getProperty("org.opencastproject.download.url"); if (serviceUrl == null) throw new IllegalStateException("Download url must be set (org.opencastproject.download.url)"); logger.info("Download url is {}", serviceUrl); String ccDistributionDirectory = cc.getBundleContext().getProperty("org.opencastproject.download.directory"); if (ccDistributionDirectory == null) throw new IllegalStateException("Distribution directory must be set (org.opencastproject.download.directory)"); this.distributionDirectory = new File(ccDistributionDirectory); logger.info("Download distribution directory is {}", distributionDirectory); this.distributionChannel = OsgiUtil.getComponentContextProperty(cc, CONFIG_KEY_STORE_TYPE); } public String getDistributionType() { return this.distributionChannel; } @Override public Job distribute(String channelId, MediaPackage mediapackage, String elementId) throws DistributionException, MediaPackageException { return distribute(channelId, mediapackage, elementId, true); } @Override public Job distribute(String channelId, MediaPackage mediapackage, String elementId, boolean checkAvailability) throws DistributionException, MediaPackageException { Set<String> elementIds = new HashSet<String>(); elementIds.add(elementId); return distribute(channelId, mediapackage, elementIds, checkAvailability); } @Override public Job distribute(String channelId, MediaPackage mediapackage, Set<String> elementIds, boolean checkAvailability) throws DistributionException, MediaPackageException { notNull(mediapackage, "mediapackage"); notNull(elementIds, "elementIds"); notNull(channelId, "channelId"); try { return serviceRegistry.createJob( JOB_TYPE, Operation.Distribute.toString(), Arrays.asList(channelId, MediaPackageParser.getAsXml(mediapackage), gson.toJson(elementIds), Boolean.toString(checkAvailability)), distributeJobLoad); } catch (ServiceRegistryException e) { throw new DistributionException("Unable to create a job", e); } } /** * Distribute Mediapackage elements to the download distribution service. * * @param channelId # The id of the publication channel to be distributed to. * @param mediapackage * The media package that contains the elements to be distributed. * @param elementIds * The ids of the elements that should be distributed contained within the media package. * @param checkAvailability * Check the availability of the distributed element via http. * @return A reference to the MediaPackageElements that have been distributed. * @throws DistributionException * Thrown if the parent directory of the MediaPackageElement cannot be created, if the MediaPackageElement * cannot be copied or another unexpected exception occurs. */ public MediaPackageElement[] distributeElements(String channelId, MediaPackage mediapackage, Set<String> elementIds, boolean checkAvailability) throws DistributionException { notNull(mediapackage, "mediapackage"); notNull(elementIds, "elementIds"); notNull(channelId, "channelId"); final Set<MediaPackageElement> elements = getElements(mediapackage, elementIds); List<MediaPackageElement> distributedElements = new ArrayList<MediaPackageElement>(); for (MediaPackageElement element : elements) { MediaPackageElement distributedElement = distributeElement(channelId, mediapackage, element, checkAvailability); distributedElements.add(distributedElement); } return distributedElements.toArray(new MediaPackageElement[distributedElements.size()]); } /** * Distribute a Mediapackage element to the download distribution service. * * @param channelId # The id of the publication channel to be distributed to. * @param mediapackage * The media package that contains the element to be distributed. * @param element * The the element that should be distributed contained within the media package. * @param checkAvailability * Check the availability of the distributed element via http. * @return A reference to the MediaPackageElement that has been distributed. * @throws DistributionException * Thrown if the parent directory of the MediaPackageElement cannot be created, if the MediaPackageElement * cannot be copied or another unexpected exception occurs. */ public MediaPackageElement distributeElement(String channelId, MediaPackage mediapackage, MediaPackageElement element, boolean checkAvailability) throws DistributionException { final String mediapackageId = mediapackage.getIdentifier().compact(); final String elementId = element.getIdentifier(); try { File source; try { source = workspace.get(element.getURI()); } catch (NotFoundException e) { throw new DistributionException("Unable to find " + element.getURI() + " in the workspace", e); } catch (IOException e) { throw new DistributionException("Error loading " + element.getURI() + " from the workspace", e); } // Try to find a duplicated element source try { source = findDuplicatedElementSource(source, mediapackageId); } catch (IOException e) { logger.warn("Unable to find duplicated source {}: {}", source, ExceptionUtils.getMessage(e)); } File destination = getDistributionFile(channelId, mediapackage, element); if (!destination.equals(source)) { // Put the file in place if sourcesfile differs destinationfile try { FileUtils.forceMkdir(destination.getParentFile()); } catch (IOException e) { throw new DistributionException("Unable to create " + destination.getParentFile(), e); } logger.debug("Distributing element {} of media package {} to publication channel {} ({})", elementId, mediapackageId, channelId, destination); try { FileSupport.link(source, destination, true); } catch (IOException e) { throw new DistributionException(format("Unable to copy %s to %s", source, destination), e); } } // Create a media package element representation of the distributed file MediaPackageElement distributedElement = (MediaPackageElement) element.clone(); try { distributedElement.setURI(getDistributionUri(channelId, mediapackageId, element)); } catch (URISyntaxException e) { throw new DistributionException("Distributed element produces an invalid URI", e); } logger.debug("Finished distributing element {} of media package {} to publication channel {}", elementId, mediapackageId, channelId); final URI uri = distributedElement.getURI(); if (checkAvailability) { logger.debug("Checking availability of distributed artifact {} at {}", distributedElement, uri); waitForResource(trustedHttpClient, uri, HttpServletResponse.SC_OK, TIMEOUT, INTERVAL) .fold(Misc.<Exception, Void> chuck(), new Effect.X<Integer>() { @Override public void xrun(Integer status) throws Exception { if (ne(status, HttpServletResponse.SC_OK)) { logger.warn("Attempt to access distributed file {} returned code {}", uri, status); throw new DistributionException("Unable to load distributed file " + uri.toString()); } } }); } return distributedElement; } catch (Exception e) { logger.warn("Error distributing " + element, e); if (e instanceof DistributionException) { throw (DistributionException) e; } else { throw new DistributionException(e); } } } @Override public Job retract(String channelId, MediaPackage mediapackage, String elementId) throws DistributionException { Set<String> elementIds = new HashSet(); elementIds.add(elementId); return retract(channelId, mediapackage, elementIds); } @Override public Job retract(String channelId, MediaPackage mediapackage, Set<String> elementIds) throws DistributionException { notNull(mediapackage, "mediapackage"); notNull(elementIds, "elementIds"); notNull(channelId, "channelId"); try { return serviceRegistry.createJob(JOB_TYPE, Operation.Retract.toString(), Arrays.asList(channelId, MediaPackageParser.getAsXml(mediapackage), gson.toJson(elementIds)), retractJobLoad); } catch (ServiceRegistryException e) { throw new DistributionException("Unable to create a job", e); } } /** * Retract a media package element from the distribution channel. The retracted element must not necessarily be the * one given as parameter <code>elementId</code>. Instead, the element's distribution URI will be calculated. This way * you are able to retract elements by providing the "original" element here. * * @param channelId * the channel id * @param mediapackage * the mediapackage * @param elementIds * the element identifiers * @return the retracted element or <code>null</code> if the element was not retracted * @throws org.opencastproject.distribution.api.DistributionException * in case of an error */ protected MediaPackageElement[] retractElements(String channelId, MediaPackage mediapackage, Set<String> elementIds) throws DistributionException { notNull(mediapackage, "mediapackage"); notNull(elementIds, "elementIds"); notNull(channelId, "channelId"); Set<MediaPackageElement> elements = getElements(mediapackage, elementIds); List<MediaPackageElement> retractedElements = new ArrayList<MediaPackageElement>(); for (MediaPackageElement element : elements) { MediaPackageElement retractedElement = retractElement(channelId, mediapackage, element); retractedElements.add(retractedElement); } return retractedElements.toArray(new MediaPackageElement[retractedElements.size()]); } /** * Retract a media package element from the distribution channel. The retracted element must not necessarily be the * one given as parameter <code>elementId</code>. Instead, the element's distribution URI will be calculated. This way * you are able to retract elements by providing the "original" element here. * * @param channelId * the channel id * @param mediapackage * the mediapackage * @param element * the element * @return the retracted element or <code>null</code> if the element was not retracted * @throws org.opencastproject.distribution.api.DistributionException * in case of an error */ protected MediaPackageElement retractElement(String channelId, MediaPackage mediapackage, MediaPackageElement element) throws DistributionException { notNull(mediapackage, "mediapackage"); notNull(element, "element"); notNull(channelId, "channelId"); String mediapackageId = mediapackage.getIdentifier().compact(); String elementId = element.getIdentifier(); try { final File elementFile = getDistributionFile(channelId, mediapackage, element); final File mediapackageDir = getMediaPackageDirectory(channelId, mediapackage); // Does the file exist? If not, the current element has not been distributed to this channel // or has been removed otherwise if (!elementFile.exists()) { logger.info("Element {} from media package {} has already been removed or has never been distributed to " + "publication channel {}", elementId, mediapackageId, channelId); return element; } logger.debug("Retracting element {} ({})", element, elementFile); // Try to remove the file and its parent folder representing the mediapackage element id FileUtils.forceDelete(elementFile.getParentFile()); if (mediapackageDir.isDirectory() && mediapackageDir.list().length == 0) FileSupport.delete(mediapackageDir); logger.debug("Finished retracting element {} of media package {} from publication channel {}", elementId, mediapackageId, channelId); return element; } catch (Exception e) { logger.warn("Error retracting element {} of media package {} from publication channel {}", elementId, mediapackageId, channelId, e); if (e instanceof DistributionException) { throw (DistributionException) e; } else { throw new DistributionException(e); } } } /** * {@inheritDoc} * * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job) */ @Override protected String process(Job job) throws Exception { Operation op = null; String operation = job.getOperation(); List<String> arguments = job.getArguments(); try { op = Operation.valueOf(operation); String channelId = arguments.get(0); MediaPackage mediapackage = MediaPackageParser.getFromXml(arguments.get(1)); Set<String> elementIds = gson.fromJson(arguments.get(2), new TypeToken<Set<String>>() { }.getType()); switch (op) { case Distribute: Boolean checkAvailability = Boolean.parseBoolean(arguments.get(3)); MediaPackageElement[] distributedElements = distributeElements(channelId, mediapackage, elementIds, checkAvailability); return (distributedElements != null) ? MediaPackageElementParser.getArrayAsXml(Arrays.asList(distributedElements)) : null; case Retract: MediaPackageElement[] retractedElements = retractElements(channelId, mediapackage, elementIds); return (retractedElements != null) ? MediaPackageElementParser.getArrayAsXml(Arrays.asList(retractedElements)) : null; default: throw new IllegalStateException("Don't know how to handle operation '" + operation + "'"); } } catch (IllegalArgumentException e) { throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'", e); } catch (IndexOutOfBoundsException e) { throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e); } catch (Exception e) { throw new ServiceRegistryException("Error handling operation '" + op + "'", e); } } private Set<MediaPackageElement> getElements(MediaPackage mediapackage, Set<String> elementIds) throws IllegalStateException { final Set<MediaPackageElement> elements = new HashSet<MediaPackageElement>(); for (String elementId : elementIds) { MediaPackageElement element = mediapackage.getElementById(elementId); if (element != null) { elements.add(element); } else { throw new IllegalStateException(format("No element %s found in mediapackage %s", elementId, mediapackage.getIdentifier())); } } return elements; } /** * Try to find the same file being already distributed in one of the other channels * * @param source * the source file * @param mpId * the element's mediapackage id * @return the found duplicated file or the given source if nothing has been found * @throws IOException * if an I/O error occurs */ private File findDuplicatedElementSource(final File source, final String mpId) throws IOException { String orgId = securityService.getOrganization().getId(); final Path rootPath = Paths.get(distributionDirectory.getAbsolutePath(), orgId); if (!Files.exists(rootPath)) return source; List<Path> mediaPackageDirectories = new ArrayList<>(); try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(rootPath)) { for (Path path : directoryStream) { Path mpDir = path.resolve(mpId); if (Files.exists(mpDir)) { mediaPackageDirectories.add(mpDir); } } } if (mediaPackageDirectories.isEmpty()) return source; final long size = Files.size(source.toPath()); final File[] result = new File[1]; for (Path p : mediaPackageDirectories) { Files.walkFileTree(p, new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { if (attrs.isDirectory()) return FileVisitResult.CONTINUE; if (size != attrs.size()) return FileVisitResult.CONTINUE; try (InputStream is1 = Files.newInputStream(source.toPath()); InputStream is2 = Files.newInputStream(file)) { if (!IOUtils.contentEquals(is1, is2)) return FileVisitResult.CONTINUE; } result[0] = file.toFile(); return FileVisitResult.TERMINATE; } }); if (result[0] != null) break; } if (result[0] != null) return result[0]; return source; } /** * Gets the destination file to copy the contents of a mediapackage element. * * @return The file to copy the content to */ protected File getDistributionFile(String channelId, MediaPackage mp, MediaPackageElement element) { final String uriString = element.getURI().toString().split("\\?")[0]; final String directoryName = distributionDirectory.getAbsolutePath(); final String orgId = securityService.getOrganization().getId(); if (uriString.startsWith(serviceUrl)) { String[] splitUrl = uriString.substring(serviceUrl.length() + 1).split("/"); if (splitUrl.length < 5) { logger.warn("Malformed URI {}. Format must be .../{orgId}/{channelId}/{mediapackageId}/{elementId}/{fileName}." + " Trying URI without channelId", uriString); return new File(path(directoryName, orgId, splitUrl[1], splitUrl[2], splitUrl[3])); } else { return new File(path(directoryName, orgId, splitUrl[1], splitUrl[2], splitUrl[3], splitUrl[4])); } } return new File(path(directoryName, orgId, channelId, mp.getIdentifier().compact(), element.getIdentifier(), FilenameUtils.getName(uriString))); } /** * Gets the directory containing the distributed files for this mediapackage. * * @return the filesystem directory */ protected File getMediaPackageDirectory(String channelId, MediaPackage mp) { final String orgId = securityService.getOrganization().getId(); return new File(distributionDirectory, path(orgId, channelId, mp.getIdentifier().compact())); } /** * Gets the URI for the element to be distributed. * * @param mediaPackageId * the mediapackage identifier * @param element * The mediapackage element being distributed * @return The resulting URI after distribution * @throws URISyntaxException * if the concrete implementation tries to create a malformed uri */ protected URI getDistributionUri(String channelId, String mediaPackageId, MediaPackageElement element) throws URISyntaxException { String elementId = element.getIdentifier(); String fileName = FilenameUtils.getName(element.getURI().toString()); String orgId = securityService.getOrganization().getId(); String destinationURI = UrlSupport.concat(serviceUrl, orgId, channelId, mediaPackageId, elementId, fileName); return new URI(destinationURI); } @Override public void updated(@SuppressWarnings("rawtypes") Dictionary properties) throws ConfigurationException { distributeJobLoad = LoadUtil.getConfiguredLoadValue(properties, DISTRIBUTE_JOB_LOAD_KEY, DEFAULT_DISTRIBUTE_JOB_LOAD, serviceRegistry); retractJobLoad = LoadUtil.getConfiguredLoadValue(properties, RETRACT_JOB_LOAD_KEY, DEFAULT_RETRACT_JOB_LOAD, serviceRegistry); } }