001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.imagery; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.io.ByteArrayInputStream; 007import java.io.IOException; 008import java.net.SocketTimeoutException; 009import java.net.URL; 010import java.nio.charset.StandardCharsets; 011import java.util.HashSet; 012import java.util.List; 013import java.util.Locale; 014import java.util.Map; 015import java.util.Map.Entry; 016import java.util.Optional; 017import java.util.Set; 018import java.util.concurrent.ConcurrentHashMap; 019import java.util.concurrent.ConcurrentMap; 020import java.util.concurrent.ThreadPoolExecutor; 021import java.util.concurrent.TimeUnit; 022import java.util.regex.Matcher; 023import java.util.regex.Pattern; 024 025import org.apache.commons.jcs3.access.behavior.ICacheAccess; 026import org.apache.commons.jcs3.engine.behavior.ICache; 027import org.openstreetmap.gui.jmapviewer.Tile; 028import org.openstreetmap.gui.jmapviewer.interfaces.TileJob; 029import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener; 030import org.openstreetmap.gui.jmapviewer.interfaces.TileSource; 031import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource; 032import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry; 033import org.openstreetmap.josm.data.cache.CacheEntry; 034import org.openstreetmap.josm.data.cache.CacheEntryAttributes; 035import org.openstreetmap.josm.data.cache.ICachedLoaderListener; 036import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob; 037import org.openstreetmap.josm.data.imagery.vectortile.VectorTile; 038import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTFile; 039import org.openstreetmap.josm.data.preferences.LongProperty; 040import org.openstreetmap.josm.tools.HttpClient; 041import org.openstreetmap.josm.tools.Logging; 042import org.openstreetmap.josm.tools.Utils; 043 044/** 045 * Class bridging TMS requests to JCS cache requests 046 * 047 * @author Wiktor Niesiobędzki 048 * @since 8168 049 */ 050public class TMSCachedTileLoaderJob extends JCSCachedTileLoaderJob<String, BufferedImageCacheEntry> implements TileJob, ICachedLoaderListener { 051 /** General maximum expires for tiles. Might be overridden by imagery settings */ 052 public static final LongProperty MAXIMUM_EXPIRES = new LongProperty("imagery.generic.maximum_expires", TimeUnit.DAYS.toMillis(30)); 053 /** General minimum expires for tiles. Might be overridden by imagery settings */ 054 public static final LongProperty MINIMUM_EXPIRES = new LongProperty("imagery.generic.minimum_expires", TimeUnit.HOURS.toMillis(1)); 055 static final Pattern SERVICE_EXCEPTION_PATTERN = Pattern.compile("(?s).+<ServiceException[^>]*>(.+)</ServiceException>.+"); 056 static final Pattern CDATA_PATTERN = Pattern.compile("(?s)\\s*<!\\[CDATA\\[(.+)\\]\\]>\\s*"); 057 static final Pattern JSON_PATTERN = Pattern.compile("\\{\"message\":\"(.+)\"\\}"); 058 protected final Tile tile; 059 private volatile URL url; 060 private final TileJobOptions options; 061 062 // we need another deduplication of Tile Loader listeners, as for each submit, new TMSCachedTileLoaderJob was created 063 // that way, we reduce calls to tileLoadingFinished, and general CPU load due to surplus Map repaints 064 private static final ConcurrentMap<String, Set<TileLoaderListener>> inProgress = new ConcurrentHashMap<>(); 065 066 /** 067 * Constructor for creating a job, to get a specific tile from cache 068 * @param listener Tile loader listener 069 * @param tile to be fetched from cache 070 * @param cache object 071 * @param options for job (such as http headers, timeouts etc.) 072 * @param downloadExecutor that will be executing the jobs 073 */ 074 public TMSCachedTileLoaderJob(TileLoaderListener listener, Tile tile, 075 ICacheAccess<String, BufferedImageCacheEntry> cache, 076 TileJobOptions options, 077 ThreadPoolExecutor downloadExecutor) { 078 super(cache, options, downloadExecutor); 079 this.tile = tile; 080 this.options = options; 081 if (listener != null) { 082 inProgress.computeIfAbsent(getCacheKey(), k -> new HashSet<>()).add(listener); 083 } 084 } 085 086 @Override 087 public String getCacheKey() { 088 if (tile != null) { 089 TileSource tileSource = tile.getTileSource(); 090 return Optional.ofNullable(tileSource.getName()).orElse("").replace(ICache.NAME_COMPONENT_DELIMITER, "_") 091 + ICache.NAME_COMPONENT_DELIMITER 092 + tileSource.getTileId(tile.getZoom(), tile.getXtile(), tile.getYtile()); 093 } 094 return null; 095 } 096 097 /* 098 * this doesn't needs to be synchronized, as it's not that costly to keep only one execution 099 * in parallel, but URL creation and Tile.getUrl() are costly and are not needed when fetching 100 * data from cache, that's why URL creation is postponed until it's needed 101 * 102 * We need to have static url value for TileLoaderJob, as for some TileSources we might get different 103 * URL's each call we made (servers switching), and URL's are used below as a key for duplicate detection 104 * 105 */ 106 @Override 107 public URL getUrl() throws IOException { 108 if (url == null) { 109 synchronized (this) { 110 if (url == null) { 111 String sUrl = tile.getUrl(); 112 if (!"".equals(sUrl)) { 113 url = new URL(sUrl); 114 } 115 } 116 } 117 } 118 return url; 119 } 120 121 @Override 122 public boolean isObjectLoadable() { 123 if (cacheData != null) { 124 byte[] content = cacheData.getContent(); 125 try { 126 return content.length > 0 || cacheData.getImage() != null || isNoTileAtZoom(); 127 } catch (IOException e) { 128 Logging.logWithStackTrace(Logging.LEVEL_WARN, e, "JCS TMS - error loading from cache for tile {0}: {1}", 129 tile.getKey(), e.getMessage()); 130 } 131 } 132 return false; 133 } 134 135 @Override 136 protected boolean isResponseLoadable(Map<String, List<String>> headers, int statusCode, byte[] content) { 137 attributes.setMetadata(tile.getTileSource().getMetadata(headers)); 138 if (tile.getTileSource().isNoTileAtZoom(headers, statusCode, content)) { 139 attributes.setNoTileAtZoom(true); 140 return false; // do no try to load data from no-tile at zoom, cache empty object instead 141 } 142 if (isNotImage(headers, statusCode)) { 143 String message = detectErrorMessage(new String(content, StandardCharsets.UTF_8)); 144 if (!Utils.isEmpty(message)) { 145 tile.setError(message); 146 } 147 return false; 148 } 149 return super.isResponseLoadable(headers, statusCode, content); 150 } 151 152 private boolean isNotImage(Map<String, List<String>> headers, int statusCode) { 153 if (statusCode == 200 && headers.containsKey("Content-Type") && !headers.get("Content-Type").isEmpty()) { 154 String contentType = headers.get("Content-Type").stream().findAny().get(); 155 if (contentType != null && !contentType.startsWith("image") && !MVTFile.MIMETYPE.contains(contentType.toLowerCase(Locale.ROOT))) { 156 Logging.warn("Image not returned for tile: " + url + " content type was: " + contentType); 157 // not an image - do not store response in cache, so next time it will be queried again from the server 158 return true; 159 } 160 } 161 return false; 162 } 163 164 @Override 165 protected boolean cacheAsEmpty(Map<String, List<String>> headerFields, int responseCode) { 166 if (isNotImage(headerFields, responseCode)) { 167 return false; 168 } 169 return isNoTileAtZoom() || super.cacheAsEmpty(headerFields, responseCode); 170 } 171 172 @Override 173 public void submit(boolean force) { 174 tile.initLoading(); 175 try { 176 super.submit(this, force); 177 } catch (IOException | IllegalArgumentException e) { 178 // if we fail to submit the job, mark tile as loaded and set error message 179 Logging.log(Logging.LEVEL_WARN, e); 180 tile.finishLoading(); 181 tile.setError(e.getMessage()); 182 } 183 } 184 185 @Override 186 public void loadingFinished(CacheEntry object, CacheEntryAttributes attributes, LoadResult result) { 187 this.attributes = attributes; // as we might get notification from other object than our selfs, pass attributes along 188 Set<TileLoaderListener> listeners = inProgress.remove(getCacheKey()); 189 boolean status = result == LoadResult.SUCCESS; 190 191 try { 192 tile.finishLoading(); // whatever happened set that loading has finished 193 // set tile metadata 194 if (this.attributes != null) { 195 for (Entry<String, String> e: this.attributes.getMetadata().entrySet()) { 196 tile.putValue(e.getKey(), e.getValue()); 197 } 198 } 199 200 switch(result) { 201 case SUCCESS: 202 handleNoTileAtZoom(); 203 if (attributes != null) { 204 int httpStatusCode = attributes.getResponseCode(); 205 if (httpStatusCode >= 400 && !isNoTileAtZoom()) { 206 status = false; 207 handleError(attributes); 208 } 209 } 210 status &= tryLoadTileImage(object); //try to keep returned image as background 211 break; 212 case FAILURE: 213 handleError(attributes); 214 tryLoadTileImage(object); 215 break; 216 case CANCELED: 217 tile.loadingCanceled(); 218 // do nothing 219 } 220 221 // always check, if there is some listener interested in fact, that tile has finished loading 222 if (listeners != null) { // listeners might be null, if some other thread notified already about success 223 for (TileLoaderListener l: listeners) { 224 l.tileLoadingFinished(tile, status); 225 } 226 } 227 } catch (IOException e) { 228 Logging.warn("JCS TMS - error loading object for tile {0}: {1}", tile.getKey(), e.getMessage()); 229 tile.setError(e); 230 tile.setLoaded(false); 231 if (listeners != null) { // listeners might be null, if some other thread notified already about success 232 for (TileLoaderListener l: listeners) { 233 l.tileLoadingFinished(tile, false); 234 } 235 } 236 } 237 } 238 239 private void handleError(CacheEntryAttributes attributes) { 240 if (tile.hasError() && tile.getErrorMessage() != null) { 241 // tile has already set error message, don't overwrite it 242 return; 243 } 244 if (attributes != null) { 245 int httpStatusCode = attributes.getResponseCode(); 246 if (attributes.getErrorMessage() == null) { 247 tile.setError(tr("HTTP error {0} when loading tiles", httpStatusCode)); 248 } else { 249 tile.setError(tr("Error downloading tiles: {0}", attributes.getErrorMessage())); 250 } 251 if (httpStatusCode >= 500 && httpStatusCode != 599) { 252 // httpStatusCode = 599 is set by JCSCachedTileLoaderJob on IOException 253 tile.setLoaded(false); // treat 500 errors as temporary and try to load it again 254 } 255 // treat SocketTimeoutException as transient error 256 attributes.getException() 257 .filter(x -> x.isAssignableFrom(SocketTimeoutException.class)) 258 .ifPresent(x -> tile.setLoaded(false)); 259 } else { 260 tile.setError(tr("Problem loading tile")); 261 // treat unknown errors as permanent and do not try to load tile again 262 } 263 } 264 265 /** 266 * For TMS use BaseURL as settings discovery, so for different paths, we will have different settings (useful for developer servers) 267 * 268 * @return base URL of TMS or server url as defined in super class 269 */ 270 @Override 271 protected String getServerKey() { 272 TileSource ts = tile.getSource(); 273 if (ts instanceof AbstractTMSTileSource) { 274 return ((AbstractTMSTileSource) ts).getBaseUrl(); 275 } 276 return super.getServerKey(); 277 } 278 279 @Override 280 protected BufferedImageCacheEntry createCacheEntry(byte[] content) { 281 return new BufferedImageCacheEntry(content); 282 } 283 284 @Override 285 public void submit() { 286 submit(false); 287 } 288 289 @Override 290 protected CacheEntryAttributes parseHeaders(HttpClient.Response urlConn) { 291 CacheEntryAttributes ret = super.parseHeaders(urlConn); 292 // keep the expiration time between MINIMUM_EXPIRES and MAXIMUM_EXPIRES, so we will cache the tiles 293 // at least for some short period of time, but not too long 294 long minimumExpiryTime = TimeUnit.SECONDS.toMillis(options.getMinimumExpiryTime()); 295 long nowPlusMin = now + Math.max(MINIMUM_EXPIRES.get(), minimumExpiryTime); 296 if (ret.getExpirationTime() < nowPlusMin) { 297 ret.setExpirationTime(nowPlusMin); 298 } 299 long nowPlusMax = now + Math.max(MAXIMUM_EXPIRES.get(), minimumExpiryTime); 300 if (ret.getExpirationTime() > nowPlusMax) { 301 ret.setExpirationTime(nowPlusMax); 302 } 303 return ret; 304 } 305 306 private boolean handleNoTileAtZoom() { 307 if (isNoTileAtZoom()) { 308 Logging.debug("JCS TMS - Tile valid, but no file, as no tiles at this level {0}", tile); 309 tile.setError(tr("No tiles at this zoom level")); 310 tile.putValue("tile-info", "no-tile"); 311 return true; 312 } 313 return false; 314 } 315 316 private boolean isNoTileAtZoom() { 317 if (attributes == null) { 318 Logging.warn("Cache attributes are null"); 319 } 320 return attributes != null && attributes.isNoTileAtZoom(); 321 } 322 323 private boolean tryLoadTileImage(CacheEntry object) throws IOException { 324 if (object != null) { 325 byte[] content = object.getContent(); 326 if (content.length > 0 || tile instanceof VectorTile) { 327 try (ByteArrayInputStream in = new ByteArrayInputStream(content)) { 328 tile.loadImage(in); 329 if ((!(tile instanceof VectorTile) && tile.getImage() == null) 330 || ((tile instanceof VectorTile) && !tile.isLoaded())) { 331 String s = new String(content, StandardCharsets.UTF_8); 332 Matcher m = SERVICE_EXCEPTION_PATTERN.matcher(s); 333 if (m.matches()) { 334 String message = Utils.strip(m.group(1)); 335 tile.setError(message); 336 Logging.error(message); 337 Logging.debug(s); 338 } else { 339 tile.setError(tr("Could not load image from tile server")); 340 } 341 return false; 342 } 343 } catch (UnsatisfiedLinkError | SecurityException e) { 344 throw new IOException(e); 345 } 346 } 347 } 348 return true; 349 } 350 351 @Override 352 public String detectErrorMessage(String data) { 353 Matcher xml = SERVICE_EXCEPTION_PATTERN.matcher(data); 354 Matcher json = JSON_PATTERN.matcher(data); 355 return xml.matches() ? removeCdata(Utils.strip(xml.group(1))) 356 : json.matches() ? Utils.strip(json.group(1)) 357 : super.detectErrorMessage(data); 358 } 359 360 private static String removeCdata(String msg) { 361 Matcher m = CDATA_PATTERN.matcher(msg); 362 return m.matches() ? Utils.strip(m.group(1)) : msg; 363 } 364}