AttachmentsController.java

/*
 * Copyright (C) 2025 B3Partners B.V.
 *
 * SPDX-License-Identifier: MIT
 */
package org.tailormap.api.controller;

import static org.apache.commons.lang3.StringUtils.isBlank;

import com.google.common.base.Splitter;
import jakarta.validation.Valid;
import java.io.IOException;
import java.io.Serializable;
import java.lang.invoke.MethodHandles;
import java.nio.ByteBuffer;
import java.sql.SQLException;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import org.geotools.api.data.Query;
import org.geotools.api.data.SimpleFeatureSource;
import org.geotools.api.filter.Filter;
import org.geotools.api.filter.FilterFactory;
import org.geotools.data.simple.SimpleFeatureIterator;
import org.geotools.factory.CommonFactoryFinder;
import org.geotools.util.factory.GeoTools;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.server.ResponseStatusException;
import org.tailormap.api.annotation.AppRestController;
import org.tailormap.api.geotools.featuresources.AttachmentsHelper;
import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
import org.tailormap.api.persistence.Application;
import org.tailormap.api.persistence.GeoService;
import org.tailormap.api.persistence.TMFeatureType;
import org.tailormap.api.persistence.json.AppTreeLayerNode;
import org.tailormap.api.persistence.json.AttachmentAttributeType;
import org.tailormap.api.persistence.json.GeoServiceLayer;
import org.tailormap.api.util.EditUtil;
import org.tailormap.api.viewer.model.AttachmentMetadata;

@AppRestController
@Validated
public class AttachmentsController {

  private static final Logger logger =
      LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  private final EditUtil editUtil;
  private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
  private final FilterFactory ff = CommonFactoryFinder.getFilterFactory(GeoTools.getDefaultHints());

  public AttachmentsController(EditUtil editUtil, FeatureSourceFactoryHelper featureSourceFactoryHelper) {
    this.editUtil = editUtil;
    this.featureSourceFactoryHelper = featureSourceFactoryHelper;
  }

  /**
   * Add an attachment to a feature
   *
   * @param appTreeLayerNode the application tree layer node
   * @param service the geo service
   * @param layer the geo service layer
   * @param application the application
   * @param featureId the feature id
   * @param attachment the attachment metadata
   * @param fileData the attachment file data
   * @return the response entity
   */
  @PostMapping(
      path = {
        "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachments"
      },
      consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
      produces = MediaType.APPLICATION_JSON_VALUE)
  @Transactional
  public ResponseEntity<Serializable> addAttachment(
      @ModelAttribute AppTreeLayerNode appTreeLayerNode,
      @ModelAttribute GeoService service,
      @ModelAttribute GeoServiceLayer layer,
      @ModelAttribute Application application,
      @PathVariable String featureId,
      @RequestPart("attachmentMetadata") AttachmentMetadata attachment,
      @RequestPart("attachment") byte[] fileData) {

    editUtil.checkEditAuthorisation();

    TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);

    Object primaryKey = getFeaturePrimaryKeyByFid(tmFeatureType, featureId);

    Set<@Valid AttachmentAttributeType> attachmentAttrSet =
        tmFeatureType.getSettings().getAttachmentAttributes();
    if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) {
      throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Layer does not support attachments");
    }

    AttachmentAttributeType attachmentAttribute = attachmentAttrSet.stream()
        .filter(attr -> attr.getAttributeName().equals(attachment.getAttributeName()))
        .findFirst()
        .orElseThrow(() -> new ResponseStatusException(
            HttpStatus.BAD_REQUEST,
            "Layer does not support attachments for attribute " + attachment.getAttributeName()));

    if (attachmentAttribute.getMaxAttachmentSize() != null
        && attachmentAttribute.getMaxAttachmentSize() < fileData.length) {
      throw new ResponseStatusException(
          HttpStatus.BAD_REQUEST,
          "Attachment size %d exceeds maximum of %d"
              .formatted(fileData.length, attachmentAttribute.getMaxAttachmentSize()));
    }

    if (!validateMimeTypeAccept(
        attachmentAttribute.getMimeType(), attachment.getFileName(), attachment.getMimeType())) {
      throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File type or extension not allowed");
    }

    logger.debug("Using attachment attribute {}", attachmentAttribute);

    AttachmentMetadata response;
    try {
      response = AttachmentsHelper.insertAttachment(tmFeatureType, attachment, primaryKey, fileData);
    } catch (IOException | SQLException e) {
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
    }

    return new ResponseEntity<>(response, HttpStatus.CREATED);
  }

  /**
   * Validate as <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#accept">file
   * input "accept" attribute</a>.
   *
   * @param acceptList comma-separated list of MIME types and file extensions to validate against
   * @param fileName name of the file to validate
   * @param mimeType MIME type of the file to validate
   * @return true if the file's extension or MIME type matches one of the accepted types, false otherwise
   */
  private static boolean validateMimeTypeAccept(String acceptList, String fileName, String mimeType) {
    if (acceptList == null || isBlank(acceptList)) {
      return true;
    }
    Iterable<String> allowedMimeTypes =
        Splitter.on(Pattern.compile(",\\s*")).split(acceptList);
    final Locale locale = Locale.ENGLISH;
    for (String allowedType : allowedMimeTypes) {
      if (allowedType.startsWith(".")) {
        // Check file extension
        if (fileName.toLowerCase(locale).endsWith(allowedType.toLowerCase(locale))) {
          return true;
        }
      } else if (allowedType.endsWith("/*")) {
        // Check mime type category (e.g. image/*)
        String category = allowedType.substring(0, allowedType.length() - 1);
        if (mimeType.startsWith(category)) {
          return true;
        }
      } else {
        // Check exact mime type match
        if (mimeType.equals(allowedType)) {
          return true;
        }
      }
    }
    return false;
  }

  /**
   * List attachments for a feature.
   *
   * @param appTreeLayerNode the application tree layer node
   * @param service the geo service
   * @param layer the geo service layer
   * @param application the application
   * @param featureId the feature id
   * @return the response entity containing a list of attachment metadata
   */
  @GetMapping(
      path = {
        "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/feature/{featureId}/attachments"
      },
      produces = MediaType.APPLICATION_JSON_VALUE)
  @Transactional
  public ResponseEntity<List<AttachmentMetadata>> listAttachments(
      @ModelAttribute AppTreeLayerNode appTreeLayerNode,
      @ModelAttribute GeoService service,
      @ModelAttribute GeoServiceLayer layer,
      @ModelAttribute Application application,
      @PathVariable String featureId) {

    TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);

    checkFeatureTypeSupportsAttachments(tmFeatureType);
    Object primaryKey = getFeaturePrimaryKeyByFid(tmFeatureType, featureId);

    List<AttachmentMetadata> response;
    try {
      response = AttachmentsHelper.listAttachmentsForFeature(tmFeatureType, primaryKey);
    } catch (IOException | SQLException e) {
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
    }

    return new ResponseEntity<>(response, HttpStatus.OK);
  }

  @DeleteMapping(
      path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}")
  @Transactional
  public ResponseEntity<Serializable> deleteAttachment(
      @ModelAttribute AppTreeLayerNode appTreeLayerNode,
      @ModelAttribute GeoService service,
      @ModelAttribute GeoServiceLayer layer,
      @ModelAttribute Application application,
      @PathVariable UUID attachmentId) {
    editUtil.checkEditAuthorisation();

    TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);

    checkFeatureTypeSupportsAttachments(tmFeatureType);

    try {
      AttachmentsHelper.deleteAttachment(attachmentId, tmFeatureType);
    } catch (IOException | SQLException e) {
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
    }

    return new ResponseEntity<>(HttpStatus.NO_CONTENT);
  }

  @Transactional
  @GetMapping(
      path = "${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/attachment/{attachmentId}",
      produces = {"application/octet-stream"})
  public ResponseEntity<byte[]> getAttachment(
      @ModelAttribute AppTreeLayerNode appTreeLayerNode,
      @ModelAttribute GeoService service,
      @ModelAttribute GeoServiceLayer layer,
      @ModelAttribute Application application,
      @PathVariable UUID attachmentId) {

    TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);

    try {
      final AttachmentsHelper.AttachmentWithBinary attachmentWithBinary =
          AttachmentsHelper.getAttachment(tmFeatureType, attachmentId);

      if (attachmentWithBinary == null) {
        throw new ResponseStatusException(
            HttpStatus.NOT_FOUND, "Attachment %s not found".formatted(attachmentId.toString()));
      }

      // the binary attachment() is a read-only ByteBuffer, so we cant use .array()
      final ByteBuffer bb = attachmentWithBinary.attachment().asReadOnlyBuffer();
      bb.rewind();
      byte[] attachmentData = new byte[bb.remaining()];
      bb.get(attachmentData);

      return ResponseEntity.ok()
          .header(
              "Content-Disposition",
              "inline; filename=\""
                  + attachmentWithBinary.attachmentMetadata().getFileName() + "\"")
          .contentType(MediaType.parseMediaType(
              attachmentWithBinary.attachmentMetadata().getMimeType()))
          .body(attachmentData);
    } catch (SQLException | IOException e) {
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
    }
  }

  private Object getFeaturePrimaryKeyByFid(TMFeatureType tmFeatureType, String featureId)
      throws ResponseStatusException {
    final Filter fidFilter = ff.id(ff.featureId(featureId));
    SimpleFeatureSource fs = null;
    try {
      fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
      Query query = new Query();
      query.setFilter(fidFilter);
      query.setPropertyNames(tmFeatureType.getPrimaryKeyAttribute());
      try (SimpleFeatureIterator sfi = fs.getFeatures(query).features()) {
        if (!sfi.hasNext()) {
          throw new ResponseStatusException(
              HttpStatus.NOT_FOUND, "Feature with id %s does not exist".formatted(featureId));
        }
        return sfi.next().getAttribute(tmFeatureType.getPrimaryKeyAttribute());
      }
    } catch (IOException e) {
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e);
    } finally {
      if (fs != null) {
        fs.getDataStore().dispose();
      }
    }
  }

  private void checkFeatureTypeSupportsAttachments(TMFeatureType tmFeatureType) throws ResponseStatusException {
    Set<@Valid AttachmentAttributeType> attachmentAttrSet =
        tmFeatureType.getSettings().getAttachmentAttributes();
    if (attachmentAttrSet == null || attachmentAttrSet.isEmpty()) {
      throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Layer does not support attachments");
    }
  }
}