blob: 0095b91f250b21b1b2ce77451d3ea1fd6f1e4c9c [file] [log] [blame]
The Android Open Source Project9066cfe2009-03-03 19:31:44 -08001/*
2 * Copyright (C) 2006 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.webkit;
18
19import android.content.Context;
20import android.net.http.Headers;
21import android.os.FileUtils;
The Android Open Source Project9066cfe2009-03-03 19:31:44 -080022import android.util.Log;
23import java.io.File;
24import java.io.FileInputStream;
25import java.io.FileNotFoundException;
26import java.io.FileOutputStream;
27import java.io.IOException;
28import java.io.InputStream;
29import java.io.OutputStream;
30import java.util.ArrayList;
31import java.util.Map;
32
33import org.bouncycastle.crypto.Digest;
34import org.bouncycastle.crypto.digests.SHA1Digest;
35
36/**
37 * The class CacheManager provides the persistent cache of content that is
38 * received over the network. The component handles parsing of HTTP headers and
39 * utilizes the relevant cache headers to determine if the content should be
40 * stored and if so, how long it is valid for. Network requests are provided to
41 * this component and if they can not be resolved by the cache, the HTTP headers
42 * are attached, as appropriate, to the request for revalidation of content. The
43 * class also manages the cache size.
44 */
45public final class CacheManager {
46
47 private static final String LOGTAG = "cache";
48
49 static final String HEADER_KEY_IFMODIFIEDSINCE = "if-modified-since";
50 static final String HEADER_KEY_IFNONEMATCH = "if-none-match";
51
52 private static final String NO_STORE = "no-store";
53 private static final String NO_CACHE = "no-cache";
54 private static final String MAX_AGE = "max-age";
55
56 private static long CACHE_THRESHOLD = 6 * 1024 * 1024;
57 private static long CACHE_TRIM_AMOUNT = 2 * 1024 * 1024;
58
59 private static boolean mDisabled;
60
61 // Reference count the enable/disable transaction
62 private static int mRefCount;
63
64 // trimCacheIfNeeded() is called when a page is fully loaded. But JavaScript
65 // can load the content, e.g. in a slideshow, continuously, so we need to
66 // trim the cache on a timer base too. endCacheTransaction() is called on a
67 // timer base. We share the same timer with less frequent update.
68 private static int mTrimCacheCount = 0;
69 private static final int TRIM_CACHE_INTERVAL = 5;
70
71 private static WebViewDatabase mDataBase;
72 private static File mBaseDir;
73
74 // Flag to clear the cache when the CacheManager is initialized
75 private static boolean mClearCacheOnInit = false;
76
77 public static class CacheResult {
78 // these fields are saved to the database
79 int httpStatusCode;
80 long contentLength;
81 long expires;
82 String localPath;
83 String lastModified;
84 String etag;
85 String mimeType;
86 String location;
87 String encoding;
88
89 // these fields are NOT saved to the database
90 InputStream inStream;
91 OutputStream outStream;
92 File outFile;
93
94 public int getHttpStatusCode() {
95 return httpStatusCode;
96 }
97
98 public long getContentLength() {
99 return contentLength;
100 }
101
102 public String getLocalPath() {
103 return localPath;
104 }
105
106 public long getExpires() {
107 return expires;
108 }
109
110 public String getLastModified() {
111 return lastModified;
112 }
113
114 public String getETag() {
115 return etag;
116 }
117
118 public String getMimeType() {
119 return mimeType;
120 }
121
122 public String getLocation() {
123 return location;
124 }
125
126 public String getEncoding() {
127 return encoding;
128 }
129
130 // For out-of-package access to the underlying streams.
131 public InputStream getInputStream() {
132 return inStream;
133 }
134
135 public OutputStream getOutputStream() {
136 return outStream;
137 }
138
139 // These fields can be set manually.
140 public void setInputStream(InputStream stream) {
141 this.inStream = stream;
142 }
143
144 public void setEncoding(String encoding) {
145 this.encoding = encoding;
146 }
147 }
148
149 /**
150 * initialize the CacheManager. WebView should handle this for each process.
151 *
152 * @param context The application context.
153 */
154 static void init(Context context) {
155 mDataBase = WebViewDatabase.getInstance(context);
156 mBaseDir = new File(context.getCacheDir(), "webviewCache");
157 if (createCacheDirectory() && mClearCacheOnInit) {
158 removeAllCacheFiles();
159 mClearCacheOnInit = false;
160 }
161 }
162
163 /**
164 * Create the cache directory if it does not already exist.
165 *
166 * @return true if the cache directory didn't exist and was created.
167 */
168 static private boolean createCacheDirectory() {
169 if (!mBaseDir.exists()) {
170 if(!mBaseDir.mkdirs()) {
171 Log.w(LOGTAG, "Unable to create webviewCache directory");
172 return false;
173 }
174 FileUtils.setPermissions(
175 mBaseDir.toString(),
176 FileUtils.S_IRWXU|FileUtils.S_IRWXG|FileUtils.S_IXOTH,
177 -1, -1);
178 // If we did create the directory, we need to flush
179 // the cache database. The directory could be recreated
180 // because the system flushed all the data/cache directories
181 // to free up disk space.
182 WebViewCore.endCacheTransaction();
183 mDataBase.clearCache();
184 WebViewCore.startCacheTransaction();
185 return true;
186 }
187 return false;
188 }
189
190 /**
191 * get the base directory of the cache. With localPath of the CacheResult,
192 * it identifies the cache file.
193 *
194 * @return File The base directory of the cache.
195 */
196 public static File getCacheFileBaseDir() {
197 return mBaseDir;
198 }
199
200 /**
201 * set the flag to control whether cache is enabled or disabled
202 *
203 * @param disabled true to disable the cache
204 */
205 // only called from WebCore thread
206 static void setCacheDisabled(boolean disabled) {
207 if (disabled == mDisabled) {
208 return;
209 }
210 mDisabled = disabled;
211 if (mDisabled) {
212 removeAllCacheFiles();
213 }
214 }
215
216 /**
217 * get the state of the current cache, enabled or disabled
218 *
219 * @return return if it is disabled
220 */
221 public static boolean cacheDisabled() {
222 return mDisabled;
223 }
224
225 // only called from WebCore thread
226 // make sure to call enableTransaction/disableTransaction in pair
227 static boolean enableTransaction() {
228 if (++mRefCount == 1) {
229 mDataBase.startCacheTransaction();
230 return true;
231 }
232 return false;
233 }
234
235 // only called from WebCore thread
236 // make sure to call enableTransaction/disableTransaction in pair
237 static boolean disableTransaction() {
238 if (mRefCount == 0) {
239 Log.e(LOGTAG, "disableTransaction is out of sync");
240 }
241 if (--mRefCount == 0) {
242 mDataBase.endCacheTransaction();
243 return true;
244 }
245 return false;
246 }
247
248 // only called from WebCore thread
249 // make sure to call startCacheTransaction/endCacheTransaction in pair
250 public static boolean startCacheTransaction() {
251 return mDataBase.startCacheTransaction();
252 }
253
254 // only called from WebCore thread
255 // make sure to call startCacheTransaction/endCacheTransaction in pair
256 public static boolean endCacheTransaction() {
257 boolean ret = mDataBase.endCacheTransaction();
258 if (++mTrimCacheCount >= TRIM_CACHE_INTERVAL) {
259 mTrimCacheCount = 0;
260 trimCacheIfNeeded();
261 }
262 return ret;
263 }
264
265 /**
266 * Given a url, returns the CacheResult if exists. Otherwise returns null.
267 * If headers are provided and a cache needs validation,
268 * HEADER_KEY_IFNONEMATCH or HEADER_KEY_IFMODIFIEDSINCE will be set in the
269 * cached headers.
270 *
271 * @return the CacheResult for a given url
272 */
273 // only called from WebCore thread
274 public static CacheResult getCacheFile(String url,
275 Map<String, String> headers) {
276 if (mDisabled) {
277 return null;
278 }
279
280 CacheResult result = mDataBase.getCache(url);
281 if (result != null) {
282 if (result.contentLength == 0) {
The Android Open Source Project10592532009-03-18 17:39:46 -0700283 if (!checkCacheRedirect(result.httpStatusCode)) {
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800284 // this should not happen. If it does, remove it.
285 mDataBase.removeCache(url);
286 return null;
287 }
288 } else {
289 File src = new File(mBaseDir, result.localPath);
290 try {
291 // open here so that even the file is deleted, the content
292 // is still readable by the caller until close() is called
293 result.inStream = new FileInputStream(src);
294 } catch (FileNotFoundException e) {
295 // the files in the cache directory can be removed by the
296 // system. If it is gone, clean up the database
297 mDataBase.removeCache(url);
298 return null;
299 }
300 }
301 } else {
302 return null;
303 }
304
305 // null headers request coming from CACHE_MODE_CACHE_ONLY
306 // which implies that it needs cache even it is expired.
307 // negative expires means time in the far future.
308 if (headers != null && result.expires >= 0
309 && result.expires <= System.currentTimeMillis()) {
310 if (result.lastModified == null && result.etag == null) {
311 return null;
312 }
313 // return HEADER_KEY_IFNONEMATCH or HEADER_KEY_IFMODIFIEDSINCE
314 // for requesting validation
315 if (result.etag != null) {
316 headers.put(HEADER_KEY_IFNONEMATCH, result.etag);
317 }
318 if (result.lastModified != null) {
319 headers.put(HEADER_KEY_IFMODIFIEDSINCE, result.lastModified);
320 }
321 }
322
Dave Bort42bc2ff2009-04-13 15:07:51 -0700323 if (WebView.LOGV_ENABLED) {
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800324 Log.v(LOGTAG, "getCacheFile for url " + url);
325 }
326
327 return result;
328 }
329
330 /**
331 * Given a url and its full headers, returns CacheResult if a local cache
332 * can be stored. Otherwise returns null. The mimetype is passed in so that
333 * the function can use the mimetype that will be passed to WebCore which
334 * could be different from the mimetype defined in the headers.
335 * forceCache is for out-of-package callers to force creation of a
336 * CacheResult, and is used to supply surrogate responses for URL
337 * interception.
338 * @return CacheResult for a given url
339 * @hide - hide createCacheFile since it has a parameter of type headers, which is
340 * in a hidden package.
341 */
Grace Kloba3afdd562009-04-02 10:55:37 -0700342 // only called from WebCore thread
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800343 public static CacheResult createCacheFile(String url, int statusCode,
344 Headers headers, String mimeType, boolean forceCache) {
345 if (!forceCache && mDisabled) {
346 return null;
347 }
348
The Android Open Source Project10592532009-03-18 17:39:46 -0700349 // according to the rfc 2616, the 303 response MUST NOT be cached.
350 if (statusCode == 303) {
Grace Kloba3afdd562009-04-02 10:55:37 -0700351 // remove the saved cache if there is any
352 mDataBase.removeCache(url);
The Android Open Source Project10592532009-03-18 17:39:46 -0700353 return null;
354 }
355
356 // like the other browsers, do not cache redirects containing a cookie
357 // header.
358 if (checkCacheRedirect(statusCode) && !headers.getSetCookie().isEmpty()) {
Grace Kloba3afdd562009-04-02 10:55:37 -0700359 // remove the saved cache if there is any
360 mDataBase.removeCache(url);
The Android Open Source Project10592532009-03-18 17:39:46 -0700361 return null;
362 }
363
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800364 CacheResult ret = parseHeaders(statusCode, headers, mimeType);
Grace Kloba3afdd562009-04-02 10:55:37 -0700365 if (ret == null) {
366 // this should only happen if the headers has "no-store" in the
367 // cache-control. remove the saved cache if there is any
368 mDataBase.removeCache(url);
369 } else {
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800370 setupFiles(url, ret);
371 try {
372 ret.outStream = new FileOutputStream(ret.outFile);
373 } catch (FileNotFoundException e) {
374 // This can happen with the system did a purge and our
375 // subdirectory has gone, so lets try to create it again
376 if (createCacheDirectory()) {
377 try {
378 ret.outStream = new FileOutputStream(ret.outFile);
379 } catch (FileNotFoundException e2) {
380 // We failed to create the file again, so there
381 // is something else wrong. Return null.
382 return null;
383 }
384 } else {
385 // Failed to create cache directory
386 return null;
387 }
388 }
389 ret.mimeType = mimeType;
390 }
391
392 return ret;
393 }
394
395 /**
396 * Save the info of a cache file for a given url to the CacheMap so that it
397 * can be reused later
398 */
399 // only called from WebCore thread
400 public static void saveCacheFile(String url, CacheResult cacheRet) {
401 try {
402 cacheRet.outStream.close();
403 } catch (IOException e) {
404 return;
405 }
406
407 if (!cacheRet.outFile.exists()) {
408 // the file in the cache directory can be removed by the system
409 return;
410 }
411
412 cacheRet.contentLength = cacheRet.outFile.length();
The Android Open Source Project10592532009-03-18 17:39:46 -0700413 if (checkCacheRedirect(cacheRet.httpStatusCode)) {
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800414 // location is in database, no need to keep the file
415 cacheRet.contentLength = 0;
416 cacheRet.localPath = new String();
417 cacheRet.outFile.delete();
418 } else if (cacheRet.contentLength == 0) {
419 cacheRet.outFile.delete();
420 return;
421 }
422
423 mDataBase.addCache(url, cacheRet);
424
Dave Bort42bc2ff2009-04-13 15:07:51 -0700425 if (WebView.LOGV_ENABLED) {
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800426 Log.v(LOGTAG, "saveCacheFile for url " + url);
427 }
428 }
429
430 /**
431 * remove all cache files
432 *
433 * @return true if it succeeds
434 */
435 // only called from WebCore thread
436 static boolean removeAllCacheFiles() {
437 // Note, this is called before init() when the database is
438 // created or upgraded.
439 if (mBaseDir == null) {
440 // Init() has not been called yet, so just flag that
441 // we need to clear the cache when init() is called.
442 mClearCacheOnInit = true;
443 return true;
444 }
445 // delete cache in a separate thread to not block UI.
446 final Runnable clearCache = new Runnable() {
447 public void run() {
448 // delete all cache files
449 try {
450 String[] files = mBaseDir.list();
451 // if mBaseDir doesn't exist, files can be null.
452 if (files != null) {
453 for (int i = 0; i < files.length; i++) {
454 new File(mBaseDir, files[i]).delete();
455 }
456 }
457 } catch (SecurityException e) {
458 // Ignore SecurityExceptions.
459 }
460 // delete database
461 mDataBase.clearCache();
462 }
463 };
464 new Thread(clearCache).start();
465 return true;
466 }
467
468 /**
469 * Return true if the cache is empty.
470 */
471 // only called from WebCore thread
472 static boolean cacheEmpty() {
473 return mDataBase.hasCache();
474 }
475
476 // only called from WebCore thread
477 static void trimCacheIfNeeded() {
478 if (mDataBase.getCacheTotalSize() > CACHE_THRESHOLD) {
479 ArrayList<String> pathList = mDataBase.trimCache(CACHE_TRIM_AMOUNT);
480 int size = pathList.size();
481 for (int i = 0; i < size; i++) {
482 new File(mBaseDir, pathList.get(i)).delete();
483 }
484 }
485 }
486
The Android Open Source Project10592532009-03-18 17:39:46 -0700487 private static boolean checkCacheRedirect(int statusCode) {
488 if (statusCode == 301 || statusCode == 302 || statusCode == 307) {
489 // as 303 can't be cached, we do not return true
490 return true;
491 } else {
492 return false;
493 }
494 }
495
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800496 @SuppressWarnings("deprecation")
497 private static void setupFiles(String url, CacheResult cacheRet) {
498 if (true) {
499 // Note: SHA1 is much stronger hash. But the cost of setupFiles() is
500 // 3.2% cpu time for a fresh load of nytimes.com. While a simple
501 // String.hashCode() is only 0.6%. If adding the collision resolving
502 // to String.hashCode(), it makes the cpu time to be 1.6% for a
503 // fresh load, but 5.3% for the worst case where all the files
504 // already exist in the file system, but database is gone. So it
505 // needs to resolve collision for every file at least once.
506 int hashCode = url.hashCode();
507 StringBuffer ret = new StringBuffer(8);
508 appendAsHex(hashCode, ret);
509 String path = ret.toString();
510 File file = new File(mBaseDir, path);
511 if (true) {
512 boolean checkOldPath = true;
513 // Check hash collision. If the hash file doesn't exist, just
514 // continue. There is a chance that the old cache file is not
515 // same as the hash file. As mDataBase.getCache() is more
516 // expansive than "leak" a file until clear cache, don't bother.
517 // If the hash file exists, make sure that it is same as the
518 // cache file. If it is not, resolve the collision.
519 while (file.exists()) {
520 if (checkOldPath) {
521 // as this is called from http thread through
522 // createCacheFile, we need endCacheTransaction before
523 // database access.
524 WebViewCore.endCacheTransaction();
525 CacheResult oldResult = mDataBase.getCache(url);
526 WebViewCore.startCacheTransaction();
527 if (oldResult != null && oldResult.contentLength > 0) {
528 if (path.equals(oldResult.localPath)) {
529 path = oldResult.localPath;
530 } else {
531 path = oldResult.localPath;
532 file = new File(mBaseDir, path);
533 }
534 break;
535 }
536 checkOldPath = false;
537 }
538 ret = new StringBuffer(8);
539 appendAsHex(++hashCode, ret);
540 path = ret.toString();
541 file = new File(mBaseDir, path);
542 }
543 }
544 cacheRet.localPath = path;
545 cacheRet.outFile = file;
546 } else {
547 // get hash in byte[]
548 Digest digest = new SHA1Digest();
549 int digestLen = digest.getDigestSize();
550 byte[] hash = new byte[digestLen];
551 int urlLen = url.length();
552 byte[] data = new byte[urlLen];
553 url.getBytes(0, urlLen, data, 0);
554 digest.update(data, 0, urlLen);
555 digest.doFinal(hash, 0);
556 // convert byte[] to hex String
557 StringBuffer result = new StringBuffer(2 * digestLen);
558 for (int i = 0; i < digestLen; i = i + 4) {
559 int h = (0x00ff & hash[i]) << 24 | (0x00ff & hash[i + 1]) << 16
560 | (0x00ff & hash[i + 2]) << 8 | (0x00ff & hash[i + 3]);
561 appendAsHex(h, result);
562 }
563 cacheRet.localPath = result.toString();
564 cacheRet.outFile = new File(mBaseDir, cacheRet.localPath);
565 }
566 }
567
568 private static void appendAsHex(int i, StringBuffer ret) {
569 String hex = Integer.toHexString(i);
570 switch (hex.length()) {
571 case 1:
572 ret.append("0000000");
573 break;
574 case 2:
575 ret.append("000000");
576 break;
577 case 3:
578 ret.append("00000");
579 break;
580 case 4:
581 ret.append("0000");
582 break;
583 case 5:
584 ret.append("000");
585 break;
586 case 6:
587 ret.append("00");
588 break;
589 case 7:
590 ret.append("0");
591 break;
592 }
593 ret.append(hex);
594 }
595
596 private static CacheResult parseHeaders(int statusCode, Headers headers,
597 String mimeType) {
598 // TODO: if authenticated or secure, return null
599 CacheResult ret = new CacheResult();
600 ret.httpStatusCode = statusCode;
601
602 String location = headers.getLocation();
603 if (location != null) ret.location = location;
604
605 ret.expires = -1;
606 String expires = headers.getExpires();
607 if (expires != null) {
608 try {
609 ret.expires = HttpDateTime.parse(expires);
610 } catch (IllegalArgumentException ex) {
611 // Take care of the special "-1" and "0" cases
612 if ("-1".equals(expires) || "0".equals(expires)) {
613 // make it expired, but can be used for history navigation
614 ret.expires = 0;
615 } else {
616 Log.e(LOGTAG, "illegal expires: " + expires);
617 }
618 }
619 }
620
621 String lastModified = headers.getLastModified();
622 if (lastModified != null) ret.lastModified = lastModified;
623
624 String etag = headers.getEtag();
625 if (etag != null) ret.etag = etag;
626
627 String cacheControl = headers.getCacheControl();
628 if (cacheControl != null) {
629 String[] controls = cacheControl.toLowerCase().split("[ ,;]");
630 for (int i = 0; i < controls.length; i++) {
631 if (NO_STORE.equals(controls[i])) {
632 return null;
633 }
634 // According to the spec, 'no-cache' means that the content
635 // must be re-validated on every load. It does not mean that
636 // the content can not be cached. set to expire 0 means it
637 // can only be used in CACHE_MODE_CACHE_ONLY case
Grace Kloba52cf58a2009-03-25 20:05:44 -0700638 if (NO_CACHE.equals(controls[i])) {
The Android Open Source Project9066cfe2009-03-03 19:31:44 -0800639 ret.expires = 0;
640 } else if (controls[i].startsWith(MAX_AGE)) {
641 int separator = controls[i].indexOf('=');
642 if (separator < 0) {
643 separator = controls[i].indexOf(':');
644 }
645 if (separator > 0) {
646 String s = controls[i].substring(separator + 1);
647 try {
648 long sec = Long.parseLong(s);
649 if (sec >= 0) {
650 ret.expires = System.currentTimeMillis() + 1000
651 * sec;
652 }
653 } catch (NumberFormatException ex) {
654 if ("1d".equals(s)) {
655 // Take care of the special "1d" case
656 ret.expires = System.currentTimeMillis() + 86400000; // 24*60*60*1000
657 } else {
658 Log.e(LOGTAG, "exception in parseHeaders for "
659 + "max-age:"
660 + controls[i].substring(separator + 1));
661 ret.expires = 0;
662 }
663 }
664 }
665 }
666 }
667 }
668
669 // According to RFC 2616 section 14.32:
670 // HTTP/1.1 caches SHOULD treat "Pragma: no-cache" as if the
671 // client had sent "Cache-Control: no-cache"
672 if (NO_CACHE.equals(headers.getPragma())) {
673 ret.expires = 0;
674 }
675
676 // According to RFC 2616 section 13.2.4, if an expiration has not been
677 // explicitly defined a heuristic to set an expiration may be used.
678 if (ret.expires == -1) {
679 if (ret.httpStatusCode == 301) {
680 // If it is a permanent redirect, and it did not have an
681 // explicit cache directive, then it never expires
682 ret.expires = Long.MAX_VALUE;
683 } else if (ret.httpStatusCode == 302 || ret.httpStatusCode == 307) {
684 // If it is temporary redirect, expires
685 ret.expires = 0;
686 } else if (ret.lastModified == null) {
687 // When we have no last-modified, then expire the content with
688 // in 24hrs as, according to the RFC, longer time requires a
689 // warning 113 to be added to the response.
690
691 // Only add the default expiration for non-html markup. Some
692 // sites like news.google.com have no cache directives.
693 if (!mimeType.startsWith("text/html")) {
694 ret.expires = System.currentTimeMillis() + 86400000; // 24*60*60*1000
695 } else {
696 // Setting a expires as zero will cache the result for
697 // forward/back nav.
698 ret.expires = 0;
699 }
700 } else {
701 // If we have a last-modified value, we could use it to set the
702 // expiration. Suggestion from RFC is 10% of time since
703 // last-modified. As we are on mobile, loads are expensive,
704 // increasing this to 20%.
705
706 // 24 * 60 * 60 * 1000
707 long lastmod = System.currentTimeMillis() + 86400000;
708 try {
709 lastmod = HttpDateTime.parse(ret.lastModified);
710 } catch (IllegalArgumentException ex) {
711 Log.e(LOGTAG, "illegal lastModified: " + ret.lastModified);
712 }
713 long difference = System.currentTimeMillis() - lastmod;
714 if (difference > 0) {
715 ret.expires = System.currentTimeMillis() + difference / 5;
716 } else {
717 // last modified is in the future, expire the content
718 // on the last modified
719 ret.expires = lastmod;
720 }
721 }
722 }
723
724 return ret;
725 }
726}