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 }