1 /**
2  * NNTP client supporting a small subset of the protocol.
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <ae@cy.md>
12  */
13 
14 module ae.net.nntp.client;
15 
16 import std.conv;
17 import std.string;
18 import std.exception;
19 
20 import ae.net.asockets;
21 import ae.sys.log;
22 import ae.utils.array;
23 
24 import core.time;
25 
26 public import ae.net.asockets : DisconnectType;
27 
28 /// Encodes a parsed entry of a LIST reply.
29 struct GroupInfo
30 {
31 	/// Group name.
32 	string name;
33 
34 	/// High and low water mark for the group.
35 	int high, low;
36 
37 	/// The group's status on this server.
38 	char mode;
39 }
40 
41 /// Implements an NNTP client connection.
42 class NntpClient
43 {
44 private:
45 	/// Socket connection.
46 	LineBufferedAdapter lineAdapter;
47 	IConnection conn;
48 
49 	/// Protocol log.
50 	Logger log;
51 
52 	/// One possible reply to an NNTP command
53 	static struct Reply
54 	{
55 		bool multiLine;
56 		void delegate(string[] lines) handleReply;
57 
58 		this(void delegate(string[] lines) handler)
59 		{
60 			multiLine = true;
61 			handleReply = handler;
62 		}
63 
64 		this(void delegate(string line) handler)
65 		{
66 			multiLine = false;
67 			handleReply = (string[] lines) { assert(lines.length==1); handler(lines[0]); };
68 		}
69 
70 		this(void delegate() handler)
71 		{
72 			multiLine = false;
73 			handleReply = (string[] lines) { assert(lines.length==1); handler(); };
74 		}
75 	}
76 
77 	/// One pipelined command
78 	static struct Command
79 	{
80 		string[] lines;
81 		bool pipelineable;
82 		Reply[int] replies;
83 		void delegate(string error) handleError;
84 	}
85 
86 	/// Commands queued to be sent to the NNTP server.
87 	Command[] queuedCommands;
88 
89 	/// Commands that have been sent to the NNTP server, and are expecting a reply.
90 	Command[] sentCommands;
91 
92 	void onConnect()
93 	{
94 		log("* Connected, waiting for greeting...");
95 	}
96 
97 	void onDisconnect(string reason, DisconnectType type)
98 	{
99 		log("* Disconnected (" ~ reason ~ ")");
100 		connected = false;
101 		foreach (command; queuedCommands ~ sentCommands)
102 			if (command.handleError)
103 				command.handleError("Disconnected from server (" ~ reason ~ ")");
104 
105 		queuedCommands = sentCommands = null;
106 		replyLines = null;
107 		currentReply = null;
108 
109 		if (handleDisconnect)
110 			handleDisconnect(reason, type);
111 	}
112 
113 	void sendLine(string line)
114 	{
115 		log("< " ~ line);
116 		lineAdapter.send(line);
117 	}
118 
119 	/// Reply line buffer.
120 	string[] replyLines;
121 
122 	/// Reply currently being received/processed.
123 	Reply* currentReply;
124 
125 	void onReadData(Data data)
126 	{
127 		try
128 		{
129 			string line = data.asDataOf!char.toGC();
130 			log("> " ~ line);
131 
132 			bool replyDone;
133 			if (replyLines.length==0)
134 			{
135 				enforce(sentCommands.length, "No command was queued when the server sent line: " ~ line);
136 				auto command = sentCommands.queuePeek();
137 
138 				int code = line.split()[0].to!int();
139 				currentReply = code in command.replies;
140 
141 				// Assume that unknown replies are single-line error messages.
142 				replyDone = currentReply ? !currentReply.multiLine : true;
143 				replyLines = [line];
144 			}
145 			else
146 			{
147 				if (line == ".")
148 					replyDone = true;
149 				else
150 				{
151 					if (line.length && line[0] == '.')
152 						line = line[1..$];
153 					replyLines ~= line;
154 				}
155 			}
156 
157 			if (replyDone)
158 			{
159 				auto command = sentCommands.queuePop();
160 				void handleReply()
161 				{
162 					enforce(currentReply, `Unexpected reply "` ~ replyLines[0] ~ `" to command "` ~ command.lines[0] ~ `"`);
163 					currentReply.handleReply(replyLines);
164 				}
165 
166 				if (command.handleError)
167 					try
168 						handleReply();
169 					catch (Exception e)
170 						command.handleError(e.msg);
171 				else
172 					handleReply(); // In the absence of an error handler, treat command handling exceptions like fatal protocol errors
173 
174 				replyLines = null;
175 				currentReply = null;
176 
177 				updateQueue();
178 			}
179 		}
180 		catch (Exception e)
181 		{
182 			foreach (line; e.toString().splitLines())
183 				log("* " ~ line);
184 			conn.disconnect("Unhandled " ~ e.classinfo.name ~ ": " ~ e.msg);
185 		}
186 	}
187 
188 	enum PIPELINE_LIMIT = 64;
189 
190 	void updateQueue()
191 	{
192 		if (!sentCommands.length && !queuedCommands.length && handleIdle)
193 			handleIdle();
194 		else
195 		while (
196 			queuedCommands.length                                                   // Got something to send?
197 		 && (sentCommands.length == 0 || sentCommands.queuePeekLast().pipelineable) // Can pipeline?
198 		 && sentCommands.length < PIPELINE_LIMIT)                                   // Not pipelining too much?
199 			send(queuedCommands.queuePop());
200 	}
201 
202 	void queue(Command command)
203 	{
204 		queuedCommands.queuePush(command);
205 		updateQueue();
206 	}
207 
208 	void send(Command command)
209 	{
210 		foreach (line; command.lines)
211 			sendLine(line);
212 		sentCommands.queuePush(command);
213 	}
214 
215 public:
216 	this(Logger log)
217 	{
218 		this.log = log;
219 	} ///
220 
221 	/// Connect to the given server.
222 	void connect(string server, void delegate() handleConnect=null)
223 	{
224 		auto tcp = new TcpConnection();
225 		IConnection c = tcp;
226 
227 		c = lineAdapter = new LineBufferedAdapter(c);
228 
229 		TimeoutAdapter timer;
230 		c = timer = new TimeoutAdapter(c);
231 		timer.setIdleTimeout(30.seconds);
232 
233 		conn = c;
234 
235 		conn.handleConnect = &onConnect;
236 		conn.handleDisconnect = &onDisconnect;
237 		conn.handleReadData = &onReadData;
238 
239 		// Manually place a fake command in the queue
240 		// (server automatically sends a greeting when a client connects).
241 		sentCommands ~= Command(null, false, [
242 			200:Reply({
243 				connected = true;
244 				if (handleConnect)
245 					handleConnect();
246 			}),
247 		]);
248 
249 		log("* Connecting to " ~ server ~ "...");
250 		tcp.connect(server, 119);
251 	}
252 
253 	/// Disconnect.
254 	void disconnect(string reason = IConnection.defaultDisconnectReason)
255 	{
256 		conn.disconnect(reason);
257 	}
258 
259 	/// True when a connection is fully established and a greeting is received.
260 	bool connected;
261 
262 	/// Send a LIST command.
263 	void listGroups(void delegate(GroupInfo[] groups) handleGroups, void delegate(string) handleError=null)
264 	{
265 		queue(Command(["LIST"], true, [
266 			215:Reply((string[] reply) {
267 				GroupInfo[] groups = new GroupInfo[reply.length-1];
268 				foreach (i, line; reply[1..$])
269 				{
270 					auto info = split(line);
271 					enforce(info.length == 4, "Unrecognized LIST reply");
272 					groups[i] = GroupInfo(info[0], to!int(info[1]), to!int(info[2]), info[3][0]);
273 				}
274 				if (handleGroups)
275 					handleGroups(groups);
276 			}),
277 		], handleError));
278 	}
279 
280 	/// Send a GROUP command.
281 	void selectGroup(string name, void delegate() handleSuccess=null, void delegate(string) handleError=null)
282 	{
283 		queue(Command(["GROUP " ~ name], true, [
284 			211:Reply({
285 				if (handleSuccess)
286 					handleSuccess();
287 			}),
288 		], handleError));
289 	}
290 
291 	/// Send a LISTGROUP command.
292 	void listGroup(string name, int from/* = 1*/, void delegate(string[] messages) handleListGroup, void delegate(string) handleError=null)
293 	{
294 		string line = from > 1 ? format("LISTGROUP %s %d-", name, from) : format("LISTGROUP %s", name);
295 
296 		queue(Command([line], true, [
297 			211:Reply((string[] reply) {
298 				if (handleListGroup)
299 					handleListGroup(reply[1..$]);
300 			}),
301 		], handleError));
302 	}
303 
304 	/// ditto
305 	void listGroup(string name, void delegate(string[] messages) handleListGroup, void delegate(string) handleError=null) { listGroup(name, 1, handleListGroup, handleError); }
306 
307 	/// Send a XOVER command.
308 	void listGroupXover(string name, int from/* = 1*/, void delegate(string[] messages) handleListGroup, void delegate(string) handleError=null)
309 	{
310 		// TODO: handle GROUP command failure
311 		selectGroup(name);
312 		queue(Command([format("XOVER %d-", from)], true, [
313 			224:Reply((string[] reply) {
314 				auto messages = new string[reply.length-1];
315 				foreach (i, line; reply[1..$])
316 					messages[i] = line.split("\t")[0];
317 				if (handleListGroup)
318 					handleListGroup(messages);
319 			}),
320 		], handleError));
321 	}
322 
323 	/// ditto
324 	void listGroupXover(string name, void delegate(string[] messages) handleListGroup, void delegate(string) handleError=null) { listGroupXover(name, 1, handleListGroup, handleError); }
325 
326 	/// Send an ARTICLE command.
327 	void getMessage(string numOrID, void delegate(string[] lines, string num, string id) handleMessage, void delegate(string) handleError=null)
328 	{
329 		queue(Command(["ARTICLE " ~ numOrID], true, [
330 			220:Reply((string[] reply) {
331 				auto message = reply[1..$];
332 				auto firstLine = reply[0].split();
333 				if (handleMessage)
334 					handleMessage(message, firstLine[1], firstLine[2]);
335 			}),
336 		], handleError));
337 	}
338 
339 	/// Send a DATE command.
340 	void getDate(void delegate(string date) handleDate, void delegate(string) handleError=null)
341 	{
342 		queue(Command(["DATE"], true, [
343 			111:Reply((string reply) {
344 				auto date = reply.split()[1];
345 				enforce(date.length == 14, "Invalid DATE format");
346 				if (handleDate)
347 					handleDate(date);
348 			}),
349 		], handleError));
350 	}
351 
352 	/// Send a NEWNEWS command.
353 	void getNewNews(string wildmat, string dateTime, void delegate(string[] messages) handleNewNews, void delegate(string) handleError=null)
354 	{
355 		queue(Command(["NEWNEWS " ~ wildmat ~ " " ~ dateTime], true, [
356 			230:Reply((string[] reply) {
357 				if (handleNewNews)
358 					handleNewNews(reply);
359 			}),
360 		], handleError));
361 	}
362 
363 	/// Send a POST command.
364 	void postMessage(string[] lines, void delegate() handlePosted=null, void delegate(string) handleError=null)
365 	{
366 		queue(Command(["POST"], false, [
367 			340:Reply({
368 				string[] postLines;
369 				foreach (line; lines)
370 					if (line.startsWith("."))
371 						postLines ~= "." ~ line;
372 					else
373 						postLines ~= line;
374 				postLines ~= ".";
375 
376 				send(Command(postLines, true, [
377 					240:Reply({
378 						if (handlePosted)
379 							handlePosted();
380 					}),
381 				], handleError));
382 			}),
383 		], handleError));
384 	}
385 
386 	/// Called when the connection is disconnected.
387 	void delegate(string reason, DisconnectType type) handleDisconnect;
388 
389 	/// Called when the command queue is empty.
390 	void delegate() handleIdle;
391 }