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 }