1 /**
2 	(module summary)
3 
4 	Copyright: © 2012-2016 RejectedSoftware e.K.
5 	License: Subject to the terms of the General Public License version 3, as written in the included LICENSE.txt file.
6 	Authors: Sönke Ludwig
7 */
8 module vibenews.news;
9 
10 import vibenews.nntp.server;
11 import vibenews.nntp.status;
12 import vibenews.controller;
13 import vibenews.vibenews;
14 
15 import antispam.antispam;
16 import userman.db.controller : User;
17 import vibe.core.core;
18 import vibe.core.log;
19 import vibe.crypto.passwordhash;
20 import vibe.data.bson;
21 import vibe.inet.message;
22 import vibe.stream.counting;
23 import vibe.stream.operations;
24 import vibe.stream.wrapper;
25 import vibe.stream.tls;
26 
27 import std.algorithm;
28 import std.array;
29 import std.conv;
30 import std.datetime;
31 import std.exception;
32 import std.format;
33 import std.string;
34 
35 
36 // TODO: capabilities, auth, better POST validation, message codes when exceptions happen
37 
38 class NewsInterface {
39 	private {
40 		Controller m_ctrl;
41 		VibeNewsSettings m_settings;
42 
43 		static TaskLocal!string s_group;
44 		static TaskLocal!string s_authUser;
45 		static TaskLocal!(User.ID) s_authUserID;
46 	}
47 
48 	this(Controller controller)
49 	{
50 		m_ctrl = controller;
51 		m_settings = controller.settings;
52 	}
53 
54 	void listen()
55 	{
56 		auto nntpsettings = new NNTPServerSettings;
57 		nntpsettings.requireSSL = m_settings.requireSSL;
58 		nntpsettings.host = m_settings.hostName;
59 		nntpsettings.port = m_settings.nntpPort;
60 		listenNNTP(nntpsettings, &handleCommand);
61 
62 		if (m_settings.sslCertFile.length || m_settings.sslKeyFile.length) {
63 			auto nntpsettingsssl = new NNTPServerSettings;
64 			nntpsettingsssl.host = m_settings.hostName;
65 			nntpsettingsssl.port = m_settings.nntpSSLPort;
66 			nntpsettingsssl.sslContext = createTLSContext(TLSContextKind.server);
67 			nntpsettingsssl.sslContext.useCertificateChainFile(m_settings.sslCertFile);
68 			nntpsettingsssl.sslContext.usePrivateKeyFile(m_settings.sslKeyFile);
69 			listenNNTP(nntpsettingsssl, &handleCommand);
70 		}
71 	}
72 
73 	void handleCommand(NNTPServerRequest req, NNTPServerResponse res)
74 	{
75 		switch( req.command ){
76 			default:
77 				res.status = NNTPStatus.badCommand;
78 				res.statusText = "Unsupported command: "~req.command;
79 				res.writeVoidBody();
80 				break;
81 			case "article": article(req, res); break;
82 			case "authinfo": authinfo(req, res); break;
83 			case "body": article(req, res); break;
84 			// capabilities
85 			case "date": date(req, res); break;
86 			case "group": group(req, res); break;
87 			case "head": article(req, res); break;
88 			case "help": help(req, res); break;
89 			// ihave
90 			// last
91 			case "list": list(req, res); break;
92 			case "listgroup": group(req, res); break;
93 			case "mode": mode(req, res); break;
94 			case "newgroups": newgroups(req, res); break;
95 			case "newnews": newnews(req, res); break;
96 			// next
97 			case "over": over(req, res); break;
98 			case "post": post(req, res); break;
99 			case "xover": over(req, res); break;
100 		}
101 	}
102 
103 	DateTime parseDateParams(string[] params, NNTPServerRequest req)
104 	{
105 		int extendYear(int two_digit_year)
106 		{
107 			if( two_digit_year >= 70 ) return 1900+two_digit_year;
108 			else return 2000 + two_digit_year;
109 		}
110 
111 		req.enforce(params.length == 2 || params[2] == "GMT",
112 			NNTPStatus.commandSyntaxError, "Time zone must be GMT");
113 
114 		auto dstr = params[0];
115 		auto tstr = params[1];
116 
117 		req.enforce(dstr.length == 6 || dstr.length == 8,
118 			NNTPStatus.commandSyntaxError, "YYMMDD or YYYYMMDD");
119 
120 		bool fullyear = dstr.length == 8;
121 		dstr ~= "11"; // just to avoid array out-of-bounds
122 		int year = fullyear ? to!int(dstr[0 .. 4]) : extendYear(to!int(dstr[0 .. 2]));
123 		int month = fullyear ? to!int(dstr[4 .. 6]) : to!int(dstr[2 .. 4]);
124 		int day = fullyear ? to!int(dstr[6 .. 8]) : to!int(dstr[4 .. 6]);
125 		int hour = to!int(tstr[0 .. 2]);
126 		int minute = to!int(tstr[2 .. 4]);
127 		int second = to!int(tstr[4 .. 6]);
128 		return DateTime(year, month, day, hour, minute, second);
129 	}
130 
131 	void article(NNTPServerRequest req, NNTPServerResponse res)
132 	{
133 		req.enforceNParams(1);
134 
135 		Article art;
136 		if( req.parameters[0].startsWith("<") ){
137 			try art = m_ctrl.getArticle(req.parameters[0]);
138 			catch( Exception e ){
139 				res.status = NNTPStatus.badArticleId;
140 				res.statusText = "Bad article id";
141 				res.writeVoidBody();
142 				return;
143 			}
144 
145 			bool auth = false;
146 			foreach( g; art.groups.byKey() ){
147 				if( testAuth(unescapeGroup(g), false) ){
148 					auth = true;
149 					break;
150 				}
151 			}
152 			if( !auth ){
153 				res.status = NNTPStatus.accessFailure;
154 				res.statusText = "Not authorized to access this article";
155 				res.writeVoidBody();
156 				return;
157 			}
158 
159 			res.statusText = "0 "~art.id~" ";
160 		} else {
161 			auto anum = to!long(req.parameters[0]);
162 
163 			if (!s_group.length) {
164 				res.status = NNTPStatus.noGroupSelected;
165 				res.statusText = "Not in a newsgroup";
166 				res.writeVoidBody();
167 				return;
168 			}
169 
170 			string groupname = s_group;
171 
172 			if( !testAuth(groupname, false, res) )
173 				return;
174 
175 			try art = m_ctrl.getArticle(groupname, anum);
176 			catch( Exception e ){
177 				res.status = NNTPStatus.badArticleNumber;
178 				res.statusText = "Bad article number";
179 				res.writeVoidBody();
180 				return;
181 			}
182 
183 			res.statusText = to!string(art.groups[escapeGroup(groupname)].articleNumber)~" "~art.id~" ";
184 		}
185 
186 		switch(req.command){
187 			default: assert(false);
188 			case "article":
189 				res.status = NNTPStatus.article;
190 				res.statusText ~= "head and body follow";
191 				break;
192 			case "body":
193 				res.status = NNTPStatus.body_;
194 				res.statusText ~= "body follows";
195 				break;
196 			case "head":
197 				res.status = NNTPStatus.head;
198 				res.statusText ~= "head follows";
199 				break;
200 		}
201 
202 		if( req.command == "head" || req.command == "article" ){
203 			bool first = true;
204 			//res.bodyWriter.write("Message-ID: ", false);
205 			//res.bodyWriter.write(art.id, false);
206 			//res.bodyWriter.write("\r\n");
207 			auto dst = res.bodyWriter;
208 			foreach( hdr; art.headers ){
209 				if( !first ) dst.write("\r\n");
210 				else first = false;
211 				dst.write(hdr.key);
212 				dst.write(": ");
213 				dst.write(hdr.value);
214 			}
215 
216 			// write Xref header
217 			dst.write("\r\n");
218 			dst.write("Xref: ");
219 			dst.write(m_settings.hostName);
220 			foreach( grpname, grpref; art.groups ){
221 				dst.write(" ");
222 				dst.write(unescapeGroup(grpname));
223 				dst.write(":");
224 				dst.write(to!string(grpref.articleNumber));
225 			}
226 
227 			if( req.command == "article" )
228 				dst.write("\r\n\r\n");
229 		}
230 
231 		if( req.command == "body" || req.command == "article" ){
232 			res.bodyWriter.write(art.message);
233 		}
234 	}
235 
236 	void authinfo(NNTPServerRequest req, NNTPServerResponse res)
237 	{
238 		req.enforceNParams(2, "USER/PASS <value>");
239 
240 		switch(req.parameters[0].toLower()){
241 			default:
242 				res.status = NNTPStatus.commandSyntaxError;
243 				res.statusText = "USER/PASS <value>";
244 				res.writeVoidBody();
245 				break;
246 			case "user":
247 				s_authUser = req.parameters[1];
248 				res.status = NNTPStatus.moreAuthInfoRequired;
249 				res.statusText = "specify password";
250 				res.writeVoidBody();
251 				break;
252 			case "pass":
253 				req.enforce(s_authUser.length > 0, NNTPStatus.authRejected, "specify user first");
254 				auto password = req.parameters[1];
255 				try {
256 					auto usr = m_ctrl.getUserByEmail(s_authUser);
257 					enforce(testSimplePasswordHash(usr.auth.passwordHash, password));
258 					s_authUserID = usr.id;
259 					res.status = NNTPStatus.authAccepted;
260 					res.statusText = "authentication successful";
261 					res.writeVoidBody();
262 				} catch( Exception e ){
263 					res.status = NNTPStatus.authRejected;
264 					res.statusText = "authentication failed";
265 					res.writeVoidBody();
266 				}
267 				break;
268 		}
269 	}
270 
271 	void date(NNTPServerRequest req, NNTPServerResponse res)
272 	{
273 		res.status = NNTPStatus.timeFollows;
274 		auto tm = Clock.currTime(UTC());
275 		auto tmstr = appender!string();
276 		formattedWrite(tmstr, "%04d%02d%02d%02d%02d%02d", tm.year, tm.month, tm.day,
277 				tm.hour, tm.minute, tm.second);
278 		res.statusText = tmstr.data;
279 		res.writeVoidBody();
280 	}
281 
282 	void group(NNTPServerRequest req, NNTPServerResponse res)
283 	{
284 		req.enforceNParams(1, "<groupname>");
285 		auto groupname = req.parameters[0];
286 		vibenews.controller.Group grp;
287 		try {
288 			grp = m_ctrl.getGroupByName(groupname);
289 			enforce(grp.active);
290 		} catch( Exception e ){
291 			res.status = NNTPStatus.noSuchGruop;
292 			res.statusText = "No such group "~groupname;
293 			res.writeVoidBody();
294 			return;
295 		}
296 
297 		if( !testAuth(groupname, false, res) )
298 			return;
299 
300 		s_group = groupname;
301 
302 		res.status = NNTPStatus.groupSelected;
303 		res.statusText = to!string(grp.articleCount)~" "~to!string(grp.minArticleNumber)~" "~to!string(grp.maxArticleNumber)~" "~groupname;
304 
305 		if( req.command == "group" ){
306 			res.writeVoidBody();
307 		} else {
308 			res.statusText = "Article list follows";
309 			res.bodyWriter();
310 			m_ctrl.enumerateArticles(groupname, (i, id, msgid, msgnum) @trusted {
311 					if( i > 0 ) res.bodyWriter.write("\r\n");
312 					res.bodyWriter.write(to!string(msgnum));
313 				});
314 		}
315 	}
316 
317 	void help(NNTPServerRequest req, NNTPServerResponse res)
318 	{
319 		req.enforceNParams(0);
320 		res.status = NNTPStatus.helpText;
321 		res.statusText = "Legal commands";
322 		res.bodyWriter.write("  help\r\n");
323 		res.bodyWriter.write("  list Kind\r\n");
324 	}
325 
326 	void list(NNTPServerRequest req, NNTPServerResponse res)
327 	{
328 		if( req.parameters.length == 0 )
329 			req.parameters ~= "active";
330 
331 		res.status = NNTPStatus.groups;
332 		switch( toLower(req.parameters[0]) ){
333 			default: enforce(false, "Invalid list kind: "~req.parameters[0]); assert(false);
334 			case "newsgroups":
335 				res.statusText = "Descriptions in form \"group description\".";
336 				res.bodyWriter();
337 				size_t cnt = 0;
338 				m_ctrl.enumerateGroups((i, grp) @trusted {
339 						if( !grp.active ) return;
340 						logDebug("Got group %s", grp.name);
341 						if( cnt++ > 0 ) res.bodyWriter.write("\r\n");
342 						res.bodyWriter.write(grp.name ~ " " ~ grp.description);
343 					});
344 				break;
345 			case "active":
346 				res.statusText = "Newsgroups in form \"group high low flags\".";
347 				size_t cnt = 0;
348 				m_ctrl.enumerateGroups((i, grp) @trusted {
349 						if( !grp.active ) return;
350 						if( cnt++ > 0 ) res.bodyWriter.write("\r\n");
351 						auto high = to!string(grp.maxArticleNumber);
352 						auto low = to!string(grp.minArticleNumber);
353 						auto flags = "y";
354 						res.bodyWriter.write(grp.name~" "~high~" "~low~" "~flags);
355 					});
356 				break;
357 		}
358 	}
359 
360 	void mode(NNTPServerRequest req, NNTPServerResponse res)
361 	{
362 		req.enforceNParams(1, "READER");
363 		if( toLower(req.parameters[0]) != "reader" ){
364 			res.status = NNTPStatus.commandSyntaxError;
365 			res.statusText = "Expected MODE READER";
366 		} else {
367 			res.status = NNTPStatus.serverReady;
368 			res.statusText = "Posting allowed";
369 		}
370 		res.writeVoidBody();
371 	}
372 
373 	void over(NNTPServerRequest req, NNTPServerResponse res)
374 	{
375 		import vibe.stream.wrapper : StreamOutputRange;
376 
377 		req.enforceNParams(1, "(X)OVER [range]");
378 		req.enforce(s_group.length > 0, NNTPStatus.noGroupSelected, "No newsgroup selected");
379 		string grpname = s_group;
380 		auto idx = req.parameters[0].countUntil('-');
381 		string fromstr, tostr;
382 		if( idx > 0 ){
383 			fromstr = req.parameters[0][0 .. idx];
384 			tostr = req.parameters[0][idx+1 .. $];
385 		} else fromstr = tostr = req.parameters[0];
386 
387 		auto grp = m_ctrl.getGroupByName(grpname);
388 
389 		if( !testAuth(grp, false, res) )
390 			return;
391 
392 		long fromnum = to!long(fromstr);
393 		long tonum = tostr.length ? to!long(tostr) : grp.maxArticleNumber;
394 
395 		res.status = NNTPStatus.overviewFollows;
396 		res.statusText = "Overview information follows (multi-line)";
397 
398 		auto dst = streamOutputRange(res.bodyWriter);
399 		m_ctrl.enumerateArticles(grpname, fromnum, tonum, (idx, art) @trusted {
400 			string sanitizeHeader(string hdr) {
401 				auto ret = appender!string();
402 				size_t sidx = 0;
403 				foreach (i, ch; hdr) {
404 					switch (ch) {
405 						default: break;
406 						case '\t', '\r', '\n':
407 							ret.put(hdr[sidx .. i]);
408 							ret.put('.');
409 							sidx = i+1;
410 							break;
411 					}
412 				}
413 				if (sidx == 0) return hdr;
414 				else { ret.put(hdr[sidx .. $]); return ret.data; }
415 			}
416 
417 			if (idx > 0) dst.put("\r\n");
418 
419 			(&dst).formattedWrite("%d\t%s\t%s\t%s\t%s\t%s\t%d\t%d",
420 				art.groups[escapeGroup(grpname)].articleNumber,
421 				sanitizeHeader(art.getHeader("Subject")),
422 				sanitizeHeader(art.getHeader("From")),
423 				sanitizeHeader(art.getHeader("Date")),
424 				sanitizeHeader(art.getHeader("Message-ID")),
425 				sanitizeHeader(art.getHeader("References")),
426 				art.messageLength,
427 				art.messageLines);
428 
429 			foreach (h; art.headers) {
430 				if (icmp(h.key, "Subject") == 0) continue;
431 				if (icmp(h.key, "From") == 0) continue;
432 				if (icmp(h.key, "Date") == 0) continue;
433 				if (icmp(h.key, "Message-ID") == 0) continue;
434 				if (icmp(h.key, "References") == 0) continue;
435 				(&dst).formattedWrite("\t%s: %s", h.key, sanitizeHeader(h.value));
436 			}
437 			dst.flush();
438 		});
439 	}
440 
441 	void post(NNTPServerRequest req, NNTPServerResponse res)
442 	{
443 		req.enforceNParams(0);
444 		Article art;
445 		art._id = BsonObjectID.generate();
446 		art.id = "<"~art._id.toString()~"@"~m_settings.hostName~">";
447 
448 		res.status = NNTPStatus.postArticle;
449 		res.statusText = "Ok, recommended ID "~art.id;
450 		res.writeVoidBody();
451 
452 		InetHeaderMap headers;
453 		parseRFC5322Header(req.bodyReader, headers);
454 		foreach( k, v; headers ) art.addHeader(k, v);
455 
456 		auto limitedReader = createLimitedInputStream(req.bodyReader, 2048*1024, true);
457 
458 		try {
459 			art.message = limitedReader.readAll();
460 		} catch( LimitException e ){
461 			static if (__traits(compiles, req.bodyReader.pipe(nullSink)))
462 				req.bodyReader.pipe(nullSink);
463 			else nullSink.write(req.bodyReader);
464 			res.restart();
465 			res.status = NNTPStatus.articleRejected;
466 			res.statusText = "Message too big, please keep below 2.0 MiB";
467 			res.writeVoidBody();
468 			return;
469 		}
470 		res.restart();
471 		art.peerAddress = [req.peerAddress];
472 
473 		try m_ctrl.postArticle(art, s_authUserID);
474 		catch (NNTPStatusException e) throw e;
475 		catch (Exception e) {
476 			res.status = NNTPStatus.articleRejected;
477 			res.statusText = "Message deemed abusive.";
478 			res.writeVoidBody();
479 			return;
480 		}
481 
482 		res.status = NNTPStatus.articlePostedOK;
483 		res.statusText = "Article posted";
484 		res.writeVoidBody();
485 	}
486 
487 	void newnews(NNTPServerRequest req, NNTPServerResponse res)
488 	{
489 		req.enforceNParams(3, 4);
490 		auto grp = req.parameters[0];
491 		auto date = parseDateParams(req.parameters[1 .. $], req);
492 
493 		if( grp == "*" ){
494 			res.status = NNTPStatus.newArticles;
495 			res.statusText = "New news follows";
496 
497 			auto writer = res.bodyWriter();
498 
499 			bool first = true;
500 			m_ctrl.enumerateGroups((gi, group) @trusted {
501 				if( !testAuth(group.name, false, res) )
502 					return;
503 
504 				m_ctrl.enumerateNewArticles(group.name, SysTime(date, UTC()), (i, id, msgid, msgnum){
505 						if( !first ) writer.write("\r\n");
506 						first = false;
507 						writer.write(msgid);
508 					});
509 			});
510 		} else {
511 			if( !testAuth(grp, false, res) )
512 				return;
513 
514 			res.status = NNTPStatus.newArticles;
515 			res.statusText = "New news follows";
516 
517 			auto writer = res.bodyWriter();
518 
519 			m_ctrl.enumerateNewArticles(grp, SysTime(date, UTC()), (i, id, msgid, msgnum){
520 					if( i > 0 ) writer.write("\r\n");
521 					writer.write(msgid);
522 				});
523 		}
524 	}
525 
526 	void newgroups(NNTPServerRequest req, NNTPServerResponse res)
527 	{
528 		req.enforceNParams(2, 3);
529 		auto date = parseDateParams(req.parameters[0 .. $], req);
530 
531 		res.status = NNTPStatus.newGroups;
532 		res.statusText = "New groups follow";
533 
534 		auto writer = res.bodyWriter();
535 
536 		size_t cnt = 0;
537 		m_ctrl.enumerateNewGroups(SysTime(date, UTC()), (i, grp){
538 				if( !grp.active ) return;
539 				if( cnt++ > 0 ) writer.write("\r\n");
540 				auto high = to!string(grp.maxArticleNumber);
541 				auto low = to!string(grp.minArticleNumber);
542 				auto flags = "y";
543 				writer.write(grp.name~" "~high~" "~low~" "~flags);
544 			});
545 
546 	}
547 
548 	bool testAuth(string grpname, bool require_write, NNTPServerResponse res = null)
549 	{
550 		try {
551 			auto grp = m_ctrl.getGroupByName(grpname);
552 			return testAuth(grp,require_write, res);
553 		} catch( Exception e ){
554 			return false;
555 		}
556 	}
557 
558 	bool testAuth(vibenews.controller.Group grp, bool require_write, NNTPServerResponse res)
559 	{
560 		if( grp.readOnlyAuthTags.empty && grp.readWriteAuthTags.empty )
561 			return true;
562 
563 		if (s_authUserID == User.ID.init) {
564 			if (res) {
565 				res.status = NNTPStatus.authRequired;
566 				res.statusText = "auth info required";
567 				res.writeVoidBody();
568 			}
569 			return false;
570 		}
571 
572 		try {
573 			if (require_write)
574 				enforce(m_ctrl.isAuthorizedForWritingGroup(s_authUserID, grp.name));
575 			else enforce(m_ctrl.isAuthorizedForReadingGroup(s_authUserID, grp.name));
576 			return true;
577 		} catch (Exception) {
578 			if (res) {
579 				res.status = NNTPStatus.accessFailure;
580 				res.statusText = "auth info not valid for group";
581 				res.writeVoidBody();
582 			}
583 			return false;
584 		}
585 	}
586 }