1 /** 2 Copyright: © 2013-2014 rejectedsoftware e.K. 3 License: Subject to the terms of the GNU GPLv3 license, as written in the included LICENSE.txt file. 4 Authors: Sönke Ludwig 5 */ 6 module dubregistry.cache; 7 8 import vibe.core.log; 9 import vibe.core.stream; 10 import vibe.db.mongo.mongo; 11 import vibe.http.client; 12 import vibe.stream.memory; 13 14 import core.time; 15 import std.algorithm : startsWith; 16 import std.exception; 17 import std.typecons : tuple; 18 19 20 enum CacheMatchMode { 21 always, // return cached data if available 22 etag, // return cached data if the server responds with "not modified" 23 never // always request fresh data 24 } 25 26 27 class URLCache { 28 @safe: 29 private { 30 MongoClient m_db; 31 MongoCollection m_entries; 32 Duration m_maxCacheTime = 365.days; 33 } 34 35 this() 36 { 37 m_db = connectMongoDB("127.0.0.1"); 38 m_entries = m_db.getCollection("urlcache.entries"); 39 m_entries.ensureIndex([tuple("url", 1)]); 40 } 41 42 void clearEntry(URL url) 43 { 44 m_entries.remove(["url": url.toString()]); 45 } 46 47 void get(URL url, scope void delegate(scope InputStream str) @safe callback, bool cache_priority = false) 48 { 49 get(url, callback, cache_priority ? CacheMatchMode.always : CacheMatchMode.etag); 50 } 51 52 void get(URL url, scope void delegate(scope InputStream str) @safe callback, CacheMatchMode mode = CacheMatchMode.etag) 53 { 54 import std.datetime : Clock, UTC; 55 import vibe.http.auth.basic_auth; 56 import dubregistry.internal.utils : black; 57 import vibe.internal.interfaceproxy : asInterface; 58 59 auto user = url.username; 60 auto password = url.password; 61 url.username = null; 62 url.password = null; 63 64 InputStream result; 65 bool handled_uncached = false; 66 67 auto now = Clock.currTime(UTC()); 68 69 foreach (i; 0 .. 10) { // follow max 10 redirects 70 auto be = m_entries.findOne(["url": url.toString()]); 71 CacheEntry entry; 72 if (!be.isNull()) { 73 // invalidate out of date cache entries 74 if (be["_id"].get!BsonObjectID.timeStamp < now - m_maxCacheTime) 75 m_entries.remove(["_id": be["_id"]]); 76 77 deserializeBson(entry, be); 78 if (mode == CacheMatchMode.always) { 79 // directly return cache result for cache_priority == true 80 logDiagnostic("Cache HIT (early): %s", url.toString()); 81 if (entry.redirectURL.length) { 82 url = URL(entry.redirectURL); 83 continue; 84 } else { 85 auto data = be["data"].get!BsonBinData().rawData(); 86 auto mdata = () @trusted { return cast(ubyte[])data; } (); 87 scope tmpresult = createMemoryStream(mdata, false); 88 callback(tmpresult); 89 return; 90 } 91 } 92 } else { 93 entry._id = BsonObjectID.generate(); 94 entry.url = url.toString(); 95 } 96 97 requestHTTP(url, 98 (scope req){ 99 if (entry.etag.length && mode != CacheMatchMode.never) req.headers["If-None-Match"] = entry.etag; 100 if (user.length) addBasicAuth(req, user, password); 101 }, 102 (scope res){ 103 switch (res.statusCode) { 104 default: 105 throw new Exception("Unexpected reply for '"~url.toString().black~"': "~httpStatusText(res.statusCode)); 106 case HTTPStatus.notModified: 107 logDiagnostic("Cache HIT: %s", url.toString()); 108 res.dropBody(); 109 auto data = be["data"].get!BsonBinData().rawData(); 110 result = createMemoryStream(cast(ubyte[])data, false); 111 break; 112 case HTTPStatus.notFound: 113 res.dropBody(); 114 throw new FileNotFoundException("File '"~url.toString().black~"' does not exist."); 115 case HTTPStatus.movedPermanently, HTTPStatus.found, HTTPStatus.temporaryRedirect: 116 auto pv = "Location" in res.headers; 117 enforce(pv !is null, "Server responded with redirect but did not specify the redirect location for "~url.toString()); 118 logDebug("Redirect to '%s'", *pv); 119 if (startsWith((*pv), "http:") || startsWith((*pv), "https:")) { 120 url = URL(*pv); 121 } else url.localURI = *pv; 122 res.dropBody(); 123 124 entry.redirectURL = url.toString(); 125 m_entries.update(["_id": entry._id], entry, UpdateFlags.Upsert); 126 break; 127 case HTTPStatus.ok: 128 auto pet = "ETag" in res.headers; 129 if (pet || mode == CacheMatchMode.always) { 130 logDiagnostic("Cache MISS: %s", url.toString()); 131 auto dst = createMemoryOutputStream(); 132 res.bodyReader.pipe(dst); 133 auto rawdata = dst.data; 134 if (pet) entry.etag = *pet; 135 entry.data = BsonBinData(BsonBinData.Type.Generic, cast(immutable)rawdata); 136 m_entries.update(["_id": entry._id], entry, UpdateFlags.Upsert); 137 result = createMemoryStream(rawdata, false); 138 break; 139 } 140 141 logDebug("Response without etag.. not caching: "~url.toString()); 142 143 logDiagnostic("Cache MISS (no etag): %s", url.toString()); 144 handled_uncached = true; 145 callback(res.bodyReader.asInterface!InputStream); 146 break; 147 } 148 } 149 ); 150 151 if (handled_uncached) return; 152 153 if (result) { 154 callback(result); 155 return; 156 } 157 } 158 159 throw new Exception("Too many redirects for "~url.toString().black); 160 } 161 } 162 163 class FileNotFoundException : Exception { 164 this(string msg, string file = __FILE__, size_t line = __LINE__) 165 { 166 super(msg, file, line); 167 } 168 } 169 170 private struct CacheEntry { 171 BsonObjectID _id; 172 string url; 173 string etag; 174 BsonBinData data; 175 @optional string redirectURL; 176 } 177 178 private URLCache s_cache; 179 180 void downloadCached(URL url, scope void delegate(scope InputStream str) @safe callback, bool cache_priority = false) 181 @safe { 182 if (!s_cache) s_cache = new URLCache; 183 s_cache.get(url, callback, cache_priority); 184 } 185 186 void downloadCached(string url, scope void delegate(scope InputStream str) @safe callback, bool cache_priority = false) 187 @safe { 188 return downloadCached(URL.parse(url), callback, cache_priority); 189 } 190 191 void clearCacheEntry(URL url) 192 @safe { 193 if (!s_cache) s_cache = new URLCache; 194 s_cache.clearEntry(url); 195 } 196 197 void clearCacheEntry(string url) 198 @safe { 199 clearCacheEntry(URL(url)); 200 }