001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.imagery;
003
004import java.awt.image.BufferedImage;
005import java.io.BufferedReader;
006import java.io.ByteArrayInputStream;
007import java.io.ByteArrayOutputStream;
008import java.io.IOException;
009import java.io.InputStream;
010import java.io.InputStreamReader;
011import java.io.StringReader;
012import java.net.HttpURLConnection;
013import java.net.MalformedURLException;
014import java.net.URL;
015import java.net.URLConnection;
016import java.nio.charset.StandardCharsets;
017import java.text.DecimalFormat;
018import java.text.DecimalFormatSymbols;
019import java.text.NumberFormat;
020import java.util.ArrayList;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Locale;
024import java.util.Map;
025import java.util.Map.Entry;
026import java.util.regex.Matcher;
027import java.util.regex.Pattern;
028
029import javax.xml.parsers.DocumentBuilder;
030import javax.xml.parsers.DocumentBuilderFactory;
031import javax.xml.parsers.ParserConfigurationException;
032
033import org.openstreetmap.josm.Main;
034import org.openstreetmap.josm.data.ProjectionBounds;
035import org.openstreetmap.josm.data.coor.EastNorth;
036import org.openstreetmap.josm.data.coor.LatLon;
037import org.openstreetmap.josm.data.imagery.GeorefImage.State;
038import org.openstreetmap.josm.data.imagery.ImageryInfo;
039import org.openstreetmap.josm.gui.MapView;
040import org.openstreetmap.josm.gui.layer.WMSLayer;
041import org.openstreetmap.josm.io.OsmTransferException;
042import org.openstreetmap.josm.io.ProgressInputStream;
043import org.openstreetmap.josm.tools.ImageProvider;
044import org.openstreetmap.josm.tools.Utils;
045import org.w3c.dom.Document;
046import org.w3c.dom.NodeList;
047import org.xml.sax.InputSource;
048import org.xml.sax.SAXException;
049
050/**
051 * WMS grabber, fetching tiles from WMS server.
052 * @since 3715
053 */
054public class WMSGrabber implements Runnable {
055
056    protected final MapView mv;
057    protected final WMSLayer layer;
058    private final boolean localOnly;
059
060    protected ProjectionBounds b;
061    protected volatile boolean canceled;
062
063    protected String baseURL;
064    private ImageryInfo info;
065    private Map<String, String> props = new HashMap<>();
066
067    /**
068     * Constructs a new {@code WMSGrabber}.
069     * @param mv Map view
070     * @param layer WMS layer
071     */
072    public WMSGrabber(MapView mv, WMSLayer layer, boolean localOnly) {
073        this.mv = mv;
074        this.layer = layer;
075        this.localOnly = localOnly;
076        this.info = layer.getInfo();
077        this.baseURL = info.getUrl();
078        if (layer.getInfo().getCookies() != null && !layer.getInfo().getCookies().isEmpty()) {
079            props.put("Cookie", layer.getInfo().getCookies());
080        }
081        Pattern pattern = Pattern.compile("\\{header\\(([^,]+),([^}]+)\\)\\}");
082        StringBuffer output = new StringBuffer();
083        Matcher matcher = pattern.matcher(this.baseURL);
084        while (matcher.find()) {
085            props.put(matcher.group(1),matcher.group(2));
086            matcher.appendReplacement(output, "");
087        }
088        matcher.appendTail(output);
089        this.baseURL = output.toString();
090    }
091
092    int width() {
093        return layer.getBaseImageWidth();
094    }
095
096    int height() {
097        return layer.getBaseImageHeight();
098    }
099
100    @Override
101    public void run() {
102        while (true) {
103            if (canceled)
104                return;
105            WMSRequest request = layer.getRequest(localOnly);
106            if (request == null)
107                return;
108            this.b = layer.getBounds(request);
109            if (request.isPrecacheOnly()) {
110                if (!layer.cache.hasExactMatch(Main.getProjection(), request.getPixelPerDegree(), b.minEast, b.minNorth)) {
111                    attempt(request);
112                } else if (Main.isDebugEnabled()) {
113                    Main.debug("Ignoring "+request+" (precache only + exact match)");
114                }
115            } else if (!loadFromCache(request)){
116                attempt(request);
117            } else if (Main.isDebugEnabled()) {
118                Main.debug("Ignoring "+request+" (loaded from cache)");
119            }
120            layer.finishRequest(request);
121        }
122    }
123
124    protected void attempt(WMSRequest request){ // try to fetch the image
125        int maxTries = 5; // n tries for every image
126        for (int i = 1; i <= maxTries; i++) {
127            if (canceled)
128                return;
129            try {
130                if (!request.isPrecacheOnly() && !layer.requestIsVisible(request))
131                    return;
132                fetch(request, i);
133                break; // break out of the retry loop
134            } catch (IOException e) {
135                try { // sleep some time and then ask the server again
136                    Thread.sleep(random(1000, 2000));
137                } catch (InterruptedException e1) {
138                    Main.debug("InterruptedException in "+getClass().getSimpleName()+" during WMS request");
139                }
140                if (i == maxTries) {
141                    Main.error(e);
142                    request.finish(State.FAILED, null, null);
143                }
144            } catch (WMSException e) {
145                // Fail fast in case of WMS Service exception: useless to retry:
146                // either the URL is wrong or the server suffers huge problems
147                Main.error("WMS service exception while requesting "+e.getUrl()+":\n"+e.getMessage().trim());
148                request.finish(State.FAILED, null, e);
149                break; // break out of the retry loop
150            }
151        }
152    }
153
154    public static int random(int min, int max) {
155        return (int)(Math.random() * ((max+1)-min) ) + min;
156    }
157
158    public final void cancel() {
159        canceled = true;
160    }
161
162    private void fetch(WMSRequest request, int attempt) throws IOException, WMSException {
163        URL url = null;
164        try {
165            url = getURL(
166                    b.minEast, b.minNorth,
167                    b.maxEast, b.maxNorth,
168                    width(), height());
169            request.finish(State.IMAGE, grab(request, url, attempt), null);
170
171        } catch (IOException | OsmTransferException e) {
172            Main.error(e);
173            throw new IOException(e.getMessage() + "\nImage couldn't be fetched: " + (url != null ? url.toString() : ""), e);
174        }
175    }
176
177    public static final NumberFormat latLonFormat = new DecimalFormat("###0.0000000", new DecimalFormatSymbols(Locale.US));
178
179    protected URL getURL(double w, double s,double e,double n,
180            int wi, int ht) throws MalformedURLException {
181        String myProj = Main.getProjection().toCode();
182        if (!info.getServerProjections().contains(myProj) && "EPSG:3857".equals(Main.getProjection().toCode())) {
183            LatLon sw = Main.getProjection().eastNorth2latlon(new EastNorth(w, s));
184            LatLon ne = Main.getProjection().eastNorth2latlon(new EastNorth(e, n));
185            myProj = "EPSG:4326";
186            s = sw.lat();
187            w = sw.lon();
188            n = ne.lat();
189            e = ne.lon();
190        }
191        if ("EPSG:4326".equals(myProj) && !info.getServerProjections().contains(myProj) && info.getServerProjections().contains("CRS:84")) {
192            myProj = "CRS:84";
193        }
194
195        // Bounding box coordinates have to be switched for WMS 1.3.0 EPSG:4326.
196        //
197        // Background:
198        //
199        // bbox=x_min,y_min,x_max,y_max
200        //
201        //      SRS=... is WMS 1.1.1
202        //      CRS=... is WMS 1.3.0
203        //
204        // The difference:
205        //      For SRS x is east-west and y is north-south
206        //      For CRS x and y are as specified by the EPSG
207        //          E.g. [1] lists lat as first coordinate axis and lot as second, so it is switched for EPSG:4326.
208        //          For most other EPSG code there seems to be no difference.
209        // [1] https://www.epsg-registry.org/report.htm?type=selection&entity=urn:ogc:def:crs:EPSG::4326&reportDetail=short&style=urn:uuid:report-style:default-with-code&style_name=OGP%20Default%20With%20Code&title=EPSG:4326
210        boolean switchLatLon = false;
211        if (baseURL.toLowerCase().contains("crs=epsg:4326")) {
212            switchLatLon = true;
213        } else if (baseURL.toLowerCase().contains("crs=") && "EPSG:4326".equals(myProj)) {
214            switchLatLon = true;
215        }
216        String bbox;
217        if (switchLatLon) {
218            bbox = String.format("%s,%s,%s,%s", latLonFormat.format(s), latLonFormat.format(w), latLonFormat.format(n), latLonFormat.format(e));
219        } else {
220            bbox = String.format("%s,%s,%s,%s", latLonFormat.format(w), latLonFormat.format(s), latLonFormat.format(e), latLonFormat.format(n));
221        }
222        return new URL(baseURL.replaceAll("\\{proj(\\([^})]+\\))?\\}", myProj)
223                .replaceAll("\\{bbox\\}", bbox)
224                .replaceAll("\\{w\\}", latLonFormat.format(w))
225                .replaceAll("\\{s\\}", latLonFormat.format(s))
226                .replaceAll("\\{e\\}", latLonFormat.format(e))
227                .replaceAll("\\{n\\}", latLonFormat.format(n))
228                .replaceAll("\\{width\\}", String.valueOf(wi))
229                .replaceAll("\\{height\\}", String.valueOf(ht))
230                .replace(" ", "%20"));
231    }
232
233    public boolean loadFromCache(WMSRequest request) {
234        BufferedImage cached = layer.cache.getExactMatch(
235                Main.getProjection(), request.getPixelPerDegree(), b.minEast, b.minNorth);
236
237        if (cached != null) {
238            request.finish(State.IMAGE, cached, null);
239            return true;
240        } else if (request.isAllowPartialCacheMatch()) {
241            BufferedImage partialMatch = layer.cache.getPartialMatch(
242                    Main.getProjection(), request.getPixelPerDegree(), b.minEast, b.minNorth);
243            if (partialMatch != null) {
244                request.finish(State.PARTLY_IN_CACHE, partialMatch, null);
245                return true;
246            }
247        }
248
249        if ((!request.isReal() && !layer.hasAutoDownload())){
250            request.finish(State.NOT_IN_CACHE, null, null);
251            return true;
252        }
253
254        return false;
255    }
256
257    protected BufferedImage grab(WMSRequest request, URL url, int attempt) throws WMSException, IOException, OsmTransferException {
258        Main.info("Grabbing WMS " + (attempt > 1? "(attempt " + attempt + ") ":"") + url);
259
260        HttpURLConnection conn = Utils.openHttpConnection(url);
261        for (Entry<String, String> e : props.entrySet()) {
262            conn.setRequestProperty(e.getKey(), e.getValue());
263        }
264        conn.setConnectTimeout(Main.pref.getInteger("socket.timeout.connect",15) * 1000);
265        conn.setReadTimeout(Main.pref.getInteger("socket.timeout.read", 30) * 1000);
266
267        String contentType = conn.getHeaderField("Content-Type");
268        if (conn.getResponseCode() != 200
269                || contentType != null && !contentType.startsWith("image") ) {
270            String xml = readException(conn);
271            try {
272                DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder();
273                InputSource is = new InputSource(new StringReader(xml));
274                Document doc = db.parse(is);
275                NodeList nodes = doc.getElementsByTagName("ServiceException");
276                List<String> exceptions = new ArrayList<>(nodes.getLength());
277                for (int i = 0; i < nodes.getLength(); i++) {
278                    exceptions.add(nodes.item(i).getTextContent());
279                }
280                throw new WMSException(request, url, exceptions);
281            } catch (SAXException | ParserConfigurationException ex) {
282                throw new IOException(xml, ex);
283            }
284        }
285
286        ByteArrayOutputStream baos = new ByteArrayOutputStream();
287        try (InputStream is = new ProgressInputStream(conn, null)) {
288            Utils.copyStream(is, baos);
289        }
290
291        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
292        BufferedImage img = layer.normalizeImage(ImageProvider.read(bais, true, WMSLayer.PROP_ALPHA_CHANNEL.get()));
293        bais.reset();
294        layer.cache.saveToCache(layer.isOverlapEnabled()?img:null, bais, Main.getProjection(), request.getPixelPerDegree(), b.minEast, b.minNorth);
295        return img;
296    }
297
298    protected String readException(URLConnection conn) throws IOException {
299        StringBuilder exception = new StringBuilder();
300        InputStream in = conn.getInputStream();
301        try (BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
302            String line = null;
303            while( (line = br.readLine()) != null) {
304                // filter non-ASCII characters and control characters
305                exception.append(line.replaceAll("[^\\p{Print}]", ""));
306                exception.append('\n');
307            }
308            return exception.toString();
309        }
310    }
311}