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 }