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