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