1 /**
2  * ae.sys.net implementation for HTTP using Curl,
3  * with caching and cookie support
4  *
5  * License:
6  *   This Source Code Form is subject to the terms of
7  *   the Mozilla Public License, v. 2.0. If a copy of
8  *   the MPL was not distributed with this file, You
9  *   can obtain one at http://mozilla.org/MPL/2.0/.
10  *
11  * Authors:
12  *   Vladimir Panteleev <vladimir@thecybershadow.net>
13  */
14 
15 module ae.sys.net.cachedcurl;
16 
17 // TODO: refactor into an abstract Cached!Network wrapper?
18 
19 import std.algorithm.comparison;
20 import std.conv;
21 import std.exception;
22 import std.file;
23 import std.net.curl;
24 import std.path;
25 import std.string;
26 import std.typecons;
27 
28 import ae.net.ietf.url;
29 import ae.sys.file;
30 import ae.sys.net;
31 import ae.utils.digest;
32 import ae.utils.json;
33 import ae.utils.time;
34 
35 class CachedCurlNetwork : Network
36 {
37 	/// Curl HTTP object
38 	/// Can be customized after construction.
39 	HTTP http;
40 
41 	/// Directory for caching responses
42 	string cacheDir = "cache";
43 
44 	/// Ignore cache entries older than the given time
45 	StdTime epoch = 0;
46 
47 	/// Directory for reading cookies.
48 	/// May be moved to a lambda in the future.
49 	/// Format is one file per host, with hostname ~ cookieExt being the file name.
50 	/// Contents is one line for the entire HTTP "Cookie" header.
51 	string cookieDir, cookieExt;
52 
53 	this()
54 	{
55 		http = HTTP();
56 	}
57 
58 	static struct Metadata
59 	{
60 		HTTP.StatusLine statusLine;
61 		string[][string] headers;
62 	}
63 
64 	static struct Request
65 	{
66 		string url;
67 		HTTP.Method method = HTTP.Method.get;
68 		const(void)[] data;
69 		const(string[2])[] headers;
70 
71 		int maxRedirects = int.min; // choose depending or method
72 	}
73 
74 	/*private*/ static void req(CachedCurlNetwork instance, in ref Request request, string target, string metadataPath)
75 	{
76 		with (instance)
77 		{
78 			http.clearRequestHeaders();
79 			http.method = request.method;
80 			if (request.maxRedirects != int.min)
81 				http.maxRedirects = request.maxRedirects;
82 			else
83 			if (request.method == HTTP.Method.head)
84 				http.maxRedirects = uint.max;
85 			else
86 				http.maxRedirects = 10;
87 			auto host = request.url.split("/")[2];
88 			if (cookieDir)
89 			{
90 				auto cookiePath = buildPath(cookieDir, host ~ cookieExt);
91 				if (cookiePath.exists)
92 					http.addRequestHeader("Cookie", cookiePath.readText.chomp());
93 			}
94 			foreach (header; request.headers)
95 				http.addRequestHeader(header[0], header[1]);
96 			Metadata metadata;
97 			http.onReceiveHeader =
98 				(in char[] key, in char[] value)
99 				{
100 					metadata.headers[key.idup] ~= value.idup;
101 				};
102 			http.onReceiveStatusLine =
103 				(HTTP.StatusLine statusLine)
104 				{
105 					metadata.statusLine = statusLine;
106 				};
107 			if (request.data)
108 			{
109 				const(void)[] data = request.data;
110 				http.addRequestHeader("Content-Length", data.length.text);
111 				http.onSend = (void[] buf)
112 					{
113 						size_t len = min(buf.length, data.length);
114 						buf[0..len] = data[0..len];
115 						data = data[len..$];
116 						return len;
117 					};
118 			}
119 			else
120 				http.onSend = null;
121 			download!HTTP(request.url, target, http);
122 			write(metadataPath, metadata.toJson);
123 		}
124 	}
125 
126 	static struct Response
127 	{
128 		string responsePath;
129 		string metadataPath;
130 
131 		@property ubyte[] responseData()
132 		{
133 			checkOK();
134 			return cast(ubyte[])std.file.read(responsePath);
135 		}
136 
137 		@property Metadata metadata()
138 		{
139 			return metadataPath.exists ? metadataPath.readText.jsonParse!Metadata : Metadata.init;
140 		}
141 
142 		@property bool ok()
143 		{
144 			return metadata.statusLine.code / 100 == 2;
145 		}
146 
147 		ref Response checkOK()
148 		{
149 			if (!ok)
150 				throw new CachedCurlException(metadata);
151 			return this;
152 		}
153 	}
154 
155 	static class CachedCurlException : Exception
156 	{
157 		Metadata metadata;
158 
159 		this(Metadata metadata, string fn = __FILE__, size_t ln = __LINE__)
160 		{
161 			this.metadata = metadata;
162 			super("Request failed: " ~ metadata.statusLine.reason, fn, ln);
163 		}
164 	}
165 
166 	Response cachedReq(in ref Request request)
167 	{
168 		auto hash = getDigestString!MD5(request.url ~ cast(char)request.method ~ request.data);
169 		auto path = buildPath(cacheDir, hash[0..2], hash);
170 		ensurePathExists(path);
171 		auto metadataPath = path ~ ".metadata";
172 		if (path.exists && path.timeLastModified.stdTime < epoch)
173 			path.remove();
174 		cached!req(this, request, path, metadataPath);
175 		return Response(path, metadataPath);
176 	}
177 
178 	Response cachedReq(string url, HTTP.Method method, in void[] data = null)
179 	{
180 		auto req = Request(url, method, data);
181 		return cachedReq(req);
182 	}
183 
184 	string downloadFile(string url)
185 	{
186 		return cachedReq(url, HTTP.Method.get).checkOK.responsePath;
187 	}
188 
189 	override void downloadFile(string url, string target)
190 	{
191 		std.file.copy(downloadFile(url), target);
192 	}
193 
194 	override void[] getFile(string url)
195 	{
196 		return cachedReq(url, HTTP.Method.get).responseData;
197 	}
198 
199 	override bool urlOK(string url)
200 	{
201 		return cachedReq(url, HTTP.Method.get).ok;
202 	}
203 
204 	override string resolveRedirect(string url)
205 	{
206 		return
207 			url.applyRelativeURL(
208 				cachedReq(url, HTTP.Method.head, null)
209 				.metadata
210 				.headers
211 				.get("location", null)
212 				.enforce("Not a redirect: " ~ url)
213 				[$-1]);
214 	}
215 
216 	override void[] post(string url, in void[] data)
217 	{
218 		return cachedReq(url, HTTP.Method.post, data).responseData;
219 	}
220 }
221 
222 alias CachedCurlException = CachedCurlNetwork.CachedCurlException;
223 
224 static this()
225 {
226 	net = new CachedCurlNetwork();
227 }