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