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