1/*
2 * Copyright (c) 2009-2010 jMonkeyEngine
3 * All rights reserved.
4 *
5 * Redistribution and use in source and binary forms, with or without
6 * modification, are permitted provided that the following conditions are
7 * met:
8 *
9 * * Redistributions of source code must retain the above copyright
10 *   notice, this list of conditions and the following disclaimer.
11 *
12 * * Redistributions in binary form must reproduce the above copyright
13 *   notice, this list of conditions and the following disclaimer in the
14 *   documentation and/or other materials provided with the distribution.
15 *
16 * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
17 *   may be used to endorse or promote products derived from this software
18 *   without specific prior written permission.
19 *
20 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
22 * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
23 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
24 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
25 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
26 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
27 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
28 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
29 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31 */
32
33package com.jme3.asset.plugins;
34
35import com.jme3.asset.AssetInfo;
36import com.jme3.asset.AssetKey;
37import com.jme3.asset.AssetLocator;
38import com.jme3.asset.AssetManager;
39import java.io.IOException;
40import java.io.InputStream;
41import java.net.HttpURLConnection;
42import java.net.URL;
43import java.nio.ByteBuffer;
44import java.nio.CharBuffer;
45import java.nio.charset.CharacterCodingException;
46import java.nio.charset.Charset;
47import java.nio.charset.CharsetDecoder;
48import java.nio.charset.CoderResult;
49import java.util.HashMap;
50import java.util.logging.Level;
51import java.util.logging.Logger;
52import java.util.zip.Inflater;
53import java.util.zip.InflaterInputStream;
54import java.util.zip.ZipEntry;
55
56public class HttpZipLocator implements AssetLocator {
57
58    private static final Logger logger = Logger.getLogger(HttpZipLocator.class.getName());
59
60    private URL zipUrl;
61    private String rootPath = "";
62    private int numEntries;
63    private int tableOffset;
64    private int tableLength;
65    private HashMap<String, ZipEntry2> entries;
66
67    private static final ByteBuffer byteBuf = ByteBuffer.allocate(250);
68    private static final CharBuffer charBuf = CharBuffer.allocate(250);
69    private static final CharsetDecoder utf8Decoder;
70
71    public static final long LOCSIG = 0x4034b50, EXTSIG = 0x8074b50,
72      CENSIG = 0x2014b50, ENDSIG = 0x6054b50;
73
74    public static final int LOCHDR = 30, EXTHDR = 16, CENHDR = 46, ENDHDR = 22,
75      LOCVER = 4, LOCFLG = 6, LOCHOW = 8, LOCTIM = 10, LOCCRC = 14,
76      LOCSIZ = 18, LOCLEN = 22, LOCNAM = 26, LOCEXT = 28, EXTCRC = 4,
77      EXTSIZ = 8, EXTLEN = 12, CENVEM = 4, CENVER = 6, CENFLG = 8,
78      CENHOW = 10, CENTIM = 12, CENCRC = 16, CENSIZ = 20, CENLEN = 24,
79      CENNAM = 28, CENEXT = 30, CENCOM = 32, CENDSK = 34, CENATT = 36,
80      CENATX = 38, CENOFF = 42, ENDSUB = 8, ENDTOT = 10, ENDSIZ = 12,
81      ENDOFF = 16, ENDCOM = 20;
82
83    static {
84        Charset utf8 = Charset.forName("UTF-8");
85        utf8Decoder = utf8.newDecoder();
86    }
87
88    private static class ZipEntry2 {
89        String name;
90        int length;
91        int offset;
92        int compSize;
93        long crc;
94        boolean deflate;
95
96        @Override
97        public String toString(){
98            return "ZipEntry[name=" + name +
99                         ",  length=" + length +
100                         ",  compSize=" + compSize +
101                         ",  offset=" + offset + "]";
102        }
103    }
104
105    private static int get16(byte[] b, int off) {
106	return  (b[off++] & 0xff) |
107               ((b[off]   & 0xff) << 8);
108    }
109
110    private static int get32(byte[] b, int off) {
111	return  (b[off++] & 0xff) |
112               ((b[off++] & 0xff) << 8) |
113               ((b[off++] & 0xff) << 16) |
114               ((b[off] & 0xff) << 24);
115    }
116
117    private static long getu32(byte[] b, int off) throws IOException{
118        return (b[off++]&0xff) |
119              ((b[off++]&0xff) << 8) |
120              ((b[off++]&0xff) << 16) |
121             (((long)(b[off]&0xff)) << 24);
122    }
123
124    private static String getUTF8String(byte[] b, int off, int len) throws CharacterCodingException {
125        StringBuilder sb = new StringBuilder();
126
127        int read = 0;
128        while (read < len){
129            // Either read n remaining bytes in b or 250 if n is higher.
130            int toRead = Math.min(len - read, byteBuf.capacity());
131
132            boolean endOfInput = toRead < byteBuf.capacity();
133
134            // read 'toRead' bytes into byteBuf
135            byteBuf.put(b, off + read, toRead);
136
137            // set limit to position and set position to 0
138            // so data can be decoded
139            byteBuf.flip();
140
141            // decode data in byteBuf
142            CoderResult result = utf8Decoder.decode(byteBuf, charBuf, endOfInput);
143
144            // if the result is not an underflow its an error
145            // that cannot be handled.
146            // if the error is an underflow and its the end of input
147            // then the decoder expects more bytes but there are no more => error
148            if (!result.isUnderflow() || !endOfInput){
149                result.throwException();
150            }
151
152            // flip the char buf to get the string just decoded
153            charBuf.flip();
154
155            // append the decoded data into the StringBuilder
156            sb.append(charBuf.toString());
157
158            // clear buffers for next use
159            byteBuf.clear();
160            charBuf.clear();
161
162            read += toRead;
163        }
164
165        return sb.toString();
166    }
167
168    private InputStream readData(int offset, int length) throws IOException{
169        HttpURLConnection conn = (HttpURLConnection) zipUrl.openConnection();
170        conn.setDoOutput(false);
171        conn.setUseCaches(false);
172        conn.setInstanceFollowRedirects(false);
173        String range = "-";
174        if (offset != Integer.MAX_VALUE){
175            range = offset + range;
176        }
177        if (length != Integer.MAX_VALUE){
178            if (offset != Integer.MAX_VALUE){
179                range = range + (offset + length - 1);
180            }else{
181                range = range + length;
182            }
183        }
184
185        conn.setRequestProperty("Range", "bytes=" + range);
186        conn.connect();
187        if (conn.getResponseCode() == HttpURLConnection.HTTP_PARTIAL){
188            return conn.getInputStream();
189        }else if (conn.getResponseCode() == HttpURLConnection.HTTP_OK){
190            throw new IOException("Your server does not support HTTP feature Content-Range. Please contact your server administrator.");
191        }else{
192            throw new IOException(conn.getResponseCode() + " " + conn.getResponseMessage());
193        }
194    }
195
196    private int readTableEntry(byte[] table, int offset) throws IOException{
197        if (get32(table, offset) != CENSIG){
198            throw new IOException("Central directory error, expected 'PK12'");
199        }
200
201        int nameLen = get16(table, offset + CENNAM);
202        int extraLen = get16(table, offset + CENEXT);
203        int commentLen = get16(table, offset + CENCOM);
204        int newOffset = offset + CENHDR + nameLen + extraLen + commentLen;
205
206        int flags = get16(table, offset + CENFLG);
207        if ((flags & 1) == 1){
208            // ignore this entry, it uses encryption
209            return newOffset;
210        }
211
212        int method = get16(table, offset + CENHOW);
213        if (method != ZipEntry.DEFLATED && method != ZipEntry.STORED){
214            // ignore this entry, it uses unknown compression method
215            return newOffset;
216        }
217
218        String name = getUTF8String(table, offset + CENHDR, nameLen);
219        if (name.charAt(name.length()-1) == '/'){
220            // ignore this entry, it is directory node
221            // or it has no name (?)
222            return newOffset;
223        }
224
225        ZipEntry2 entry = new ZipEntry2();
226        entry.name     = name;
227        entry.deflate  = (method == ZipEntry.DEFLATED);
228        entry.crc      = getu32(table, offset + CENCRC);
229        entry.length   = get32(table, offset + CENLEN);
230        entry.compSize = get32(table, offset + CENSIZ);
231        entry.offset   = get32(table, offset + CENOFF);
232
233        // we want offset directly into file data ..
234        // move the offset forward to skip the LOC header
235        entry.offset += LOCHDR + nameLen + extraLen;
236
237        entries.put(entry.name, entry);
238
239        return newOffset;
240    }
241
242    private void fillByteArray(byte[] array, InputStream source) throws IOException{
243        int total = 0;
244        int length = array.length;
245	while (total < length) {
246	    int read = source.read(array, total, length - total);
247            if (read < 0)
248                throw new IOException("Failed to read entire array");
249
250	    total += read;
251	}
252    }
253
254    private void readCentralDirectory() throws IOException{
255        InputStream in = readData(tableOffset, tableLength);
256        byte[] header = new byte[tableLength];
257
258        // Fix for "PK12 bug in town.zip": sometimes
259        // not entire byte array will be read with InputStream.read()
260        // (especially for big headers)
261        fillByteArray(header, in);
262
263//        in.read(header);
264        in.close();
265
266        entries = new HashMap<String, ZipEntry2>(numEntries);
267        int offset = 0;
268        for (int i = 0; i < numEntries; i++){
269            offset = readTableEntry(header, offset);
270        }
271    }
272
273    private void readEndHeader() throws IOException{
274
275//        InputStream in = readData(Integer.MAX_VALUE, ENDHDR);
276//        byte[] header = new byte[ENDHDR];
277//        fillByteArray(header, in);
278//        in.close();
279//
280//        if (get32(header, 0) != ENDSIG){
281//            throw new IOException("End header error, expected 'PK56'");
282//        }
283
284        // Fix for "PK56 bug in town.zip":
285        // If there's a zip comment inside the end header,
286        // PK56 won't appear in the -22 position relative to the end of the
287        // file!
288        // In that case, we have to search for it.
289        // Increase search space to 200 bytes
290
291        InputStream in = readData(Integer.MAX_VALUE, 200);
292        byte[] header = new byte[200];
293        fillByteArray(header, in);
294        in.close();
295
296        int offset = -1;
297        for (int i = 200 - 22; i >= 0; i--){
298            if (header[i] == (byte) (ENDSIG & 0xff)
299              && get32(header, i) == ENDSIG){
300                // found location
301                offset = i;
302                break;
303            }
304        }
305        if (offset == -1)
306            throw new IOException("Cannot find Zip End Header in file!");
307
308        numEntries  = get16(header, offset + ENDTOT);
309        tableLength = get32(header, offset + ENDSIZ);
310        tableOffset = get32(header, offset + ENDOFF);
311    }
312
313    public void load(URL url) throws IOException {
314        if (!url.getProtocol().equals("http"))
315            throw new UnsupportedOperationException();
316
317        zipUrl = url;
318        readEndHeader();
319        readCentralDirectory();
320    }
321
322    private InputStream openStream(ZipEntry2 entry) throws IOException{
323        InputStream in = readData(entry.offset, entry.compSize);
324        if (entry.deflate){
325            return new InflaterInputStream(in, new Inflater(true));
326        }
327        return in;
328    }
329
330    public InputStream openStream(String name) throws IOException{
331        ZipEntry2 entry = entries.get(name);
332        if (entry == null)
333            throw new RuntimeException("Entry not found: "+name);
334
335        return openStream(entry);
336    }
337
338    public void setRootPath(String path){
339        if (!rootPath.equals(path)){
340            rootPath = path;
341            try {
342                load(new URL(path));
343            } catch (IOException ex) {
344                logger.log(Level.WARNING, "Failed to set root path "+path, ex);
345            }
346        }
347    }
348
349    public AssetInfo locate(AssetManager manager, AssetKey key){
350        final ZipEntry2 entry = entries.get(key.getName());
351        if (entry == null)
352            return null;
353
354        return new AssetInfo(manager, key){
355            @Override
356            public InputStream openStream() {
357                try {
358                    return HttpZipLocator.this.openStream(entry);
359                } catch (IOException ex) {
360                    logger.log(Level.WARNING, "Error retrieving "+entry.name, ex);
361                    return null;
362                }
363            }
364        };
365    }
366
367}
368