View Javadoc
1   /*
2    * Copyright (C) 2026 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.service;
7   
8   import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
9   import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION;
10  
11  import java.time.temporal.ChronoField;
12  import java.util.UUID;
13  import java.util.regex.Pattern;
14  import org.springframework.http.CacheControl;
15  import org.springframework.http.ContentDisposition;
16  import org.springframework.http.ResponseEntity;
17  import org.springframework.stereotype.Service;
18  import org.tailormap.api.controller.LayerAttachedUploadsController;
19  import org.tailormap.api.controller.UploadsController;
20  import org.tailormap.api.persistence.Application;
21  import org.tailormap.api.persistence.Upload;
22  import org.tailormap.api.persistence.UploadCategory;
23  import org.tailormap.api.repository.UploadRepository;
24  
25  @Service
26  public class UploadsService {
27    private final UploadRepository uploadRepository;
28    public static final String DESCRIPTION_HEADER_NAME = "TM-Description";
29    /** The scheme used in Markdown files to reference an upload. For example, {@code upload://<upload-id>}. */
30    public static final String UPLOAD_MARKDOWN_SCHEME = "upload://";
31  
32    private static final Pattern UPLOAD_REPLACE_PATTERN =
33        Pattern.compile("upload://([a-f0-9]{8}(?:-[a-f0-9]{4}){4}[a-f0-9]{8})");
34  
35    public UploadsService(UploadRepository uploadRepository) {
36      this.uploadRepository = uploadRepository;
37    }
38  
39    /**
40     * Checks if the upload with the given ID has been modified since the provided timestamp.
41     *
42     * @param id the UUID of the upload
43     * @param ifModifiedSince the timestamp to compare against (in milliseconds)
44     * @return true if the upload has been modified since the provided timestamp or when the upload does not exist,
45     *     false otherwise
46     */
47    public boolean checkIfModifiedSince(UUID id, long ifModifiedSince) {
48      if (ifModifiedSince == -1) {
49        return true;
50      }
51  
52      return uploadRepository
53          .findLastModifiedById(id)
54          .map(uploadLastModified -> ifModifiedSince
55              < uploadLastModified
56                  .with(ChronoField.MILLI_OF_SECOND, 0)
57                  .toInstant()
58                  .toEpochMilli())
59          .orElse(true);
60    }
61  
62    public String getUrlForImage(String imageId, UploadCategory category) {
63      if (imageId == null) {
64        return null;
65      }
66      try {
67        UUID uuid = UUID.fromString(imageId);
68        return getUrlForImage(uuid, category);
69      } catch (IllegalArgumentException e) {
70        // Illegal UUID, return null
71        return null;
72      }
73    }
74  
75    public String getUrlForImage(UUID imageId, UploadCategory category) {
76      if (imageId == null) {
77        return null;
78      }
79      if (category.isRestricted()) {
80        throw new IllegalArgumentException(
81            "Access to restricted category is not allowed without application and layer context");
82      }
83      return uploadRepository
84          .findByIdAndCategory(imageId, category)
85          .map(upload -> linkTo(UploadsController.class)
86              .slash("api")
87              .slash("uploads")
88              .slash(category)
89              .slash(imageId.toString())
90              .slash(upload.getFilename())
91              .toString())
92          .orElse(null);
93    }
94  
95    /**
96     * Returns the URL for a layer-attached image e.g. a legend image, or null if the imageId is null or not found.
97     *
98     * @param imageId the id of the image
99     * @param category the category of the image, e.g. LEGEND
100    * @param application the application the image is attached to
101    * @param layerId the id of the layer the image is attached to
102    * @return the URL for the layer-attached image or null
103    */
104   public String getUrlForLayerAttachedImage(
105       UUID imageId, UploadCategory category, Application application, String layerId) {
106     if (imageId == null) {
107       return null;
108     }
109     if (category.isRestricted()) {
110       return uploadRepository
111           .findByIdAndCategory(imageId, category)
112           .map(upload -> linkTo(LayerAttachedUploadsController.class)
113               .slash("api")
114               .slash("app")
115               .slash(application.getName())
116               .slash("layer")
117               .slash(layerId)
118               .slash("uploads")
119               .slash(category)
120               .slash(imageId.toString())
121               .slash(upload.getFilename())
122               .toString())
123           .orElse(null);
124     } else {
125       return getUrlForImage(imageId, category);
126     }
127   }
128 
129   public String replaceUploadLinks(Application application, String appLayerId, String description) {
130     if (description == null) {
131       return null;
132     }
133 
134     return UPLOAD_REPLACE_PATTERN.matcher(description).replaceAll(matchResult -> {
135       try {
136         UUID uploadId = UUID.fromString(matchResult.group(1));
137         Upload upload = uploadRepository.findById(uploadId).orElse(null);
138         if (upload == null) {
139           return "";
140         }
141         return linkTo(LayerAttachedUploadsController.class)
142             .slash("api")
143             .slash("app")
144             .slash(application.getName())
145             .slash("layer")
146             .slash(appLayerId)
147             .slash("uploads")
148             .slash(upload.getCategory().toString())
149             .slash(uploadId.toString())
150             .slash(upload.getFilename())
151             .toString();
152       } catch (Exception _ignored) {
153         return "";
154       }
155     });
156   }
157 
158   public static ResponseEntity<byte[]> createUploadResponseEntity(Upload upload) {
159     return ResponseEntity.ok()
160         .header("Content-Type", upload.getMimeType())
161         .header(UploadsService.DESCRIPTION_HEADER_NAME, upload.getDescription())
162         .header(
163             CONTENT_DISPOSITION,
164             ContentDisposition.inline()
165                 .filename(upload.getFilename())
166                 .build()
167                 .toString())
168         .lastModified(upload.getLastModified().toInstant())
169         .contentLength(upload.getContentLength())
170         .cacheControl(CacheControl.noCache().cachePublic())
171         .body(upload.getContent());
172   }
173 }