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 
70 		int maxRedirects = int.min; // choose depending or method
71 	}
72 
73 	/*private*/ static void req(CachedCurlNetwork instance, in ref Request request, string target, string metadataPath)
74 	{
75 		with (instance)
76 		{
77 			http.clearRequestHeaders();
78 			http.method = request.method;
79 			if (request.maxRedirects != int.min)
80 				http.maxRedirects = request.maxRedirects;
81 			else
82 			if (request.method == HTTP.Method.head)
83 				http.maxRedirects = uint.max;
84 			else
85 				http.maxRedirects = 10;
86 			auto host = request.url.split("/")[2];
87 			if (cookieDir)
88 			{
89 				auto cookiePath = buildPath(cookieDir, host ~ cookieExt);
90 				if (cookiePath.exists)
91 					http.addRequestHeader("Cookie", cookiePath.readText.chomp());
92 			}
93 			Metadata metadata;
94 			http.onReceiveHeader =
95 				(in char[] key, in char[] value)
96 				{
97 					metadata.headers[key.idup] ~= value.idup;
98 				};
99 			http.onReceiveStatusLine =
100 				(HTTP.StatusLine statusLine)
101 				{
102 					metadata.statusLine = statusLine;
103 				};
104 			if (request.data)
105 			{
106 				const(void)[] data = request.data;
107 				http.addRequestHeader("Content-Length", data.length.text);
108 				http.onSend = (void[] buf)
109 					{
110 						size_t len = min(buf.length, data.length);
111 						buf[0..len] = data[0..len];
112 						data = data[len..$];
113 						return len;
114 					};
115 			}
116 			else
117 				http.onSend = null;
118 			download!HTTP(request.url, target, http);
119 			write(metadataPath, metadata.toJson);
120 		}
121 	}
122 
123 	static struct Response
124 	{
125 		string responsePath;
126 		string metadataPath;
127 
128 		@property ubyte[] responseData()
129 		{
130 			checkOK();
131 			return cast(ubyte[])std.file.read(responsePath);
132 		}
133 
134 		@property Metadata metadata()
135 		{
136 			return metadataPath.exists ? metadataPath.readText.jsonParse!Metadata : Metadata.init;
137 		}
138 
139 		@property bool ok()
140 		{
141 			return metadata.statusLine.code / 100 == 2;
142 		}
143 
144 		ref Response checkOK()
145 		{
146 			if (!ok)
147 				throw new CachedCurlException(metadata);
148 			return this;
149 		}
150 	}
151 
152 	static class CachedCurlException : Exception
153 	{
154 		Metadata metadata;
155 
156 		this(Metadata metadata, string fn = __FILE__, size_t ln = __LINE__)
157 		{
158 			this.metadata = metadata;
159 			super("Request failed: " ~ metadata.statusLine.reason, fn, ln);
160 		}
161 	}
162 
163 	Response cachedReq(in ref Request request)
164 	{
165 		auto hash = getDigestString!MD5(request.url ~ cast(char)request.method ~ request.data);
166 		auto path = buildPath(cacheDir, hash[0..2], hash);
167 		ensurePathExists(path);
168 		auto metadataPath = path ~ ".metadata";
169 		if (path.exists && path.timeLastModified.stdTime < epoch)
170 			path.remove();
171 		cached!req(this, request, path, metadataPath);
172 		return Response(path, metadataPath);
173 	}
174 
175 	Response cachedReq(string url, HTTP.Method method, in void[] data = null)
176 	{
177 		auto req = Request(url, method, data);
178 		return cachedReq(req);
179 	}
180 
181 	override void downloadFile(string url, string target)
182 	{
183 		std.file.copy(cachedReq(url, HTTP.Method.get).checkOK.responsePath, target);
184 	}
185 
186 	override void[] getFile(string url)
187 	{
188 		return cachedReq(url, HTTP.Method.get).responseData;
189 	}
190 
191 	override bool urlOK(string url)
192 	{
193 		return cachedReq(url, HTTP.Method.get).ok;
194 	}
195 
196 	override string resolveRedirect(string url)
197 	{
198 		return
199 			url.applyRelativeURL(
200 				cachedReq(url, HTTP.Method.head, null)
201 				.metadata
202 				.headers
203 				.get("location", null)
204 				.enforce("Not a redirect: " ~ url)
205 				[$-1]);
206 	}
207 
208 	override void[] post(string url, in void[] data)
209 	{
210 		return cachedReq(url, HTTP.Method.post, data).responseData;
211 	}
212 }
213 
214 alias CachedCurlException = CachedCurlNetwork.CachedCurlException;
215 
216 static this()
217 {
218 	net = new CachedCurlNetwork();
219 }