View Javadoc
1   /*
2    * Copyright (C) 2026 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller;
7   
8   import static org.springframework.http.HttpStatus.BAD_REQUEST;
9   import static org.springframework.http.HttpStatus.NOT_FOUND;
10  import static org.springframework.http.HttpStatus.NOT_MODIFIED;
11  import static org.tailormap.api.service.UploadsService.createUploadResponseEntity;
12  import static org.tailormap.api.util.TMStringUtils.nullIfEmpty;
13  
14  import jakarta.servlet.http.HttpServletRequest;
15  import java.util.Optional;
16  import java.util.UUID;
17  import java.util.regex.Pattern;
18  import org.apache.commons.lang3.ObjectUtils;
19  import org.gaul.modernizer_maven_annotations.SuppressModernizer;
20  import org.springframework.http.ResponseEntity;
21  import org.springframework.validation.annotation.Validated;
22  import org.springframework.web.bind.annotation.GetMapping;
23  import org.springframework.web.bind.annotation.ModelAttribute;
24  import org.springframework.web.bind.annotation.PathVariable;
25  import org.springframework.web.server.ResponseStatusException;
26  import org.tailormap.api.annotation.AppRestController;
27  import org.tailormap.api.persistence.Application;
28  import org.tailormap.api.persistence.GeoService;
29  import org.tailormap.api.persistence.Upload;
30  import org.tailormap.api.persistence.UploadCategory;
31  import org.tailormap.api.persistence.json.AppLayerSettings;
32  import org.tailormap.api.persistence.json.AppTreeLayerNode;
33  import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
34  import org.tailormap.api.persistence.json.GeoServiceLayer;
35  import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
36  import org.tailormap.api.repository.UploadRepository;
37  import org.tailormap.api.service.UploadsService;
38  
39  @AppRestController
40  @Validated
41  public class LayerAttachedUploadsController {
42    private final UploadsService uploadsService;
43    private final UploadRepository uploadRepository;
44    private final UploadsController uploadsController;
45  
46    public LayerAttachedUploadsController(
47        UploadsService uploadsService, UploadRepository uploadRepository, UploadsController uploadsController) {
48      this.uploadsService = uploadsService;
49      this.uploadRepository = uploadRepository;
50      this.uploadsController = uploadsController;
51    }
52  
53    @GetMapping(
54        path = {
55          /* Can't use ${tailormap-api.base-path} because linkTo() used in UploadHelper#getUrlForLayerAttachedImage() may not work */
56          "/api/app/{viewerName}/layer/{appLayerId}/uploads/{category}/{id}",
57          "/api/app/{viewerName}/layer/{appLayerId}/uploads/{category}/{id}/{filename}"
58        })
59    public ResponseEntity<byte[]> getLayerAttachedUpload(
60        @ModelAttribute AppTreeLayerNode appTreeLayerNode,
61        @ModelAttribute GeoService service,
62        @ModelAttribute GeoServiceLayer layer,
63        @ModelAttribute Application application,
64        HttpServletRequest request,
65        @PathVariable UploadCategory category,
66        @PathVariable(name = "id") UUID id,
67        @PathVariable(name = "filename", required = false) String filename) {
68  
69      if (!category.isRestricted()) {
70        // return from the normal '/uploads' endpoint if the category is not restricted while removing the
71        // application and layer from the path. This could happen for "unrestricted" categories like APP_LOGO,
72        // UNRESTRICTED, etc. that have been attached to a layer.
73        return uploadsController.getUpload(request, category, id, filename);
74      }
75  
76      switch (category) {
77        case LAYER_ATTACHED_FILE ->
78          validateUploadIsInDescription(id, service, layer, application, appTreeLayerNode);
79        case LEGEND -> {
80          validateLegendIsAttached(id, service, layer, application, appTreeLayerNode);
81        }
82        default ->
83          throw new ResponseStatusException(
84              BAD_REQUEST, "Uploads for category " + category + " are not accessible via this endpoint");
85      }
86  
87      if (!uploadsService.checkIfModifiedSince(id, request.getDateHeader("If-Modified-Since"))) {
88        return ResponseEntity.status(NOT_MODIFIED).build();
89      }
90      // TODO this would fail when we have added a LEGEND upload to a layer description, because the category would be
91      //  wrong, since the frontend generating the url does not know the category of the upload, so it is likely to
92      //  always use LAYER_ATTACHED_FILE.
93      Upload upload = uploadRepository
94          .findWithContentByIdAndCategory(id, category)
95          .orElseThrow(() -> new ResponseStatusException(NOT_FOUND));
96      return createUploadResponseEntity(upload);
97    }
98  
99    /**
100    * check that the upload is actually attached to the layer by checking the text of any of the descriptions of the
101    * layer
102    *
103    * @param id the upload id
104    * @param service the GeoService the layer belongs to
105    * @param layer the layer the upload is attached to
106    * @param application the application the layer belongs to
107    * @param appTreeLayerNode the application tree node for the layer
108    * @throws ResponseStatusException if the upload is not attached to the layer
109    */
110   private void validateUploadIsInDescription(
111       UUID id,
112       GeoService service,
113       GeoServiceLayer layer,
114       Application application,
115       AppTreeLayerNode appTreeLayerNode)
116       throws ResponseStatusException {
117 
118     Pattern pattern =
119         Pattern.compile(Pattern.quote(UploadsService.UPLOAD_MARKDOWN_SCHEME) + Pattern.quote(id.toString()));
120 
121     GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
122             service.getSettings().getDefaultLayerSettings())
123         .orElseGet(GeoServiceDefaultLayerSettings::new);
124     GeoServiceLayerSettings serviceLayerSettings = Optional.ofNullable(
125             service.getSettings().getLayerSettings().get(layer.getName()))
126         .orElseGet(GeoServiceLayerSettings::new);
127     AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
128 
129     @SuppressModernizer
130     // not using Objects.requireNonNullElse(arg1, arg2) because we have 3 options to check for null
131     String description = ObjectUtils.firstNonNull(
132         nullIfEmpty(appLayerSettings.getDescription()),
133         nullIfEmpty(serviceLayerSettings.getDescription()),
134         nullIfEmpty(defaultLayerSettings.getDescription()));
135 
136     if (description == null
137         || description.isBlank()
138         || !pattern.matcher(description).find()) {
139       throw new ResponseStatusException(
140           BAD_REQUEST, "Upload with id '" + id + "' is not attached to layer '" + layer.getName() + "'");
141     }
142   }
143   /**
144    * check that the legend is actually attached to the layer by checking the legend fields and the text of any of the
145    * descriptions of the layer.
146    *
147    * @param id the upload id
148    * @param service the GeoService the layer belongs to
149    * @param layer the layer the upload is attached to
150    * @param application the application the layer belongs to
151    * @param appTreeLayerNode the application tree node for the layer
152    * @throws ResponseStatusException if the legend is not attached to the layer
153    */
154   private void validateLegendIsAttached(
155       UUID id,
156       GeoService service,
157       GeoServiceLayer layer,
158       Application application,
159       AppTreeLayerNode appTreeLayerNode)
160       throws ResponseStatusException {
161 
162     GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
163             service.getSettings().getDefaultLayerSettings())
164         .orElseGet(GeoServiceDefaultLayerSettings::new);
165 
166     GeoServiceLayerSettings serviceLayerSettings = Optional.ofNullable(
167             service.getSettings().getLayerSettings().get(layer.getName()))
168         .orElseGet(GeoServiceLayerSettings::new);
169 
170     @SuppressModernizer
171     // not using Objects.requireNonNullElse(arg1, arg2) because we never want to throw an NPE here, we just want to
172     // check if the legendImageId is set in either the service layer settings or the default layer settings
173     String legendImageId = ObjectUtils.firstNonNull(
174         serviceLayerSettings.getLegendImageId(), defaultLayerSettings.getLegendImageId());
175 
176     UUID legendUuid = null;
177     if (legendImageId != null && !legendImageId.isBlank()) {
178       try {
179         legendUuid = UUID.fromString(legendImageId);
180       } catch (IllegalArgumentException ignored) {
181         // Invalid UUID configured; treat as "not attached" and fall back to the description check below.
182       }
183     }
184 
185     if (!id.equals(legendUuid)) {
186       // could be a bad request, but a legend could also be in the description, so check that as well
187       validateUploadIsInDescription(id, service, layer, application, appTreeLayerNode);
188     }
189   }
190 }