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.util.TMStringUtils.nullIfEmpty;
12  
13  import jakarta.servlet.http.HttpServletRequest;
14  import java.util.Optional;
15  import java.util.UUID;
16  import java.util.regex.Pattern;
17  import org.apache.commons.lang3.ObjectUtils;
18  import org.gaul.modernizer_maven_annotations.SuppressModernizer;
19  import org.springframework.http.CacheControl;
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 (UploadCategory.getUnrestrictedCategories().contains(category)) {
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  
97      return ResponseEntity.ok()
98          .header("Content-Type", upload.getMimeType())
99          .header(UploadsService.DESCRIPTION_HEADER_NAME, upload.getDescription())
100         .lastModified(upload.getLastModified().toInstant())
101         .contentLength(upload.getContentLength())
102         .cacheControl(CacheControl.noCache().cachePublic())
103         .body(upload.getContent());
104   }
105 
106   /**
107    * check that the upload is actually attached to the layer by checking the text of any of the descriptions of the
108    * layer
109    *
110    * @param id the upload id
111    * @param service the GeoService the layer belongs to
112    * @param layer the layer the upload is attached to
113    * @param application the application the layer belongs to
114    * @param appTreeLayerNode the application tree node for the layer
115    * @throws ResponseStatusException if the upload is not attached to the layer
116    */
117   private void validateUploadIsInDescription(
118       UUID id,
119       GeoService service,
120       GeoServiceLayer layer,
121       Application application,
122       AppTreeLayerNode appTreeLayerNode)
123       throws ResponseStatusException {
124 
125     Pattern pattern =
126         Pattern.compile(Pattern.quote(UploadsService.UPLOAD_MARKDOWN_SCHEME) + Pattern.quote(id.toString()));
127 
128     GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
129             service.getSettings().getDefaultLayerSettings())
130         .orElseGet(GeoServiceDefaultLayerSettings::new);
131     GeoServiceLayerSettings serviceLayerSettings = Optional.ofNullable(
132             service.getSettings().getLayerSettings().get(layer.getName()))
133         .orElseGet(GeoServiceLayerSettings::new);
134     AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);
135 
136     @SuppressModernizer
137     // not using Objects.requireNonNullElse(arg1, arg2) because we have 3 options to check for null
138     String description = ObjectUtils.firstNonNull(
139         nullIfEmpty(appLayerSettings.getDescription()),
140         nullIfEmpty(serviceLayerSettings.getDescription()),
141         nullIfEmpty(defaultLayerSettings.getDescription()));
142 
143     if (description == null
144         || description.isBlank()
145         || !pattern.matcher(description).find()) {
146       throw new ResponseStatusException(
147           BAD_REQUEST, "Upload with id '" + id + "' is not attached to layer '" + layer.getName() + "'");
148     }
149   }
150   /**
151    * check that the legend is actually attached to the layer by checking the legend fields and the text of any of the
152    * descriptions of the layer.
153    *
154    * @param id the upload id
155    * @param service the GeoService the layer belongs to
156    * @param layer the layer the upload is attached to
157    * @param application the application the layer belongs to
158    * @param appTreeLayerNode the application tree node for the layer
159    * @throws ResponseStatusException if the legend is not attached to the layer
160    */
161   private void validateLegendIsAttached(
162       UUID id,
163       GeoService service,
164       GeoServiceLayer layer,
165       Application application,
166       AppTreeLayerNode appTreeLayerNode)
167       throws ResponseStatusException {
168 
169     GeoServiceDefaultLayerSettings defaultLayerSettings = Optional.ofNullable(
170             service.getSettings().getDefaultLayerSettings())
171         .orElseGet(GeoServiceDefaultLayerSettings::new);
172 
173     GeoServiceLayerSettings serviceLayerSettings = Optional.ofNullable(
174             service.getSettings().getLayerSettings().get(layer.getName()))
175         .orElseGet(GeoServiceLayerSettings::new);
176 
177     @SuppressModernizer
178     // not using Objects.requireNonNullElse(arg1, arg2) because we never want to throw an NPE here, we just want to
179     // check if the legendImageId is set in either the service layer settings or the default layer settings
180     String legendImageId = ObjectUtils.firstNonNull(
181         serviceLayerSettings.getLegendImageId(), defaultLayerSettings.getLegendImageId());
182 
183     UUID legendUuid = null;
184     if (legendImageId != null && !legendImageId.isBlank()) {
185       try {
186         legendUuid = UUID.fromString(legendImageId);
187       } catch (IllegalArgumentException ignored) {
188         // Invalid UUID configured; treat as "not attached" and fall back to the description check below.
189       }
190     }
191 
192     if (!id.equals(legendUuid)) {
193       // could be a bad request, but a legend could also be in the description, so check that as well
194       validateUploadIsInDescription(id, service, layer, application, appTreeLayerNode);
195     }
196   }
197 }