DamMediaSource.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2014 wcm.io
 * %%
 * Licensed under the Apache 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://www.apache.org/licenses/LICENSE-2.0
 *
 * 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.
 * #L%
 */
package io.wcm.handler.mediasource.dam;

import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.jackrabbit.util.Text;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.adapter.Adaptable;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.InjectionStrategy;
import org.apache.sling.models.annotations.injectorspecific.OSGiService;
import org.apache.sling.models.annotations.injectorspecific.Self;
import org.apache.sling.models.annotations.injectorspecific.SlingObject;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.osgi.annotation.versioning.ProviderType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.day.cq.wcm.api.WCMMode;
import com.day.cq.wcm.api.components.Component;
import com.day.cq.wcm.api.components.ComponentContext;
import com.day.cq.wcm.api.components.DropTarget;
import com.day.cq.wcm.api.components.InplaceEditingConfig;
import com.day.cq.wcm.commons.WCMUtils;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.wcm.handler.commons.dom.HtmlElement;
import io.wcm.handler.commons.editcontext.DropTargetImpl;
import io.wcm.handler.media.Asset;
import io.wcm.handler.media.Media;
import io.wcm.handler.media.MediaArgs;
import io.wcm.handler.media.MediaFileType;
import io.wcm.handler.media.MediaInvalidReason;
import io.wcm.handler.media.MediaRequest;
import io.wcm.handler.media.MediaRequest.MediaPropertyNames;
import io.wcm.handler.media.format.MediaFormatHandler;
import io.wcm.handler.media.impl.ipeconfig.CroppingRatios;
import io.wcm.handler.media.impl.ipeconfig.IPEConfigResourceProvider;
import io.wcm.handler.media.markup.MediaMarkupBuilderUtil;
import io.wcm.handler.media.spi.MediaHandlerConfig;
import io.wcm.handler.media.spi.MediaSource;
import io.wcm.handler.mediasource.dam.impl.DamAsset;
import io.wcm.handler.mediasource.dam.impl.dynamicmedia.DynamicMediaSupportService;
import io.wcm.handler.mediasource.dam.impl.weboptimized.WebOptimizedImageDeliveryService;
import io.wcm.sling.models.annotations.AemObject;

/**
 * Default implementation for media requests to media items stored in AEM Assets (DAM).
 */
@Model(adaptables = {
    SlingHttpServletRequest.class, Resource.class
})
@ProviderType
public final class DamMediaSource extends MediaSource {

  @Self
  private Adaptable adaptable;
  @SlingObject
  private ResourceResolver resourceResolver;
  @SlingObject
  private Resource resource;
  @AemObject(injectionStrategy = InjectionStrategy.OPTIONAL)
  private WCMMode wcmMode;
  @AemObject(injectionStrategy = InjectionStrategy.OPTIONAL)
  private ComponentContext componentContext;
  @Self
  private MediaHandlerConfig mediaHandlerConfig;
  @Self
  private MediaFormatHandler mediaFormatHandler;
  @OSGiService
  private DynamicMediaSupportService dynamicMediaSupportService;
  @OSGiService
  private WebOptimizedImageDeliveryService webOptimizedImageDeliveryService;

  private final Logger log = LoggerFactory.getLogger(getClass());

  /**
   * Media source ID
   */
  public static final @NotNull String ID = "dam";

  @Override
  public @NotNull String getId() {
    return ID;
  }

  @Override
  public boolean accepts(@Nullable String mediaRef) {
    return StringUtils.startsWith(mediaRef, "/content/dam/");
  }

  @Override
  public @NotNull String getPrimaryMediaRefProperty() {
    return mediaHandlerConfig.getMediaRefProperty();
  }

  @Override
  @SuppressWarnings({
      "null",
      "java:S3776" // ignore complexity
  })
  @SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
  public @NotNull Media resolveMedia(@NotNull Media media) {
    String mediaRef = getMediaRef(media.getMediaRequest(), mediaHandlerConfig);
    MediaArgs mediaArgs = media.getMediaRequest().getMediaArgs();

    boolean renditionsResolved = false;
    if (StringUtils.isNotBlank(mediaRef)) {

      // Update media args settings from resource (e.g. alt. text setings)
      if (media.getMediaRequest().getResource() != null) {
        updateMediaArgsFromResource(mediaArgs, media.getMediaRequest().getResource(), mediaHandlerConfig);
      }

      // Check for transformations
      media.setCropDimension(getMediaCropDimension(media.getMediaRequest(), mediaHandlerConfig));
      media.setRotation(getMediaRotation(media.getMediaRequest(), mediaHandlerConfig));
      media.setMap(getMediaMap(media.getMediaRequest(), mediaHandlerConfig));

      // get DAM Asset to check for available renditions
      com.day.cq.dam.api.Asset damAsset = null;
      Resource assetResource = resourceResolver.getResource(mediaRef);
      if (assetResource != null) {
        damAsset = assetResource.adaptTo(com.day.cq.dam.api.Asset.class);
      }
      if (damAsset != null) {
        Asset asset = new DamAsset(media, damAsset, mediaHandlerConfig,
            dynamicMediaSupportService, webOptimizedImageDeliveryService, adaptable);
        media.setAsset(asset);

        // resolve rendition(s)
        renditionsResolved = resolveRenditions(media, asset, mediaArgs);
      }

    }

    // set media invalid reason
    if (!renditionsResolved) {
      if (media.getAsset() != null) {
        if (media.getRenditions().isEmpty()) {
          media.setMediaInvalidReason(MediaInvalidReason.NO_MATCHING_RENDITION);
        }
        else {
          media.setMediaInvalidReason(MediaInvalidReason.NOT_ENOUGH_MATCHING_RENDITIONS);
        }
      }
      else if (StringUtils.isNotEmpty(mediaRef)) {
        media.setMediaInvalidReason(MediaInvalidReason.MEDIA_REFERENCE_INVALID);
      }
      else {
        media.setMediaInvalidReason(MediaInvalidReason.MEDIA_REFERENCE_MISSING);
      }
    }

    return media;
  }

  @Override
  @SuppressWarnings({ "null", "java:S2589" })
  public void enableMediaDrop(@NotNull HtmlElement element, @NotNull MediaRequest mediaRequest) {
    if (wcmMode == WCMMode.DISABLED || wcmMode == null) {
      return;
    }

    if (componentContext != null && componentContext.getEditContext() != null
        && MediaMarkupBuilderUtil.canApplyDragDropSupport(mediaRequest, componentContext)) {

      String refProperty = prependDotSlash(getMediaRefProperty(mediaRequest, mediaHandlerConfig));
      String cropProperty = prependDotSlash(getMediaCropProperty(mediaRequest, mediaHandlerConfig));
      String rotationProperty = prependDotSlash(getMediaRotationProperty(mediaRequest, mediaHandlerConfig));
      String mapProperty = prependDotSlash(getMediaMapProperty(mediaRequest, mediaHandlerConfig));

      String name = refProperty;
      if (StringUtils.contains(name, "/")) {
        name = Text.getName(name);
      }

      // check of drop target for "media" group already exists - get it's id for the cq-dd- css class
      Optional<String> dropTargetCssClass = getMediaDropTargetID();
      if (!dropTargetCssClass.isPresent()) {
        // otherwise add a new drop target and get it's id
        MediaPropertyNames mediaPropertyNames = new MediaPropertyNames()
            .refProperty(refProperty)
            .cropProperty(cropProperty)
            .rotationProperty(rotationProperty)
            .mapProperty(mapProperty);
        dropTargetCssClass = addMediaDroptarget(refProperty, mediaPropertyNames, name);
      }

      if (element != null) {
        element.addCssClass(dropTargetCssClass.get());
      }
    }
  }

  @Override
  @SuppressWarnings({ "PMD.AvoidAccessibilityAlteration", "java:S3011" })
  public void setCustomIPECropRatios(@NotNull HtmlElement element, @NotNull MediaRequest mediaRequest) {
    if (wcmMode == WCMMode.DISABLED || wcmMode == null) {
      return;
    }

    if (componentContext != null
        && MediaMarkupBuilderUtil.canSetCustomIPECropRatios(mediaRequest, componentContext, mediaHandlerConfig.allowedIpeEditorTypes())) {
      // overlay IPE config with cropping ratios for each media format with a valid ratio
      CroppingRatios croppingRatios = new CroppingRatios(mediaFormatHandler);
      Set<String> mediaFormatNames = croppingRatios.getMediaFormatsForCropping(mediaRequest);
      if (!mediaFormatNames.isEmpty()) {
        // build custom IPE config path containing both the resource context path and the
        // configured media formats. The path is served by a custom resource provider, because
        // there is no other interface to pass in a dynamic IPE configuration
        String ipeConfigPath = IPEConfigResourceProvider.buildPath(componentContext.getResource().getPath(), mediaFormatNames);
        // clone IPE config and overwrite config path via reflection (no API available for this)
        InplaceEditingConfig customIpeConfig = new InplaceEditingConfig(componentContext
            .getEditContext().getEditConfig().getInplaceEditingConfig());
        try {
          Field configPathField = InplaceEditingConfig.class.getDeclaredField("configPath");
          configPathField.setAccessible(true);
          configPathField.set(customIpeConfig, ipeConfigPath);
        }
        catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException ex) {
          log.warn("Unable to set custom IPE config via reflection for {}", componentContext.getResource().getPath(), ex);
        }
        componentContext.getEditContext().getEditConfig().setInplaceEditingConfig(customIpeConfig);
      }

    }
  }

  private String prependDotSlash(String property) {
    if (!StringUtils.startsWith(property, "./")) {
      return "./" + property;
    }
    else {
      return property;
    }
  }

  private Optional<String> getMediaDropTargetID() {
    return componentContext.getEditContext().getEditConfig().getDropTargets().values().stream()
        .filter(item -> ArrayUtils.contains(item.getGroups(), "media"))
        .map(DropTarget::getId)
        .findFirst();
  }

  private Optional<String> addMediaDroptarget(String refProperty, MediaPropertyNames mediaPropertyNames, String name) {
    Component componentDefinition = WCMUtils.getComponent(resource);

    // set drop target - with path of current component as default resource type
    Map<String, String> params = new HashMap<>();
    if (componentDefinition != null) {
      params.put("./" + ResourceResolver.PROPERTY_RESOURCE_TYPE, componentDefinition.getPath());

      // clear cropping parameters if a new image is inserted via drag&drop
      params.put(mediaPropertyNames.getCropProperty(), "");
      params.put(mediaPropertyNames.getRotationProperty(), "");
      params.put(mediaPropertyNames.getMapProperty(), "");
    }

    DropTarget dropTarget = new DropTargetImpl(name, refProperty).setAccept(
        MediaFileType.getImageContentTypes().stream().toArray(size -> new String[size]) // allow all image mime types
    ).setGroups(new String[] {
        "media" // allow drop from DAM contentfinder tab
    }).setParameters(params);

    componentContext.getEditContext().getEditConfig().getDropTargets().put(dropTarget.getId(), dropTarget);

    return Optional.of(dropTarget.getId());
  }

  @Override
  public String toString() {
    return ID;
  }

}