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.controller; 9 10 import vibenews.nntp.status; 11 import vibenews.vibenews; 12 13 import vibe.vibe; 14 15 import antispam.antispam; 16 import userman.api : UserManAPI, createLocalUserManAPI; 17 import userman.db.controller; 18 19 import std.algorithm; 20 import std.array; 21 import std.base64; 22 import std.encoding : sanitize; 23 import std.string; 24 25 26 class Controller { 27 private { 28 VibeNewsSettings m_settings; 29 MongoCollection m_groups; 30 MongoCollection m_groupCategories; 31 MongoCollection m_articles; 32 MongoCollection m_threads; 33 UserManController m_userdb; 34 UserManAPI m_userapi; 35 } 36 37 this(VibeNewsSettings vnsettings) 38 { 39 m_settings = vnsettings; 40 41 auto settings = new UserManSettings; 42 settings.useUserNames = false; 43 settings.databaseURL = "mongodb://127.0.0.1:27017/"~m_settings.databaseName; 44 settings.serviceName = m_settings.title; 45 settings.serviceUrl = URL("http://"~m_settings.hostName~"/"); 46 settings.serviceEmail = "info@"~m_settings.hostName; 47 settings.mailSettings = m_settings.mailSettings; 48 settings.requireAccountValidation = m_settings.requireAccountValidation; 49 m_userdb = createUserManController(settings); 50 m_userapi = createLocalUserManAPI(m_userdb); 51 52 auto db = connectMongoDB("127.0.0.1").getDatabase(m_settings.databaseName); 53 m_groups = db["groups"]; 54 m_groupCategories = db["groupCategories"]; 55 m_articles = db["articles"]; 56 m_threads = db["threads"]; 57 //m_users = m_db["vibenews.users"]; 58 59 version (VibenewsLegacyUpgrades) { 60 // 07/2013: upgrade missing posterEmail field 61 foreach (bart; m_articles.find(["posterEmail": ["$exists": false]])) () @safe { 62 Article art; 63 art._id = bart["_id"].get!BsonObjectID; 64 art.headers = deserializeBson!(ArticleHeader[])(bart["headers"]); 65 string name, email; 66 decodeEmailAddressHeader(art.getHeader("From"), name, email); 67 m_articles.update(["_id": art._id], ["$set": ["posterEmail": email]]); 68 } (); 69 70 // 11/2013: fix missing Date headers 71 foreach (bart; m_articles.find(["headers": ["$not": ["$elemMatch": ["key": "Date"]]]], ["headers": true])) { 72 Article art; 73 art._id = bart["_id"].get!BsonObjectID; 74 art.headers = deserializeBson!(ArticleHeader[])(bart["headers"]); 75 assert(!art.hasHeader("Date")); 76 art.addHeader("Date", art._id.timeStamp.toRFC822DateTimeString()); 77 assert(art.hasHeader("Date")); 78 m_articles.update(["_id": art._id], ["$set": ["headers": art.headers]]); 79 } 80 } 81 82 83 // create indexes 84 import std.typecons : tuple; 85 //m_users.ensureIndex([tuple("email", 1)], IndexFlags.Unique); 86 m_groups.ensureIndex([tuple("name", 1)], IndexFlags.Unique); 87 m_threads.ensureIndex([tuple("groupId", 1)]); 88 m_threads.ensureIndex([tuple("firstArticleId", 1)]); 89 m_threads.ensureIndex([tuple("lastArticleId", -1)]); 90 m_articles.ensureIndex([tuple("id", 1)], IndexFlags.Unique); 91 foreach (grp; m_groups.find(Bson.emptyObject, ["name": 1])) 92 createGroupIndexes(grp["name"].get!string()); 93 94 // run fixups asynchronously 95 runTask({ 96 sleep(5.seconds); 97 98 // pre-0.8.3 did not write the posterEmail field correctly 99 foreach (bart; m_articles.find()) () @safe { 100 Article art; 101 art._id = bart["_id"].get!BsonObjectID; 102 art.headers = deserializeBson!(ArticleHeader[])(bart["headers"]); 103 string name, email; 104 decodeEmailAddressHeader(art.getHeader("From"), name, email); 105 m_articles.update(["_id": art._id], ["$set": ["posterEmail": email]]); 106 } (); 107 }); 108 } 109 110 @property VibeNewsSettings settings() { return m_settings; } 111 112 @property UserManController userManController() { return m_userdb; } 113 114 @property UserManAPI userManAPI() { return m_userapi; } 115 116 bool isEmailRegistered(string email) { return m_userdb.isEmailRegistered(email); } 117 118 User getUser(User.ID user_id) { return m_userdb.getUser(user_id); } 119 User getUserByEmail(string email) { return m_userdb.getUserByEmail(email); } 120 121 userman.db.controller.Group getAuthGroupByName(string name) { return m_userdb.getGroup(name); } 122 123 void enumerateUsers(int first_user, int max_count, void delegate(ref User usr) del) 124 { 125 m_userdb.enumerateUsers(first_user, max_count, del); 126 } 127 128 long getUserCount() { return m_userdb.getUserCount(); } 129 130 void updateUser(User user) { m_userdb.updateUser(user); } 131 void deleteUser(User.ID user_id) { m_userdb.deleteUser(user_id); } 132 133 void deleteOrphanedUsers() 134 { 135 auto limitdate = Clock.currTime() - (60 * 24).hours; 136 m_userdb.enumerateUsers(0, int.max, (ref usr) { 137 if (usr.id.bsonObjectIDValue.timeStamp > limitdate) return; 138 auto ac = m_articles.count(["posterEmail": Bson(usr.email), "active": Bson(true)]); 139 if (ac == 0) deleteUser(usr.id); 140 }); 141 } 142 143 void getUserMessageCount(string email, out ulong active_count, out ulong inactive_count) 144 { 145 active_count = m_articles.count(["posterEmail": Bson(email), "active": Bson(true)]); 146 inactive_count = m_articles.count(["posterEmail": Bson(email), "active": Bson(false)]); 147 } 148 149 /***************************/ 150 /* Group categories */ 151 /***************************/ 152 153 void enumerateGroupCategories(void delegate(size_t idx, GroupCategory) @safe del) 154 { 155 size_t idx = 0; 156 foreach (bc; m_groupCategories.find()) { 157 GroupCategory c; 158 deserializeBson(c, bc); 159 del(idx++, c); 160 } 161 } 162 163 GroupCategory getGroupCategory(BsonObjectID id) 164 { 165 auto bc = m_groupCategories.findOne(["_id": id]); 166 enforce(!bc.isNull(), "Invalid category id"); 167 GroupCategory cat; 168 deserializeBson(cat, bc); 169 return cat; 170 } 171 172 BsonObjectID createGroupCategory(string caption, int index) 173 { 174 GroupCategory cat; 175 cat._id = BsonObjectID.generate(); 176 cat.caption = caption; 177 cat.index = index; 178 m_groupCategories.insert(cat); 179 return cat._id; 180 } 181 182 void updateGroupCategory(BsonObjectID category, string caption, int index, BsonObjectID[] groups) 183 { 184 GroupCategory cat; 185 cat._id = category; 186 cat.caption = caption; 187 cat.index = index; 188 cat.groups = groups; 189 m_groupCategories.update(["_id": category], cat); 190 } 191 192 void deleteGroupCategory(BsonObjectID id) 193 { 194 m_groupCategories.remove(["_id": id]); 195 } 196 197 /***************************/ 198 /* Groups */ 199 /***************************/ 200 201 void enumerateGroups(void delegate(size_t idx, Group) @safe cb, bool allow_inactive = false) 202 { 203 Group group; 204 size_t idx = 0; 205 foreach (bg; m_groups.find()) { 206 if( !allow_inactive && !bg["active"].get!bool ) 207 continue; 208 deserializeBson(group, bg); 209 cb(idx++, group); 210 } 211 } 212 213 void enumerateNewGroups(SysTime date, void delegate(size_t idx, Group) @safe del, bool allow_inactive = false) 214 { 215 Group group; 216 Bson idmatch = Bson(BsonObjectID.createDateID(date)); 217 size_t idx = 0; 218 foreach (bg; m_groups.find(["_id": Bson(["$gte": idmatch])])) { 219 if( !allow_inactive && !bg["active"].get!bool ) 220 continue; 221 deserializeBson(group, bg); 222 del(idx++, group); 223 } 224 } 225 226 bool groupExists(string name, bool allow_inactive = false) 227 { 228 auto bg = m_groups.findOne(["name": Bson(name)], ["active": 1]); 229 return !bg.isNull() && (allow_inactive || bg["active"].get!bool); 230 } 231 232 Group getGroup(BsonObjectID id, bool allow_inactive = false) 233 { 234 auto bg = m_groups.findOne(["_id": Bson(id)]); 235 enforce(!bg.isNull() && (allow_inactive || bg["active"].get!bool), "Unknown group id!"); 236 Group ret; 237 deserializeBson(ret, bg); 238 return ret; 239 } 240 241 Group getGroupByName(string name, bool allow_inactive = false) 242 { 243 auto bg = m_groups.findOne(["name": Bson(name)]); 244 enforce(!bg.isNull() && (allow_inactive || bg["active"].get!bool), "Group "~name~" not found!"); 245 Group ret; 246 deserializeBson(ret, bg); 247 return ret; 248 } 249 250 void addGroup(Group g) 251 { 252 m_groups.insert(g); 253 createGroupIndexes(g.name); 254 } 255 256 void updateGroup(Group g) 257 { 258 m_groups.update(["_id": g._id], g); 259 } 260 261 void createGroupIndexes()(string grpname) 262 { 263 import std.typecons : tuple; 264 265 string egrp = escapeGroup(grpname); 266 string grpfield = "groups."~egrp; 267 m_articles.ensureIndex([tuple(grpfield~".articleNumber", 1)], IndexFlags.Sparse); 268 m_articles.ensureIndex([tuple(grpfield~".threadId", 1)], IndexFlags.Sparse); 269 } 270 271 /***************************/ 272 /* Threads */ 273 /***************************/ 274 275 long getThreadCount(BsonObjectID group) 276 { 277 return m_threads.count(["groupId": Bson(group), "firstArticleId": serializeToBson(["$ne": BsonObjectID()])]); 278 } 279 280 Thread getThread(BsonObjectID id) 281 { 282 auto bt = m_threads.findOne(["_id": id]); 283 enforce(!bt.isNull(), "Unknown thread id"); 284 Thread t; 285 deserializeBson(t, bt); 286 return t; 287 } 288 289 Thread getThreadForFirstArticle(string groupname, long articlenum) 290 { 291 auto art = m_articles.findOne(["groups."~escapeGroup(groupname)~".articleNumber": articlenum], ["_id": 1]); 292 enforce(!art.isNull(), "Invalid article group/number"); 293 auto bt = m_threads.findOne(["firstArticleId": art["_id"]]); 294 enforce(!bt.isNull(), "Article is not the first of any thread."); 295 Thread t; 296 deserializeBson(t, bt); 297 return t; 298 } 299 300 void enumerateThreads(BsonObjectID group, size_t skip, size_t max_count, void delegate(size_t, Thread) @safe del) 301 { 302 assert(skip <= int.max); 303 size_t idx = skip; 304 foreach( bthr; m_threads.find(["groupId": Bson(group), "firstArticleId": serializeToBson(["$ne": BsonObjectID()])], null, QueryFlags.None, cast(int)skip).sort(["lastArticleId": Bson(-1)]) ){ 305 Thread thr; 306 deserializeBson(thr, bthr); 307 del(idx, thr); 308 if( ++idx >= skip+max_count ) break; 309 } 310 } 311 312 long getThreadPostCount(BsonObjectID thread, string groupname = null) 313 { 314 if( !groupname ) groupname = getGroup(getThread(thread).groupId).name; 315 return m_articles.count(["groups."~escapeGroup(groupname)~".threadId" : Bson(thread), "active": Bson(true)]); 316 } 317 318 void enumerateThreadPosts(BsonObjectID thread, string groupname, size_t skip, size_t max_count, void delegate(size_t, Article) @safe del) 319 { 320 assert(skip <= int.max); 321 size_t idx = skip; 322 foreach (bart; m_articles.find(["groups."~escapeGroup(groupname)~".threadId": Bson(thread), "active": Bson(true)], null, QueryFlags.None, cast(int)skip, cast(int)max_count).sort(["_id": Bson(1)])) { 323 Article art; 324 deserializeBson(art, bart); 325 del(idx, art); 326 if( ++idx >= skip+max_count ) break; 327 } 328 } 329 330 long getThreadArticleIndex(BsonObjectID thread_id, long article_number, string group_name = null) 331 { 332 if( group_name.length == 0 ){ 333 auto thr = m_threads.findOne(["_id": thread_id], ["groupId": true]); 334 enforce(!thr.isNull()); 335 auto grp = m_groups.findOne(["_id": thr["groupId"]], ["name": true]); 336 enforce(!grp.isNull()); 337 338 group_name = grp["name"].get!string; 339 } 340 341 Bson[string] query; 342 query["groups."~escapeGroup(group_name)~".threadId"] = Bson(thread_id); 343 query["groups."~escapeGroup(group_name)~".articleNumber"] = serializeToBson(["$lt": article_number]); 344 query["active"] = Bson(true); 345 346 return m_articles.count(query); 347 } 348 349 /***************************/ 350 /* Articles */ 351 /***************************/ 352 353 Article getArticle(BsonObjectID id) 354 { 355 auto ba = m_articles.findOne(["_id": Bson(id), "active": Bson(true)]); 356 enforce(!ba.isNull(), "Unknown article id!"); 357 Article ret; 358 deserializeBson(ret, ba); 359 return ret; 360 } 361 362 Article getArticle(string id) 363 { 364 auto ba = m_articles.findOne(["id": Bson(id), "active": Bson(true)]); 365 enforce(!ba.isNull(), "Article "~id~" not found!"); 366 Article ret; 367 deserializeBson(ret, ba); 368 return ret; 369 } 370 371 Article getArticle(string groupname, long number, bool msgbdy = true) 372 { 373 auto egrp = escapeGroup(groupname); 374 auto nummatch = Bson(number); 375 auto ba = m_articles.findOne(["groups."~egrp~".articleNumber": nummatch, "active": Bson(true)], msgbdy ? null : ["message": 0]); 376 enforce(!ba.isNull(), "Article "~to!string(number)~" not found for group "~groupname~"!"); 377 if( !msgbdy ) ba["message"] = Bson(BsonBinData()); 378 Article ret; 379 deserializeBson(ret, ba); 380 return ret; 381 } 382 383 GroupRef[string] getArticleGroupRefs(BsonObjectID id) 384 { 385 auto art = m_articles.findOne(["_id": id], ["groups": 1]); 386 enforce(!art.isNull(), "Unknown article id!"); 387 GroupRef[string] ret; 388 deserializeBson(ret, art["groups"]); 389 return ret; 390 } 391 392 GroupRef[string] getArticleGroupRefs(string group_name, long article_number) 393 { 394 auto art = m_articles.findOne(["groups."~escapeGroup(group_name)~".articleNumber": article_number], ["groups": 1]); 395 enforce(!art.isNull(), "Unknown article id!"); 396 GroupRef[string] ret; 397 deserializeBson(ret, art["groups"]); 398 return ret; 399 } 400 401 void enumerateArticles(string groupname, void delegate(size_t idx, BsonObjectID _id, string msgid, long msgnum) @safe del) 402 { 403 auto egrp = escapeGroup(groupname); 404 auto numkey = "groups."~egrp~".articleNumber"; 405 auto numquery = serializeToBson(["$exists": true]); 406 size_t idx = 0; 407 foreach (ba; m_articles.find([numkey: numquery, "active": Bson(true)], ["_id": 1, "id": 1, "groups": 1]).sort([numkey: 1])) { 408 del(idx++, ba["_id"].get!BsonObjectID, ba["id"].get!string, ba["groups"][escapeGroup(groupname)]["articleNumber"].get!long); 409 } 410 } 411 412 void enumerateArticles(string groupname, long from, long to, void delegate(size_t idx, Article art) @safe del) 413 { 414 Article art; 415 string gpne = escapeGroup(groupname); 416 auto numkey = "groups."~gpne~".articleNumber"; 417 auto numquery = serializeToBson(["$gte": from, "$lte": to]); 418 size_t idx = 0; 419 foreach (ba; m_articles.find([numkey: numquery, "active": Bson(true)], ["message": 0]).sort([numkey: 1])) { 420 ba["message"] = Bson(BsonBinData(BsonBinData.Type.Generic, null)); 421 if( ba["groups"][gpne]["articleNumber"].get!long > to ) 422 break; 423 deserializeBson(art, ba); 424 del(idx++, art); 425 } 426 } 427 428 void enumerateNewArticles(string groupname, SysTime date, void delegate(size_t idx, BsonObjectID _id, string msgid, long msgnum) @safe del) 429 { 430 Bson idmatch = Bson(BsonObjectID.createDateID(date)); 431 Bson groupmatch = Bson(true); 432 auto egrp = escapeGroup(groupname); 433 auto numkey = "groups."~egrp~".articleNumber"; 434 auto query = serializeToBson(["_id" : Bson(["$gte": idmatch]), numkey: Bson(["$exists": groupmatch]), "active": Bson(true)]); 435 size_t idx = 0; 436 foreach (ba; m_articles.find(query, ["_id": 1, "id": 1, "groups": 1]).sort([numkey: 1])) { 437 del(idx++, ba["_id"].get!BsonObjectID, ba["id"].get!string, ba["groups"][egrp]["articleNumber"].get!long); 438 } 439 } 440 441 void enumerateAllArticlesBackwards(string groupname, int first, int count, void delegate(ref Article art) @safe del) 442 { 443 auto egrp = escapeGroup(groupname); 444 auto numkey = "groups."~egrp~".articleNumber"; 445 logDebug("%s %s", groupname, egrp); 446 size_t idx = 0; 447 foreach (ba; m_articles.find([numkey: ["$exists": true]], null, QueryFlags.None, first, count).sort([numkey: -1])) { 448 Article art; 449 deserializeBson(art, ba); 450 del(art); 451 if (idx++ == count-1) break; 452 } 453 } 454 455 ulong getAllArticlesCount(string groupname) 456 { 457 return m_articles.count(["groups."~escapeGroup(groupname)~".articleNumber": ["$exists": true]]); 458 } 459 460 void enumerateActiveArticlesBackwards(string groupname, int first, int count, void delegate(ref Article art) @safe del) 461 { 462 auto egrp = escapeGroup(groupname); 463 auto numkey = "groups."~egrp~".articleNumber"; 464 logDebug("%s %s", groupname, egrp); 465 size_t idx = 0; 466 foreach (ba; m_articles.find([numkey: Bson(["$exists": Bson(true)]), "active": Bson(true)], null, QueryFlags.None, first, count).sort([numkey: -1])) { 467 Article art; 468 deserializeBson(art, ba); 469 del(art); 470 if (idx++ == count-1) break; 471 } 472 } 473 474 ulong getActiveArticlesCount(string groupname) 475 { 476 return m_articles.count(["groups."~escapeGroup(groupname)~".articleNumber": Bson(["$exists": Bson(true)]), "active": Bson(true)]); 477 } 478 479 void postArticle(ref Article art, User.ID user_id) 480 { 481 AntispamMessage msg = toAntispamMessage(art); 482 bool revoke = false; 483 outer: 484 foreach( flt; m_settings.spamFilters ) { 485 auto status = flt.determineImmediateSpamStatus(msg); 486 final switch (status) { 487 case SpamAction.amnesty: revoke = false; break outer; 488 case SpamAction.pass: break; 489 case SpamAction.revoke: revoke = true; break; 490 case SpamAction.block: throw new Exception("Article is deemed to be abusive. Rejected."); 491 } 492 } 493 494 495 string relay_version = art.getHeader("Relay-Version"); 496 string posting_version = art.getHeader("Posting-Version"); 497 string from = art.getHeader("From"); 498 string from_name, from_email; 499 decodeEmailAddressHeader(from, from_name, from_email); 500 string date = art.getHeader("Date"); 501 string[] newsgroups = commaSplit(art.getHeader("Newsgroups")); 502 string subject = art.subject; 503 string messageid = art.getHeader("Message-ID"); 504 string path = art.getHeader("Path"); 505 string reply_to = art.getHeader("In-Reply-To"); 506 if( reply_to.length == 0 ){ 507 auto refs = art.getHeader("References").split(" "); 508 if( refs.length > 0 ) reply_to = refs[$-1]; 509 } 510 511 if (messageid.length) art.id = messageid; 512 else art.addHeader("Message-ID", art.id); 513 if (!date.length) art.addHeader("Date", Clock.currTime(UTC()).toRFC822DateTimeString()); 514 assert(art.hasHeader("Date")); 515 art.messageLength = art.message.length; 516 art.messageLines = countLines(art.message); 517 art.posterEmail = from_email; 518 519 enforce(art.message.length > 0, "You must enter a message."); 520 521 // validate sender 522 if (user_id == User.ID.init) { 523 enforce(!isEmailRegistered(from_email), new NNTPStatusException(NNTPStatus.articleRejected, "Need to log in to send from a registered email address.")); 524 } else { 525 User usr; 526 User lusr = m_userdb.getUser(user_id); 527 try usr = m_userdb.getUserByEmail(from_email); 528 catch (Exception) {} 529 enforce(usr.id == user_id, new NNTPStatusException(NNTPStatus.articleRejected, "Not allowed to post with a foreign email address, please use "~lusr.email~".")); 530 } 531 532 // validate groups 533 foreach( grp; newsgroups ){ 534 auto bgpre = m_groups.findOne(["name": grp]); 535 enforce(!bgpre.isNull(), new NNTPStatusException(NNTPStatus.articleRejected, "Invalid group: "~grp)); 536 enforce(isAuthorizedForWritingGroup(user_id, grp), new NNTPStatusException(NNTPStatus.articleRejected, "Not allowed to post in "~grp)); 537 } 538 539 foreach( grp; newsgroups ){ 540 auto bgpre = m_groups.findAndModify(["name": grp], ["$inc": ["articleNumberCounter": 1]], ["articleNumberCounter": 1]); 541 if( bgpre.isNull() ) continue; // ignore non-existant groups 542 m_groups.update(["name": grp], ["$inc": ["articleCount": 1]]); 543 logDebug("GRP: %s", bgpre.toJson()); 544 545 // try to find the thread of any reply-to message 546 BsonObjectID threadid; 547 auto rart = reply_to.length ? m_articles.findOne(["id": reply_to]) : Bson(null); 548 if( !rart.isNull() && !rart["groups"].isNull() ){ 549 auto gref = rart["groups"][escapeGroup(grp)]; 550 if( !gref.isNull() ) threadid = gref["threadId"].get!BsonObjectID; 551 } 552 553 // create a new thread if necessary 554 if( threadid == BsonObjectID() ){ 555 Thread thr; 556 thr._id = BsonObjectID.generate(); 557 thr.groupId = bgpre["_id"].get!BsonObjectID; 558 thr.subject = subject; 559 thr.firstArticleId = art._id; 560 thr.lastArticleId = art._id; 561 m_threads.insert(thr); 562 threadid = thr._id; 563 } else { 564 m_threads.update(["_id": threadid], ["$set": ["lastArticleId": art._id]]); 565 } 566 567 GroupRef grpref; 568 grpref.articleNumber = bgpre["articleNumberCounter"].get!long + 1; 569 grpref.threadId = threadid; 570 art.groups[escapeGroup(grp)] = grpref; 571 m_groups.update(["name": Bson(grp), "maxArticleNumber": serializeToBson(["$lt": grpref.articleNumber])], ["$set": ["maxArticleNumber": grpref.articleNumber]]); 572 } 573 574 m_articles.insert(art); 575 576 markAsSpam(art._id, revoke); 577 578 runTask({ 579 bool async_revoke = revoke; 580 foreach (flt; m_settings.spamFilters) { 581 auto status = flt.determineAsyncSpamStatus(msg); 582 final switch (status) { 583 case SpamAction.amnesty: markAsSpam(art._id, false); return; 584 case SpamAction.pass: break; 585 case SpamAction.revoke: async_revoke = true; break; 586 case SpamAction.block: markAsSpam(art._id, true); return; 587 } 588 if (status == SpamAction.amnesty) break; 589 else if (status != SpamAction.pass) { 590 return; 591 } 592 } 593 if (async_revoke != revoke) 594 markAsSpam(art._id, async_revoke); 595 }); 596 } 597 598 void deactivateArticle(BsonObjectID artid) 599 { 600 auto oldart = m_articles.findAndModify(["_id": artid], ["$set": ["active": false]]); 601 if( !oldart["active"].get!bool ) return; // was already deactivated 602 603 // update the group counters 604 foreach (string gname, grp; oldart["groups"]) { 605 // update the group 606 string numfield = "groups."~gname~".articleNumber"; 607 auto groupname = Bson(unescapeGroup(gname)); 608 auto articlequery = Bson([numfield: Bson(["$exists": Bson(true)]), "active": Bson(true)]); 609 m_groups.update(["name": groupname], ["$inc": ["articleCount": -1]]); 610 auto g = m_groups.findOne(["name": groupname]); 611 auto num = grp["articleNumber"]; 612 if( g["minArticleNumber"] == num ){ 613 auto minorder = serializeToBson([numfield: 1]); 614 auto minart = m_articles.findOne(Bson(["query": articlequery, "orderby": minorder])); 615 long newnum; 616 if (minart.isNull()) newnum = long.max; 617 else newnum = minart["groups"][gname]["articleNumber"].get!long; 618 m_groups.update(["name": groupname, "minArticleNumber": num], ["$set": ["minArticleNumber": newnum]]); 619 } 620 if( g["maxArticleNumber"] == num ){ 621 auto maxorder = serializeToBson([numfield: -1]); 622 auto maxart = m_articles.findOne(Bson(["query": articlequery, "orderby": maxorder])); 623 long newnum; 624 if (!maxart.isNull()) newnum = maxart["groups"][gname]["articleNumber"].get!long; 625 else newnum = 0; 626 m_groups.update(["name": groupname, "maxArticleNumber": num], ["$set": ["maxArticleNumber": newnum]]); 627 } 628 629 // update the matching thread 630 auto threadid = grp["threadId"]; 631 auto newfirstart = m_articles.findOne(serializeToBson(["query": ["groups."~gname~".threadId": threadid, "active": Bson(true)], "orderby": ["_id": Bson(1)]]), ["_id": true]); 632 auto newfirstid = newfirstart.isNull() ? BsonObjectID() : newfirstart["_id"].get!BsonObjectID; 633 m_threads.update(["_id": threadid, "firstArticleId": oldart["_id"]], ["$set": ["firstArticleId": newfirstid]]); 634 auto newlastart = m_articles.findOne(serializeToBson(["query": ["groups."~gname~".threadId": threadid, "active": Bson(true)], "orderby": ["_id": Bson(-1)]]), ["_id": true]); 635 auto newlastid = newfirstart.isNull() ? BsonObjectID() : newlastart["_id"].get!BsonObjectID; 636 m_threads.update(["_id": threadid, "lastArticleId": oldart["_id"]], ["$set": ["lastArticleId": newlastid]]); 637 } 638 } 639 640 void activateArticle(BsonObjectID artid) 641 { 642 auto oldart = m_articles.findAndModify(["_id": artid], ["$set": ["active": true]]); 643 if (oldart["active"].get!bool) return; // was already activated by someone else 644 645 // update the group counters 646 foreach (string gname, gref; oldart["groups"]) { 647 auto num = gref["articleNumber"]; 648 auto threadid = gref["threadId"]; 649 string numfield = "groups."~gname~".articleNumber"; 650 auto groupname = Bson(unescapeGroup(gname)); 651 m_groups.update(["name": groupname], ["$inc": ["articleCount": 1]]); 652 m_groups.update(["name": groupname, "maxArticleNumber": Bson(["$lt": num])], ["$set": ["maxArticleNumber": num]]); 653 m_groups.update(["name": groupname, "minArticleNumber": Bson(["$gt": num])], ["$set": ["minArticleNumber": num]]); 654 655 auto first_matches = serializeToBson([["firstArticleId": Bson(["$gt": oldart["_id"]])], ["firstArticleId": Bson(BsonObjectID())]]); 656 m_threads.update(["_id": threadid, "$or": first_matches], ["$set": ["firstArticleId": oldart["_id"]]]); 657 m_threads.update(["_id": threadid, "lastArticleId": Bson(["$lt": oldart["_id"]])], ["$set": ["lastArticleId": oldart["_id"]]]); 658 } 659 } 660 661 void deleteArticle(BsonObjectID artid) 662 { 663 deactivateArticle(artid); 664 m_articles.remove(["_id": artid]); 665 } 666 667 void reclassifySpam() 668 { 669 foreach (flt; m_settings.spamFilters) 670 flt.resetClassification(); 671 672 foreach (bart; m_articles.find()) { 673 auto art = deserializeBson!Article(bart); 674 foreach (flt; m_settings.spamFilters) { 675 auto msg = toAntispamMessage(art); 676 if (art.hasHeader("X-Spam-Status")) { 677 flt.classify(msg, art.getHeader("X-Spam-Status").icmp("yes") == 0); 678 } else if (art.active) flt.classify(msg, false); 679 } 680 } 681 } 682 683 void markAsSpam(BsonObjectID article, bool is_spam) 684 { 685 if (is_spam) deactivateArticle(article); 686 else activateArticle(article); 687 688 auto art = deserializeBson!Article(m_articles.findOne(["_id": article])); 689 690 auto msg = toAntispamMessage(art); 691 bool was_spam = false; 692 if (art.hasHeader("X-Spam-Status")) { 693 was_spam = art.getHeader("X-Spam-Status").icmp("yes") == 0; 694 if (was_spam == is_spam) return; 695 foreach (flt; m_settings.spamFilters) 696 flt.classify(msg, was_spam, true); 697 } 698 foreach (flt; m_settings.spamFilters) 699 flt.classify(msg, is_spam, false); 700 701 art.setHeader("X-Spam-Status", is_spam ? "Yes" : "No"); 702 m_articles.update(["_id": article], ["$set": ["headers": art.headers]]); 703 } 704 705 // deletes all inactive articles from the group 706 void purgeGroup(string name) 707 { 708 m_articles.remove(["active": Bson(false), "groups."~escapeGroup(name)~".articleNumber": Bson(["$exists": Bson(true)])]); 709 } 710 711 bool isAuthorizedForReadingGroup(User.ID user, string groupname) 712 { 713 import std.range : chain; 714 auto grp = m_groups.findOne(["name": groupname], ["readOnlyAuthTags": 1, "readWriteAuthTags": 1]); 715 if (grp.isNull()) return false; 716 if (grp["readOnlyAuthTags"].length == 0) return true; 717 enforce(user != User.ID.init, "Group does not allow public access."); 718 auto usr = m_userdb.getUser(user); 719 foreach (ag; chain(grp["readOnlyAuthTags"].get!(Bson[]), grp["readWriteAuthTags"].get!(Bson[]))) { 720 auto agid = () @trusted { return getAuthGroupByName(ag.get!string).id; } (); 721 foreach (gid; usr.groups) 722 if (gid == agid) 723 return true; 724 } 725 return false; 726 } 727 728 bool isAuthorizedForWritingGroup(User.ID user, string groupname) 729 { 730 auto grp = m_groups.findOne(["name": groupname], ["readOnlyAuthTags": 1, "readWriteAuthTags": 1]); 731 if (grp.isNull()) return false; 732 if (grp["readOnlyAuthTags"].length == 0 && grp["readWriteAuthTags"].length == 0) return true; 733 enforce(user != User.ID.init, "Group does not allow public access."); 734 auto usr = m_userdb.getUser(user); 735 foreach (ag; grp["readWriteAuthTags"]) { 736 auto agid = () @trusted { return getAuthGroupByName(ag.get!string).id; } (); 737 foreach (gid; usr.groups) 738 if (gid == agid) 739 return true; 740 } 741 return false; 742 } 743 744 /***************************/ 745 /* DB Repair */ 746 /***************************/ 747 748 void repairGroupNumbers() 749 { 750 foreach (grp; m_groups.find()) { 751 logInfo("Repairing group numbers of %s:", grp["name"].get!string); 752 auto grpname = escapeGroup(grp["name"].get!string); 753 auto numbername = "groups."~grpname~".articleNumber"; 754 755 auto artquery = serializeToBson([numbername: Bson(["$exists": Bson(true)]), "active": Bson(true)]); 756 auto artcnt = m_articles.count(artquery); 757 logInfo(" article count: %s", artcnt); 758 m_groups.update(["_id": grp["_id"], "articleCount": grp["articleCount"]], ["$set": ["articleCount": artcnt]]); 759 760 auto first_art = m_articles.findOne(Bson(["$query": artquery, "$orderby": serializeToBson([numbername: 1])]), ["groups": 1]); 761 auto last_art = m_articles.findOne(Bson(["$query": artquery, "$orderby": serializeToBson([numbername: -1])]), ["groups": 1]); 762 763 auto first_art_num = first_art.isNull() ? 1 : first_art["groups"][grpname]["articleNumber"].get!long; 764 auto last_art_num = last_art.isNull() ? 0 : last_art["groups"][grpname]["articleNumber"].get!long; 765 assert(first_art.isNull() == last_art.isNull()); 766 767 logInfo(" first article: %s", first_art_num); 768 logInfo(" last article: %s", last_art_num); 769 770 m_groups.update(["_id": grp["_id"], "minArticleNumber": grp["minArticleNumber"]], ["$set": ["minArticleNumber": first_art_num]]); 771 m_groups.update(["_id": grp["_id"], "maxArticleNumber": grp["maxArticleNumber"]], ["$set": ["maxArticleNumber": last_art_num]]); 772 } 773 774 logInfo("Repair of group numbers finished."); 775 } 776 777 void repairThreads() 778 { 779 m_threads.remove(Bson.emptyObject); 780 781 foreach (ba; m_articles.find(["active": Bson(true)]).sort(["_id": Bson(1)])) () @safe { 782 Article a; 783 deserializeBson(a, ba); 784 785 // extract reply-to and subject headers 786 string repl = a.getHeader("In-Reply-To"); 787 string subject = a.subject; 788 if( repl.length == 0 ){ 789 auto refs = a.getHeader("References").split(" "); 790 if( refs.length > 0 ) repl = refs[$-1]; 791 } 792 auto rart = repl.length ? m_articles.findOne(["id": repl]) : Bson(null); 793 794 foreach (gname; trustedRange(() @system => a.groups.byKey())) ()@safe{ 795 auto grp = m_groups.findOne(["name": unescapeGroup(gname)], ["_id": true]); 796 //if( grp.isNull() ) continue; 797 798 BsonObjectID threadid; 799 800 // try to find the thread of any reply-to message 801 if( !rart.isNull() ){ 802 auto gref = rart["groups"][gname]; 803 if( !gref.isNull() && m_threads.count(["_id": gref["threadId"]]) > 0 ) 804 threadid = gref["threadId"].get!BsonObjectID; 805 } 806 807 // otherwise create a new thread 808 if( threadid == BsonObjectID() ){ 809 Thread thr; 810 thr._id = BsonObjectID.generate(); 811 thr.groupId = grp["_id"].get!BsonObjectID; 812 thr.subject = subject; 813 thr.firstArticleId = a._id; 814 thr.lastArticleId = a._id; 815 m_threads.insert(thr); 816 817 threadid = thr._id; 818 } else { 819 m_threads.update(["_id": threadid], ["$set": ["lastArticleId": a._id]]); 820 } 821 822 m_articles.update(["_id": a._id], ["$set": ["groups."~gname~".threadId": threadid]]); 823 }(); 824 }(); 825 } 826 827 } 828 829 AntispamMessage toAntispamMessage(in ref Article art) 830 @safe { 831 AntispamMessage msg; 832 foreach (hdr; art.headers) msg.headers[hdr.key] = hdr.value; 833 msg.message = art.message; 834 msg.peerAddress = art.peerAddress; 835 return msg; 836 } 837 838 839 string escapeGroup(string str) 840 @safe { 841 return str.translate(['.': '#'], null); 842 } 843 844 string unescapeGroup(string str) 845 @safe { 846 return str.translate(['#': '.'], null); 847 } 848 849 string[] commaSplit(string str) 850 @safe { 851 string[] ret; 852 while(true){ 853 auto idx = str.countUntil(','); 854 if( idx > 0 ){ 855 ret ~= strip(str[0 .. idx]); 856 str = str[idx+1 .. $]; 857 } else { 858 ret ~= strip(str); 859 break; 860 } 861 } 862 return ret; 863 } 864 865 long countLines(const(ubyte)[] str) 866 @safe { 867 long sum = 1; 868 while(str.length > 0){ 869 auto idx = str.countUntil('\n'); 870 if( idx < 0 ) break; 871 str = str[idx+1 .. $]; 872 sum++; 873 } 874 return sum; 875 } 876 877 878 struct Article { 879 BsonObjectID _id; 880 string id; // "<asdasdasd@server.com>" 881 bool active = true; 882 string posterEmail; 883 GroupRef[string] groups; // num[groupname] 884 ArticleHeader[] headers; 885 ubyte[] message; 886 long messageLength; 887 long messageLines; 888 string[] peerAddress; // list of hops starting from the original client 889 890 @safe: 891 892 @property string subject() const @trusted { return sanitize(decodeEncodedWords(getHeader("Subject"))); } 893 894 string getHeader(string name) 895 const { 896 foreach( h; headers ) 897 if( icmp(h.key, name) == 0 ) 898 return h.value; 899 return null; 900 } 901 902 bool hasHeader(string name) 903 const { 904 foreach( h; headers ) 905 if( icmp(h.key, name) == 0 ) 906 return true; 907 return false; 908 } 909 910 void addHeader(string name, string value) 911 { 912 assert(!hasHeader(name)); 913 headers ~= ArticleHeader(encode(name), encode(value)); 914 } 915 916 void setHeader(string name, string value) 917 { 918 foreach (ref h; headers) 919 if (icmp(h.key, name) == 0) { 920 h.value = encode(value); 921 return; 922 } 923 addHeader(name, value); 924 } 925 926 static string encode(string str) 927 { 928 size_t first_non_ascii = size_t.max, last_non_ascii = 0; 929 foreach( i; 0 .. str.length ) 930 if( (str[i] & 0x80) ){ 931 if( first_non_ascii == size_t.max ) 932 first_non_ascii = i; 933 last_non_ascii = i; 934 } 935 if( last_non_ascii < first_non_ascii ) return str; 936 937 auto non_ascii = str[first_non_ascii .. last_non_ascii+1]; 938 939 return format("%s=?UTF-8?B?%s?=%s", str[0 .. first_non_ascii], 940 cast(const(char)[])Base64.encode(cast(const(ubyte)[])non_ascii), 941 str[last_non_ascii+1 .. $]); 942 } 943 } 944 945 struct GroupRef { 946 long articleNumber; 947 BsonObjectID threadId; 948 } 949 950 struct ArticleHeader { 951 string key; 952 string value; 953 } 954 955 struct GroupCategory { 956 BsonObjectID _id; 957 string caption; 958 int index; 959 BsonObjectID[] groups; 960 } 961 962 struct Group { 963 BsonObjectID _id; 964 bool active = true; 965 string name; 966 string caption; 967 string description; 968 long articleCount = 0; 969 long minArticleNumber = 1; 970 long maxArticleNumber = 0; 971 long articleNumberCounter = 0; 972 string[] readOnlyAuthTags; 973 string[] readWriteAuthTags; 974 } 975 976 struct Thread { 977 BsonObjectID _id; 978 BsonObjectID groupId; 979 string subject; 980 BsonObjectID firstArticleId; 981 BsonObjectID lastArticleId; 982 } 983 984 enum authGroupPrefix = "vibenews.authgroup."; 985 986 987 private auto trustedRange(R)(scope R delegate() rng) 988 @trusted { 989 static struct TR { 990 R _rng; 991 bool empty() @trusted { return _rng.empty; } 992 auto front() @trusted { return _rng.front; } 993 void popFront() @trusted { _rng.popFront(); } 994 } 995 996 return TR(rng()); 997 }